Node与浏览器中EventLoop有什么区别

浏览器中的EventLoop

众所周知, JS 是单线程运行的,在代码执行时,通过将不同函数的执行上下文压入执行栈中来保证代码的有序执行。在执行 同步代码 时,如果遇到 异步事件JS 引擎并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当 异步事件 执行完毕后,再将 异步事件 对应的回调加入到一个任务队列中等待执行。该异步任务队列可以分为 宏任务队列微任务队列 ,当当前执行栈中的事件执行完毕后,JS 引擎首先会判断微任务队列中是否有任务可以执行,如果有就将微任务队首的事件压入栈中执行。当微任务队列中的任务都执行完成后再去执行宏任务队列中的任务。

EventLoop

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
2
3
4
5
6
7
8
9
async function test() {
return 1 // async的函数会在这里帮我们隐式使用Promise.resolve(1)
}
// 等价于下面的代码
function test() {
return new Promise(function(resolve, reject) {
resolve(1)
})
}

可见 async 只是一个语法糖,只是帮助我们返回一个 Promise 而已

await

await 表示等待,是右侧「表达式」的结果,这个表达式的计算结果可以是 Promise 对象的值或者一个函数的值(换句话说,就是没有特殊限定)。并且只能在带有 async 的内部使用

使用 await 时,会从右往左执行,当遇到 await 时,会阻塞函数内部处于它后面的代码,去执行该函数外部的同步代码,当外部同步代码执行完毕,再回到该函数内部执行剩余的代码, 并且当 await 执行完毕之后,会先处理微任务队列的代码

下面来看一个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout( function () {
console.log('setTimeout')
}, 0 )
async1();
new Promise( function ( resolve ) {
console.log('promise1')
resolve();
} ).then( function () {
console.log('promise2')
} )
console.log('script end')

使用事件循环机制分析:

  • 首先执行同步代码,执行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()其实是返回一个Promiseawait - async2()相当于获取它的值,其实就相当于这段代码Promise.resolve(undefined).then((undefined) => {}),所以.then会被推入微任务队列。接下来处理微任务队列,依次打印async1 endpromise2,后面一个.then不会有任何打印,但是会执行
  • 进入第二次事件循环,执行宏任务队列, 打印console.log('setTimeout')

浏览器中的执行结果:

1
2
3
4
5
6
7
8
9
script start
async1 start
async2
promise1
script end
async1 end
promise2
undefined
setTimeout

Node 中的 EventLoop

Node 中的 EventLoop 和浏览器中的是完全不相同的东西。
Node 的 EventLoop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。

  1. Timers(计时器阶段):初次进入事件循环,会从计时器阶段开始。此阶段会判断是否存在过期的计时器回调(包含 setTimeoutsetInterval ),如果存在则会执行所有过期的计时器回调,执行完毕后,如果回调中触发了相应的微任务,会接着执行所有微任务,执行完微任务后再进入 Pending callbacks 阶段。
  2. Pending callbacks:执行推迟到下一个循环迭代的I / O回调(系统调用相关的回调)。
  3. Idle/Prepare:仅供内部使用。
  4. Poll(轮询阶段)
    • 当回调队列不为空时:会执行回调,若回调中触发了相应的微任务,这里的微任务执行时机和其他地方有所不同,不会等到所有回调执行完毕后才执行,而是针对每一个回调执行完毕后,就执行相应微任务。执行完所有的回调后,变为下面的情况。
    • 当回调队列为空时(没有回调或所有回调执行完毕):但如果存在有计时器( setTimeoutsetIntervalsetImmediate )没有执行,会结束轮询阶段,进入 Check 阶段。否则会阻塞并等待任何正在执行的I/O操作完成,并马上执行相应的回调,直到所有回调执行完毕。
  5. Check(查询阶段):会检查是否存在 setImmediate 相关的回调,如果存在则执行所有回调,执行完毕后,如果回调中触发了相应的微任务,会接着执行所有微任务,执行完微任务后再进入 Close callbacks 阶段。
  6. Close callbacks:执行一些关闭回调,比如socket.on(‘close’, …)等。

setTimeout 和 setImmediate

二者非常相似,区别主要在于调用时机不同。

  • setImmediate 设计在 poll 阶段完成时执行,即 check 阶段;
  • setTimeout 设计在 poll 阶段为空闲时,且设定时间到达后执行,但它在 timer 阶段执行
1
2
3
4
5
6
setTimeout(() => {
console.log('setTimeout')
}, 0)
setImmediate(() => {
console.log('setImmediate')
})

对于以上代码来说,setTimeout 可能执行在前,也可能执行在后

  • 首先 setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的
  • 进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行 setTimeout 回调
  • 那么如果准备时间花费小于 1ms,那么就是 setImmediate 回调先执行了

当然在某些情况下,他们的执行顺序一定是固定的,比如以下代码:

1
2
3
4
5
6
7
8
9
const fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0)
setImmediate(() => {
console.log('immediate')
})
})

在上述代码中,setImmediate 永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了。

上面都是 宏任务 的执行情况,对于 微任务 来说,它会在以上每个阶段完成前 清空 微任务 队列,下图中的 Tick 就代表了 微任务。

1
2
3
4
5
6
setTimeout(() => {
console.log('timer')
}, 0)
Promise.resolve().then(function() {
console.log('promise')
})

对于以上代码来说,其实和浏览器中的输出是一样的,微任务microtask 永远执行在 宏任务macrotask 前面。

process.nextTick

最后来看 Node 中的 process.nextTick,这个函数其实是独立于 EventLoop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 微任务microtask 执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
})
})
})
})

对于以上代码,永远都是先把 nextTick 全部打印出来。

参考


Node与浏览器中EventLoop有什么区别
https://toflying.com/2022/07/04/4-js-event-loop/
作者
KingChen
发布于
2022年7月4日
许可协议