事件循环
js 作为主要运行在浏览器的脚本语言,主要用途之一是操作 DOM。如果同时有两个线程对同一个 dom 进行操作,这时浏览器应该听哪个线程的,如何判断优先级?JS 是单线程的,为了防止一个函数执行时间过长阻塞后面的代码,Event Loop 方案应运而生。
① 浏览器中的事件循环
JS 依靠执行栈和任务队列(task queue)来执行代码,整个执行过程便是事件循环过程。一个线程只有一个事件循环,但可以拥有多个任务队列。任务队列分为 macro-task(宏任务)与 micro-task(微任务)。在最新标准中,它们被分别称为 task 与 jobs。只要异步任务有了运行结果,就会进入任务队列当中。
- macro-tasks: script、
setTimeout
、setInterval
、setImmediate
、 I/O、UI render - micro-task:
process.nextTick
、Promise
、async/await
(本质也是 promise ),MutationObserver
( html5 新特性)

所有同步任务都在主线程上执行,形成一个执行栈。当主线程中的执行栈为空时,会检查任务队列是否为空,如果为空则继续检查;如不为空,把微任务队列执行清空。微任务队列清空后,进入宏任务队列,取队列的第一项任务放入 Stack(栈)中执行,执行完成后,查看微任务队列是否有任务,有的话,清空微任务队列。若微任务在执行过程中产生了新的微任务,则继续执行微任务,微任务执行完毕后,再回到宏任务中进行下一轮循环。如此反复循环,直至清空所有的任务。举例:
1 | async function async1() { |
② async/await 执行顺序
async
隐式返回 Promise 作为结果的函数,即当 await 后面的函数执行完毕时,await 会产生一个微任务(Promise.then 是微任务)。但要注意这个微任务产生的时机,它是执行完 await 后,直接跳出 async 函数执行其它代码(此处就是协程的运作,A 暂停执行,控制权交给 B)。其它代码执行完毕后,再回到 async 函数执行剩下的代码,然后把 await 后面的代码注册到微任务队列当中。举例如下:
1 | console.log('script start') |
分析这段代码:
- 执行代码,输出
script start
; - 执行
async1()
,会调用async2()
,然后输出async2 end
,此时将会保留 async1 函数的上下文,然后跳出 async1 函数; - 遇到
setTimeout
,产生一个宏任务; - 执行 Promise,输出
Promise
。遇到 then,产生第一个微任务; - 继续执行代码,输出
script end
; - 代码逻辑执行完毕 (当前宏任务执行完毕),开始执行当前宏任务产生的微任务队列,输出
promise1
,该微任务遇到 then,产生一个新的微任务 - 执行产生的微任务,输出
promise2
,当前微任务队列执行完毕。执行权回到 async1 - 执行 await,实际上会产生一个 promise 返回,即
1 | let promise_ = new Promise((resolve,reject){ resolve(undefined)}) |
执行完成,执行 await 后面的语句,输出 async1 end
- 最后,执行下一个宏任务,即执行
setTimeout
,输出setTimeout
注意:新版 chrome 浏览器中不是如上打印的,await 在优化后的 chrome 下变得更快,输出为:
1 | // script start => async2 end => Promise => script end => async1 end => promise1 => promise2 => setTimeout |
但这种做法其实违反了规范,当然规范也是可以更改的,这是 V8 团队的一个 PR ,目前新版打印已经修改。知乎上也有相关讨论: www.zhihu.com/question/26…
我们可以分 2 种情况来理解:
- 如果 await 后面直接跟一个变量,如
await 1;
这种情况相当于直接把 await 后面的代码注册为一个微任务,可以简单理解为 promise.then(await 下面的代码)。然后跳出 async1 函数,执行其它代码,当遇到 promise 函数时,会注册promise.then()
函数到微任务队列,注意此时微任务队列里面已经存在 await 后面的微任务。所以这种情况会先执行 await 后面的代码(async1 end),再执行 async1 函数后面注册的微任务代码 (promise1, promise2)。 - 如果 await 后面跟的是一个异步函数的调用,比如上面的代码,将代码改成这样:
1 | console.log('script start') |
此时执行完 await 并不先把 await 后面的代码注册到微任务队列中去,而是执行完 await 后,直接跳出 async1 函数,执行其他代码。然后遇到 promise 时,把 promise.then
注册为微任务。其他代码执行完毕后,需要回到 async1 函数去执行剩下的代码,然后把 await 后面的代码注册到微任务队列当中,注意此时微任务队列中是有之前注册的微任务的。所以这种情况会先执行 async1 函数之外的微任务 (promise1,promise2),然后才执行 async1 内注册的微任务 (async1 end)。可以理解为,这种情况下,await 后面的代码会在本轮循环的最后被执行。
③ node 中的事件循环
除了浏览器,node 也有事件循环。事件循环是 node 处理非阻塞 I/O 操作的机制,node 中事件循环的实现是依靠 libuv 引擎。由于 node 11 之后,事件循环的一些原理发生了变化,这里就以新的标准去讲,最后再列上变化点让大家了解前因后果。
宏任务和微任务
node 中也有宏任务和微任务,与浏览器中的事件循环类似,其中,
macro-task 大概包括:
- setTimeout
- setInterval
- setImmediate
- script(整体代码)
- I/O 操作等。
micro-task 大概包括:
- process.nextTick(与普通微任务有区别,在微任务队列执行之前执行)
- new Promise().then(回调) 等。
node 事件循环整体理解
先看一张官网的 node 事件循环简化图:
图中的每个框被称为事件循环机制的一个阶段,每个阶段都有一个 FIFO 队列来执行回调。虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,直到队列用尽或最大回调数已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一阶段。
因此,从上面这个简化图中,我们可以分析出 node 的事件循环的阶段顺序为:
输入数据阶段 (incoming data)->轮询阶段 (poll)->检查阶段 (check)->关闭事件回调阶段 (close callback)->定时器检测阶段 (timers)->I/O 事件回调阶段 (I/O callbacks)->闲置阶段 (idle, prepare)->轮询阶段…
阶段概述
- 定时器检测阶段 (timers):本阶段执行 timer 的回调,即 setTimeout、setInterval 里面的回调函数。
- I/O事件回调阶段(I/O callbacks):执行延迟到下一个循环迭代的 I/O 回调,即上一轮循环中未被执行的一些 I/O 回调。
- 闲置阶段 (idle, prepare):仅系统内部使用。
- 轮询阶段 (poll):检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和 setImmediate() 调度的之外),其余情况 node 将在适当的时候在此阻塞。
- 检查阶段 (check):setImmediate() 回调函数在这里执行
- 关闭事件回调阶段 (close callback):一些关闭的回调函数,如:socket.on(‘close’, …)。
三大重点阶段
日常开发中的绝大部分异步任务都是在 poll、check、timers 这 3 个阶段处理的。
① timers:该阶段会执行 setTimeout 和 setInterval 回调,并且是由 poll 阶段控制的。同样,在 Node 中定时器指定的时间也不是准确时间,只能是尽快执行。
② poll:这是一个至关重要的阶段,poll 阶段的执行逻辑流程图如下:
如果当前已经存在定时器,而且有定时器到时间了,拿出来执行,eventLoop 将回到 timers 阶段。
如果没有定时器,会去看回调函数队列。
- 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制
- 如果 poll 队列为空时,会有两件事发生
- 如果有 setImmediate 回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调
- 如果没有 setImmediate 回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去,一段时间后自动进入 check 阶段。
③ check:这是一个比较简单的阶段,直接执行 setImmdiate 的回调。
process.nextTick
process.nextTick 是一个独立于 eventLoop 的任务队列。
在每一个 eventLoop 阶段完成后会去检查 nextTick 队列,如果里面有任务,会让这部分任务优先于微任务执行。看一个例子:
1 | setImmediate(() => { |
- 在 node11 之前,因为每一个 eventLoop 阶段完成后会去检查 nextTick 队列,如果里面有任务,会让这部分任务优先于微任务执行,因此上述代码是先进入 check 阶段,执行所有 setImmediate,完成之后执行 nextTick 队列,最后执行微任务队列,因此输出为
timeout1=>timeout2=>timeout3=>timeout4=>next tick1=>next tick2=>promise resolve
- 在 node11 之后,process.nextTick 是微任务的一种,因此上述代码是先进入 check 阶段,执行一个 setImmediate 宏任务,然后执行其微任务队列,再执行下一个宏任务及其微任务,因此输出为
timeout1=>next tick1=>promise resolve=>timeout2=>next tick2=>timeout3=>timeout4
④ node 版本差异说明
这里主要说明的是 node11 前后的差异,因为 node11 之后一些特性已经向浏览器看齐了,总的变化一句话来说就是,如果是 node11 版本一旦执行一个阶段里的一个宏任务 (setTimeout,setInterval 和 setImmediate) 就立刻执行对应的微任务队列,一起来看看吧~
timers 阶段的执行时机变化
1 | setTimeout(()=>{ |
- 如果是 node11 版本一旦执行一个阶段里的一个宏任务 (setTimeout,setInterval 和 setImmediate) 就立刻执行微任务队列,这就跟浏览器端运行一致,最后的结果为
timer1=>promise1=>timer2=>promise2
- 如果是 node10 及其之前版本要看第一个定时器执行完,第二个定时器是否在完成队列中。
- 如果是第二个定时器还未在完成队列中,最后的结果为
timer1=>promise1=>timer2=>promise2
- 如果是第二个定时器已经在完成队列中,则最后的结果为
timer1=>timer2=>promise1=>promise2
- 如果是第二个定时器还未在完成队列中,最后的结果为
check 阶段的执行时机变化
1 | setImmediate(() => console.log('immediate1')); |
- 如果是 node11 后的版本,会输出
immediate1=>immediate2=>promise resolve=>immediate3=>immediate4
- 如果是 node11 前的版本,会输出
immediate1=>immediate2=>immediate3=>immediate4=>promise resolve
nextTick 队列的执行时机变化
1 | setImmediate(() => console.log('timeout1')); |
- 如果是 node11 后的版本,会输出
timeout1=>timeout2=>next tick=>timeout3=>timeout4
- 如果是 node11 前的版本,会输出
timeout1=>timeout2=>timeout3=>timeout4=>next tick
以上几个例子,你应该就能清晰感受到它的变化了,反正记着一个结论,如果是 node11 版本一旦执行一个阶段里的一个宏任务 (setTimeout,setInterval 和 setImmediate) 就立刻执行对应的微任务队列。
node 和 浏览器 eventLoop 的主要区别:两者最主要的区别在于浏览器中的微任务是在每个相应的宏任务中执行的,而 nodejs 中的微任务是在不同阶段之间执行的。