Node.js 事件循环详解
在 Node.js 的世界里,事件循环(Event Loop)是一个核心概念,它使得 Node.js 能够处理高并发和异步 I/O
操作,非常适合处理 I/O
密集型任务。本文将详细解释 Node.js 的事件循环机制,包括其背景、工作原理、六个阶段以及实际应用。
事件循环的背景
首先,我们需要理解为什么 Node.js 需要事件循环。Node.js 采用单线程模型,这意味着所有的 JavaScript 代码都在一个主线程上运行。这种设计虽然简单,但会导致一些问题,比如无法充分利用多核 CPU 的性能。为了解决这个问题,Node.js 引入了事件循环和异步 I/O
模型。

在事件循环模型中,主线程将所有任务都放在循环队列中,然后由底层的 libuv
库从循环事件队列中取出任务分配给不同的线程去处理(在事件处理过程中,它会智能地将一些涉及到IO、网络通信等耗时比较长的操作,交由worker threads去执行)。当这些任务完成后,回调函数会被添加到事件队列中等待执行。主线程在空闲时会检查事件队列,如果有回调函数,就将其取出执行。这样,虽然 JavaScript 代码只在一个主线程上运行,但可以通过异步 I/O
和事件循环实现高并发。
上图中阻塞任务被底层线程池处理,然后处理的响应通过回调函数添加到事件队列中等待执行。
事件循环的工作原理
Node.js 的事件循环共有六个阶段,每个阶段都有特定的任务类型。这六个阶段按顺序一直循环执行,直到事件处理完成。这六个阶段分别是:
-
timers
:此阶段执行setTimeout()
和setInterval()
的回调。 -
pending callbacks
:执行延迟到下一个循环迭代的I/O
回调。 -
idle
,prepare
:仅供node
内部使用。 -
poll
:检索新的I/O
事件;执行与I/O
相关的回调(几乎所有回调,除了关闭回调,计时器回调和setImmediate()
);在适当的情况下调用node
在阶段尾部安排的回调。 -
check
:在此阶段执行setImmediate()
的回调。 -
close callbacks
:执行关闭回调,如:socket.on('close', …)
。
当事件循环进入某个阶段时,它会按照特定的顺序执行该阶段的所有任务(每一个阶段都会维护一个事件队列。可以把每一个圈想象成一个事件队列)。例如,在 poll
阶段,它会依次执行所有的 I/O
回调,直到清空队列。然后,事件循环会进入下一个阶段,重复这个过程。

