前言
知其然,更知其所以然,举一反三,融会贯通
事件循环,英文Evente Loop,是一种解决JavaScript在单线程运行时不阻塞的机制,也是JavaScript代码运行的核心,在JavaScript中我们经常会谈到同步和异步,其中异步就依赖于事件循环而实现。
每次看有关事件循环相关资料的时候,都会收获新的观点和萌发出一些疑惑,主要是不同的文章对Eevent Loop的讲解似乎都有不同之处,我想可能还是自己没有真正的理解透,在这里再次梳理温顾一遍,加深印象!
为什么要理解Event Loop?
前端为什么要深入了解事件循环? 我觉得有以下几点吧
- 它是JavaScript代码运行的核心机制,不懂可能都不好意思,毕竟前端的核心语言是JavaScript
- 现如今代码中存在大量的同步和异步代码,非常多的bug可能都是由于同步异步代码混合导致的逻辑错误,理解透有助于帮助解决bug
- 基本面试必备,这个就不说了,懂不懂,面试官一问便知
javascript事件循环
JavaScript中有一个主线程(mian thread) 和 调用栈(call stack), 所有的任务都会被放入到调用栈等待主线程执行。
主线程
js是单线程的,所有同步代码最终会在主线程被执行,即主线程会不断的从调用栈中读取事件,执行调用栈中所有的同步代码。
调用栈
调用栈是一种数据结构,采用的是后进先出的规则,当执行函数的时候,会被添加到栈的顶部,当函数被执行完成后,就会从调用栈的顶部被移出(同时也伴随内存回收),直到调用栈被清空。
同步和异步
JavaScript单线程任务可以分为同步任务和异步任务,同步任务会在调用栈中按照顺序等待主线程的依次执行,异步任务会在异步任务有了结果之后(异步任务会有单独的模块处理),将注册的回调函数加入到任务队列(task queue),等调用栈中所有任务执行完毕之后(主线程空闲的时候),再被读取到调用栈内等待主线程的执行。
多线程
浏览器中可以执行同步和异步代码,归功于浏览器是多线程的,js中同步代码是在主线程(js引擎线程)被执行,而异步任务是在浏览器其他线程中被执行,所以不会影响到主线程,比如定时器线程,网络线程,webapi线程等等...
任务队列
任务队列也是一种数据结构,遵循先进先出的规则,在任务队列中,任务分为两种:微任务和宏任务,微任务执行的优先级高于宏任务。
执行栈在执行完同步任务后,检查执行栈是否为空,如果为空,则去任务队列查看是否存在微任务
-
如果存在,则一次性把所有的微任务(microTask)读取到调用栈执行, 然后再去读取宏任务(macroTask)
-
如果不存在,则把宏任务(macroTask)读取到调用栈(应该是单个读取),等待主线程执行
每次单个宏任务执行完毕后,就会去检查任务队列是否存在微任务,如果存在,则会按照先进先出的规则全部执行完微任务,然后在执行宏任务,如此反复循环,每一次循环就是一个事件周期。
在浏览器中会有单独的一个内部线程来不断的检测任务队列中是否有新的任务
执行微任务时,如果产生新的微任务队列,那么新产生的微任务会在当前事件周期去执行,而不会等到下一个事件周期
宏任务(MacroTask)
JavaScript中的宏任务主要包括
- script全部代码
- setTimeout
- setInterval
- setImmediate(node环境)
- postMessage
- I/O
- UI Rendering
微任务(MicroTask)
JavaScript中的微任务主要包括
- promise.then
- MutationObserver
- process.nextTick(node环境)
为什么将任务队列分为宏任务和微任务呢?
JavaScript在遇到异步任务时,会将此任务交给其它线程来执行,(比如遇到setTimeout任务,会交给定时器触发线程去处理,待计时结束,会将定时器的回调函数放入到宏任务队列等待主线程读取执行),主线程继续执行后面的同步任务。
对于微任务,比如promise.then, 当执行promise.then的时候,浏览器引擎不会将异步任务交给其它线程去执行,而是将任务的回调存入一个微任务队列中,当执行栈中所有任务执行完毕之后,就去执行promise.then所在的微任务队列
那么,它们的本质区别是:
- 微任务:不需要特定的异步线程去执行,没有明确的异步任务去执行,只有回调
- 宏任务:需要特定的线程去执行,有明确的异步任务去执行,有回到
node事件循环
JavaScript和nodejs都是基于v8引擎的,浏览器中包含的异步方式在nodejs中也是一样的,除此之外,nodejs中还有一些其它的异步形式
- 文件 I/O
- setImmediate: 与setTimeout设置0ms类似,在某些同步任务完成后立即执行
- process.nextTick(): 在某些同步任务完成后立即执行
- server.close、socket.on('close',...)等:关闭回调
这些异步任务的执行就需要依靠nodejs的事件循环机制了
nodejs中的event loop和浏览器中的是完全不同的东西,nodejs使用v8引擎作为js的解析引擎,而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层的特性,对外提供统一的api,事件循环机制也是在它里面实现的,可参考下图:
事件循环流程
其中libuv引擎中的事件循环可以分为6个阶段,它们会按照顺序反复运行,每当进入某一阶段的时候,都会从对应的回调队列中取出函数去执行,当队列为空或者执行的回调函数数量达到系统设定的阈值,就会进入到下一阶段。
整个流程分为6个阶段,当这个6个阶段执行完一次之后,才可以算的上执行了一次event loop的循环过程。
- timers阶段:执行timer(setTimeout setInterval)的回调,由poll阶段控制
- I/O 回调阶段:主要执行系统级别的回调函数
- idel,prepare阶段:仅nodejs内容使用,可以忽略
- poll阶段:轮询等待新的链接和请求事件,执行I/O回调
- check阶段:执行setImmeidate的回调
- close callbacks阶段:执行关闭请的回调函数,比如:socket.on('close')
注意:上面每个阶段都会去执行完当前阶段的任务队列,然后继续执行当前阶段的微任务队列,只有当前阶段所有微任务都执行完了,才会进入下一个阶段,这也是与浏览器中逻辑差异比较大的地方。
宏任务和微任务
- 宏任务:setTimeout,setInterval,setImmediate,script(整体代码), I/O操作
- 微任务:promise.then, process.nextTick
其中setTimeout和setImmediate很相似,主要的区别在于调用的时机不同:
- setImmediate:在poll阶段完成时执行,即check阶段
- setTimeout:在poll阶段为空闲时,且设定时间到达后执行,它是在timer阶段执行
上面提到的process.nextTick(),它是node中新引入的一个任务队列,它会在各个阶段结束时,在进入下一阶段之前立即执行, 它会优先于promise.then的回调执行
node与浏览器事件循环差异
node与浏览器的事件循环的目的都是为了解决异步的任务,但是他们还是存在比较大的差异
- nodejs:microTask在事件循环各个阶段之间执行
- javascript:microTask在事件循环的macroTask执行之后执行
node和浏览器的事件循环流程对比如下:
- 执行全局的script代码(与浏览器类似)
- 微任务队列清空:node清空微任务队列的手法比较特别,在浏览器中,我们只有一个微任务需要接受处理,但在node中,有两类微任务:nextTick队列和其他队列, 其中nextTick队列,专门用来收敛process.nextTick派发的异步任务,在事件循序清空队列时,优先清空nextTick中的任务,随后再清除其它的微任务
- 执行macroTask(宏任务):node执行的方式与浏览器不同,在浏览器中,我们每次出队并执行一个宏任务,在node中,我们每次尝试清空当前阶段对应宏任务队列里的所有任务
- 从步骤3开始:会进入3 --> 2 --> 3 --> 2..的循环