Node与浏览器中EventLoop有什么区别
浏览器中的EventLoop
众所周知,
JS
是单线程运行的,在代码执行时,通过将不同函数的执行上下文压入执行栈中来保证代码的有序执行。在执行同步代码
时,如果遇到异步事件
,JS
引擎并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当异步事件
执行完毕后,再将异步事件
对应的回调加入到一个任务队列中等待执行。该异步任务队列可以分为宏任务队列
和微任务队列
,当当前执行栈中的事件执行完毕后,JS
引擎首先会判断微任务队列
中是否有任务可以执行,如果有就将微任务队首
的事件压入栈中执行。当微任务队列
中的任务都执行完成后再去执行宏任务队列
中的任务。
Event Loop 执行顺序如下所示:
- 整体script代码进入执行栈执行,依次执行所有同步代码。
- 然后开始执行异步代码,将遇到的异步代码分为宏任务(macro-task)、微任务(micro-stak),各自产生的回调分别放入各自的队列(宏任务队列微任务队列是不同的队列)。
- 此时等待主线执行栈执行同步代码结束后,开始依次执行微任务队列中代码直到微任务队列为空,再执行宏任务队列中下一个宏任务,依次循环。
注意:宏任务、微任务中又可能包括宏、微任务,仍然按照以上规则处理,顺序不能乱。
宏任务与微任务
宏任务
# | 浏览器 | Node |
---|---|---|
I/O | ✅ | ✅ |
setTimeout | ✅ | ✅ |
setInterval | ✅ | ✅ |
setImmediate | ❌ | ✅ |
requestAnimationFrame | ✅ | ❌ |
微任务
# | 浏览器 | Node |
---|---|---|
process.nextTick | ❌ | ✅ |
MutationObserver | ✅ | ❌ |
Promise.then catch finally | ✅ | ✅ |
async/await
async
当我们在函数前使用 async
的时候,使得该函数返回的是一个 Promise
对象
1 |
|
可见 async
只是一个语法糖,只是帮助我们返回一个 Promise
而已
await
await
表示等待,是右侧「表达式」的结果,这个表达式的计算结果可以是 Promise
对象的值或者一个函数的值(换句话说,就是没有特殊限定)。并且只能在带有 async
的内部使用
使用 await
时,会从右往左执行,当遇到 await
时,会阻塞函数内部处于它后面的代码,去执行该函数外部的同步代码,当外部同步代码执行完毕,再回到该函数内部执行剩余的代码, 并且当 await
执行完毕之后,会先处理微任务队列的代码。
下面来看一个栗子:
1 |
|
使用事件循环机制分析:
- 首先执行同步代码,执行
console.log('script start')
- 遇到
setTimeout
,会被推入宏任务队列 - 执行
async1()
, 它也是同步的,只是返回值是Promise
,在内部首先执行console.log('async1 start')
- 然后执行
async2()
, 然后会打印console.log('async2')
- 从右到左会执行, 当遇到
await
的时候,阻塞后面的代码,会将console.log('async1 end')
放入微任务队列中,然后去外部执行同步代码 - 进入
new Promise
,打印console.log('promise1')
- 将
.then
放入事件循环的微任务队列 - 继续执行,打印
console.log('script end')
- 外部同步代码执行完毕,接着回到
async1()
内部, 由于async2()
其实是返回一个Promise
,await - async2()
相当于获取它的值,其实就相当于这段代码Promise.resolve(undefined).then((undefined) => {})
,所以.then
会被推入微任务队列。接下来处理微任务队列,依次打印async1 end
和promise2
,后面一个.then
不会有任何打印,但是会执行 - 进入第二次事件循环,执行宏任务队列, 打印
console.log('setTimeout')
浏览器中的执行结果:
1 |
|
Node 中的 EventLoop
Node
中的 EventLoop 和浏览器中的是完全不相同的东西。Node
的 EventLoop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。
- Timers(计时器阶段):初次进入事件循环,会从计时器阶段开始。此阶段会判断是否存在过期的计时器回调(包含
setTimeout
和setInterval
),如果存在则会执行所有过期的计时器回调,执行完毕后,如果回调中触发了相应的微任务,会接着执行所有微任务,执行完微任务后再进入 Pending callbacks 阶段。 - Pending callbacks:执行推迟到下一个循环迭代的I / O回调(系统调用相关的回调)。
- Idle/Prepare:仅供内部使用。
- Poll(轮询阶段):
- 当回调队列不为空时:会执行回调,若回调中触发了相应的微任务,这里的微任务执行时机和其他地方有所不同,不会等到所有回调执行完毕后才执行,而是针对每一个回调执行完毕后,就执行相应微任务。执行完所有的回调后,变为下面的情况。
- 当回调队列为空时(没有回调或所有回调执行完毕):但如果存在有计时器(
setTimeout
、setInterval
和setImmediate
)没有执行,会结束轮询阶段,进入 Check 阶段。否则会阻塞并等待任何正在执行的I/O操作完成,并马上执行相应的回调,直到所有回调执行完毕。
- Check(查询阶段):会检查是否存在
setImmediate
相关的回调,如果存在则执行所有回调,执行完毕后,如果回调中触发了相应的微任务,会接着执行所有微任务,执行完微任务后再进入 Close callbacks 阶段。 - Close callbacks:执行一些关闭回调,比如socket.on(‘close’, …)等。
setTimeout 和 setImmediate
二者非常相似,区别主要在于调用时机不同。
setImmediate
设计在 poll 阶段完成时执行,即 check 阶段;setTimeout
设计在 poll 阶段为空闲时,且设定时间到达后执行,但它在 timer 阶段执行
1 |
|
对于以上代码来说,setTimeout
可能执行在前,也可能执行在后
- 首先
setTimeout(fn, 0) === setTimeout(fn, 1)
,这是由源码决定的 - 进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行
setTimeout
回调 - 那么如果准备时间花费小于 1ms,那么就是
setImmediate
回调先执行了
当然在某些情况下,他们的执行顺序一定是固定的,比如以下代码:
1 |
|
在上述代码中,setImmediate
永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate
回调,所以就直接跳转到 check 阶段去执行回调了。
上面都是 宏任务 的执行情况,对于 微任务 来说,它会在以上每个阶段完成前 清空 微任务 队列,下图中的 Tick 就代表了 微任务。
1 |
|
对于以上代码来说,其实和浏览器中的输出是一样的,微任务microtask 永远执行在 宏任务macrotask 前面。
process.nextTick
最后来看 Node 中的 process.nextTick
,这个函数其实是独立于 EventLoop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 微任务microtask 执行。
1 |
|
对于以上代码,永远都是先把 nextTick 全部打印出来。