上图中,几个色块的含义:
-
main
:启动入口文件,运行主函数 -
event loop
:检查是否要进入事件循环-
检查其他线程里是否还有待处理事项
-
检查其他任务是否还在进行中(比如计时器、文件读取操作等任务是否完成)
-
有以上情况,进入事件循环,运行其他任务
-
事件循环的过程:沿着从
timers
到close callbacks
这个流程,走一圈。回到event loop
看是否结束,没结束再走一圈。
-
-
-
over
:所有的事情都完毕,结束
图中灰色的圈跟操作系统有关系,不是本章解析重点。重点关注黄色、橙色的圈还有中间橘黄的方框。
我们把每一圈的事件循环叫做「一次循环」、又叫「一次轮询」、又叫「一次Tick」。
Node.js 中的宏任务和微任务
-
macro-task 大概包括:
-
setTimeout
-
setInterval
-
setImmediate
-
script(整体代码)
-
I/O 操作等
-
-
micro-task 大概包括:
-
process.nextTick(与普通微任务有区别, 在微任务队列执行之前执行)
-
new Promise().then(回调)等.
-
timers 阶段
timers
并非真正意义上的队列,他内部存放的是计时器。
每次到达这个队列,会检查计时器线程内的所有计时器,计时器线程内部多个计时器按照时间顺序排序。
检查过程:将每一个计时器按顺序分别计算一遍,计算该计时器开始计时的时间到当前时间是否满足计时器的间隔参数设定(比如 1000ms,计算计时器开始计时到现在是否有 1m)。当某个计时器检查通过,则执行其回调函数。
poll 阶段
如果 poll
中有回调函数需要执行,依次执行回调,直到清空队列。如果 poll
中没有回调函数需要执行,已经是空队列了。则会在这里等待,等待其他队列中出现回调,
如果其他队列中出现回调,则从 poll
向下到 over
,结束该阶段,进入下一阶段。如果其他队列也都没有回调,则持续在 poll
队列等待,直到任何一个队列出现回调后再进行工作。
check 阶段
检查阶段(使用 setImmediate
的回调会直接进入这个队列)
真正的队列,里边扔的就是待执行的回调函数的集合。类似 [fn,fn]
这种形式的。
每次到达 check
这个队列后,立即按顺序执行回调函数即可【类似于 [fn1,fn2].forEach((fn)=>fn())
的感觉】
所以说,setImmediate
不是一个计时器的概念。
setImmediate
和 setTimeout(0)
谁更快。
-
setImmediate
的回调是异步的,和setTimeout
回调性质一致。 -
setImmediate
回调在check
队列,setTimeout
回调在timers
队列(概念意义,实际在计时器线程,只是setTimeout
在timers
队列做检查调用而已。详细看timers
的工作原理)。 -
setImmediate
函数调用后,回调函数会立即push
到check
队列,并在下次eventloop
时被执行。setTimeout
函数调用后,计时器线程增加一个定时器任务,下次eventloop
时会在timers
阶段里检查判断定时器任务是否到达时间,到了则执行回调函数。 -
综上,
setImmediate
的运算速度比setTimeout(0)
的要快,因为setTimeout
还需要开计时器线程,并增加计算的开销。
二者的效果差不多。但是执行顺序不定。
只比较这两个函数的情况下,二者的执行顺序最终结果取决于当下计算机的运行环境以及运行速度。
// ------------------setTimeout测试:-------------------
let i= 0;
console.time('setTimeout');
function test(){
if(i < 1000) {
setTimeout(test,0)
i++
} else {
console.timeEnd('setTimeout');
}
}
test();
// ------------------setImmediate测试:-------------------
let i= 0;
console.time('setImmediate');
function test(){
if (i < 1000) {
setImmediate(test)
i++
} else {
console.timeEnd('setImmediate');
}
}
test();
可见 setTimeout 远比 setImmediate 耗时多得多。这是因为 setTimeout
不仅有主代码执行的时间消耗。还有在 timers
队列里,对于计时器线程中各个定时任务的计算时间。
poll
队列的面试题(考察 timers
、poll
和 check
的执行顺序)
// 说说下边代码的执行顺序,先打印哪个?
const fs = require('fs')
fs.readFile('./poll.js', () => {
setTimeout(() => console.log('setTimeout'), 0)
setImmediate(() => console.log('setImmediate'))
})
上边这种代码逻辑,不管执行多少次,肯定都是先执行 setImmediate
。
因为 fs
各个函数的回调是放在 poll
队列的。当程序 holding
在 poll
队列后,出现回调立即执行。
回调内执行 setTimeout
和 setImmediate
的函数后,check
队列立即增加了回调。
回调执行完毕,轮询检查其他队列有内容,程序结束 poll
队列的 holding
向下执行。
check
是 poll
阶段的紧接着的下一个。所以在向下的过程中,先执行 check
阶段内的回调,也就是先打印 setImmediate
。
到下一轮循环,到达 timers
队列,检查 setTimeout
计时器符合条件,则定时器回调被执行。
nextTick 与 Promise
说完宏任务,接下来说下微任务
-
二者都是「微队列」,执行异步微任务。
-
二者不是事件循环的一部分,程序也不会开启额外的线程去处理相关任务。(理解:
promise
里发网络请求,那是网络请求开的网络线程,跟Promise
这个微任务没关系)微队列设立的目的就是让一些任务「马上」、「立即」优先执行。 -
在每个事件循环的 当前阶段 中,Node.js 会先执行所有的
nextTick
回调,然后执行所有的 微任务,最后才会进入下一个阶段执行 宏任务(例如setTimeout
、setInterval
等)。
process.nextTick(() => {})
Promise.resolve().then(() => {})
process.nextTick()
与 微任务的执行顺序:
-
nextTick
在事件循环中的执行优先级process.nextTick()
是事件循环中的 最高优先级。它的回调会在当前执行栈(同步代码)执行完毕后立即执行,而不等到事件循环的其他阶段(如I/O
阶段、setImmediate
等)开始。 -
nextTick
和 微任务队列的关系nextTick
回调的优先级比 微任务队列 中的回调还要高。微任务队列包含了Promise
的.then()
和.catch()
回调等。当
nextTick
被调用时,当前的操作栈(执行栈)会先执行nextTick
队列中的所有回调,然后再处理微任务队列中的回调。 -
nextTick
在微任务之前执行process.nextTick()
会在每次事件循环的 任何微任务之前 执行。如果在某个阶段(如Promise
回调)中又调用了process.nextTick()
,它会立即执行,不会让出控制权给其他微任务。(这里存在问题,不符合实际执行结果)这意味着,如果你在某个
nextTick
回调中再次调用process.nextTick()
,这个新添加的nextTick
回调会被立即执行,直到nextTick
队列清空。
如何参与事件循环?
事件循环中,每执行一个回调前,先按序清空一次 nextTick
和 promise
。
// 执行宏任务
setImmediate(() => {
console.log('10-setImmediate');
});
// 执行 nextTick 队列中的所有回调后,然后再处理微任务队列中的回调
Promise.resolve().then(() => {
console.log('5-promise 1 start');
Promise.resolve().then(() => {
console.log('8-promise in promise');
})
// 注意
// process.nextTick() 会在每次事件循环的微任务执行中。如果在 Promise 回调中又调用了 process.nextTick(),它不会立即执行,会等其他微任务执行完成后,再执行
process.nextTick(() => {
console.log('9-nextTick in promise');
})
// 微任务执行
console.log('6-promise 1 end')
})
// 当前执行栈中的所有同步代码执行完后,Node.js 会先执行 nextTick 队列中的回调
process.nextTick(() => {
console.log('2-nextTick 1 start');
Promise.resolve().then(() => {
console.log('7-promise in nextTick');
})
// process.nextTick() 会在每次事件循环的 任何微任务之前执行。如果在 process.nextTick() 中又调用了 process.nextTick(),它会立即执行,不会让出控制权给其他微任务
process.nextTick(() => {
console.log('4-nextTick in nextTick');
})
console.log('3-nextTick 1 end');
})
// 同步代码执行
console.log('1-global');
// 1-global
// 2-nextTick 1 start
// 3-nextTick 1 end
// 4-nextTick in nextTick
// 5-promise 1 start
// 6-promise 1 end
// 7-promise in nextTick
// 8-nextTick in promise
// 9-promise in promise
// 10-setImmediate
|
基于上边的说法,有两个问题待思考和解决:
-
每走一个异步宏任务队列就查一遍
nextTick
和promise
?还是每执行完 宏任务队列里的一个回调函数就查一遍呢? -
如果在
poll
的holding
阶段,插入一个nextTick
或者Promise
的回调,会立即停止poll
队列的holding
去执行回调吗?
setTimeout(() => {
console.log('16-setTimeout 100');
setTimeout(() => {
console.log('21-setTimeout 100 - 0');
process.nextTick(() => {
console.log('22-nextTick in setTimeout 100 - 0');
})
}, 0)
// 这次setImmediate比setTimeout(0)先执行的原因是:流程从timers向后走到check队列,已经有了setImmediate的回调,立即执行。
setImmediate(() => {
console.log('19-setImmediate in setTimeout 100');
process.nextTick(() => {
console.log('20-nextTick in setImmediate in setTimeout 100');
})
});
process.nextTick(() => {
console.log('17-nextTick in setTimeout100');
})
Promise.resolve().then(() => {
console.log('18-promise in setTimeout100');
})
}, 100)
const fs = require('fs')
fs.readFile('./1.poll.js', () => {
console.log('14-poll 1');
process.nextTick(() => {
console.log('15-nextTick in poll');
})
})
setTimeout(() => {
console.log('7-setTimeout 0');
process.nextTick(() => {
console.log('8-nextTick in setTimeout');
})
}, 0)
setTimeout(() => {
console.log('9-setTimeout 1');
Promise.resolve().then(() => {
console.log('11-promise in setTimeout1');
})
process.nextTick(() => {
console.log('10-nextTick in setTimeout1');
})
}, 1)
setImmediate(() => {
console.log('12-setImmediate');
process.nextTick(() => {
console.log('13-nextTick in setImmediate');
})
});
process.nextTick(() => {
console.log('2-nextTick 1');
process.nextTick(() => {
console.log('3-nextTick 2');
})
})
console.log('1-global');
Promise.resolve().then(() => {
console.log('4-promise 1');
process.nextTick(() => {
console.log('6-nextTick in promise');
})
// 注意,这里比 nextTick 先执行
Promise.resolve().then(() => {
console.log('5-promise in promise');
})
})
/** 执行顺序如下
1-global
2-nextTick 1
3-nextTick 2
4-promise 1
5-promise in promise
6-nextTick in promise
7-setTimeout 0
8-nextTick in setTimeout
9-setTimeout 1
10-nextTick in setTimeout1
11-promise in setTimeout1
12-setImmediate
13-nextTick in setImmediate
14-poll 1
15-nextTick in poll
16-setTimeout 100
17-nextTick in setTimeout100
18-promise in setTimeout100
19-setImmediate in setTimeout 100
20-nextTick in setImmediate in setTimeout 100
21-setTimeout 100 - 0
22-nextTick in setTimeout 100 - 0
*/
-
global :主线程同步任务,率先执行没毛病
-
nextTick 1:执行异步宏任务之前,清空异步微任务,nextTick 优先级高,先行一步
-
nextTick 2:执行完上边这句代码,又一个 nextTick 微任务,立即率先执行
-
promise 1:执行异步宏任务之前,清空异步微任务,Promise 的优先级低,所以在 nextTick 完了以后立即执行
-
promise in promise:注意,在 Promise 回调中调用 nextTick 不会立即执行。优先级比其它 Promise 回调低
-
nextTick in promise:清空 Promise 队列的过程中,遇到 nextTick 微任务,需要等 Promise 任务执行完成后,再执行 netxTick 任务。
-
setTimeout 0:解释第一个问题。没有上边的 nextTick 和 promise,只有 setTimeout 和 setImmediate 时他俩的执行顺序不一定。有了以后肯定是 0 先开始。可见,执行一个宏队列之前,就先按顺序检查并执行了 nextTick 和 promise 微队列。等微队列全部执行完毕,setTimeout(0) 的时机也成熟了,就被执行。
-
nextTick in setTimeout:执行完上边这句代码,又一个 nextTick 微任务,立即率先执行 【这种回调函数里的微任务,我不能确定是紧随同步任务执行的;还是放到微任务队列,等下一个宏任务执行前再清空的他们。但是顺序看上去和立即执行他们一样。不过我比较倾向于是后者:先放到微任务队列等待,下一个宏任务执行前清空他们。】
-
setTimeout 1:因为执行微任务耗费时间,导致此时 timers 里判断两个 0 和 1 的 setTimeout 计时器已经结束,所以两个 setTimeout 回调都已加入队列并被执行
-
nextTick in setTimeout1:执行完上边这句代码,又一个 nextTick 微任务,立即率先执行 【可能是下一个宏任务前清空微任务】
-
promise in setTimeout1:执行完上边这句代码,又一个 Promise 微任务,立即紧随执行 【可能是下一个宏任务前清空微任务】
-
setImmediate:poll 队列回调时机未到,先行向下到 check 队列,清空队列,立即执行 setImmediate 回调
-
nextTick in setImmediate:执行完上边这句代码,又一个 nextTick 微任务,立即率先执行 【可能是下一个宏任务前清空微任务】
-
poll 1:poll 队列实际成熟,回调触发,同步任务执行。
-
nextTick in poll :执行完上边这句代码,又一个 nextTick 微任务,立即率先执行 【可能是下一个宏任务前清空微任务】
-
setTimeout 100:定时器任务到达时间,执行回调。并在回调里往微任务推入了 nextTick、Promise,往宏任务的 check 里推入了 setImmediate 的回调。并且也开启了计时器线程,往 timers 里增加了下一轮回调的可能。
-
nextTick in setTimeout100:宏任务向下前,率先执行定时器回调内新增的微任务-nextTick 【这里就能确定了,是下一个宏任务前清空微任务的流程】
-
promise in setTimeout100:紧接着执行定时器回调内新增的微任务-Promise 【清空完 nextTick 清空 Promise 的顺序】
-
setImmediate in setTimeout 100:这次 setImmediate 比 setTimeout(0) 先执行的原因是:流程从 timers 向后走到 check 队列,已经有了 setImmediate 的回调,立即执行。
-
nextTick in setImmediate in setTimeout 100:执行完上边这句代码,又一个 nextTick 微任务,下一个宏任务前率先清空微任务
-
setTimeout 100 - 0:轮询又一次回到 timers,执行 100-0 的回调。
-
nextTick in setTimeout 100 - 0:执行完上边这句代码,又一个 nextTick 微任务,下一个宏任务前率先清空微任务。