Node.js 事件循环负责在你的同步代码执行完之后,决定接下来运行哪个回调。它按固定的周期依次经过各个阶段:timers(setTimeout、setInterval)、pending callbacks、poll(文件读取、网络数据等 I/O)、check(setImmediate)和 close callbacks。每个回调执行完后,Node 会先清空 process.nextTick 队列,再清空 Promise 队列,所以它们总是在事件循环继续之前执行。
它依次打印 sync、nextTick、promise、setTimeout,和代码书写的顺序正好相反。同步代码总是最先执行完,然后 Node 清空 nextTick 队列,再清空 Promise 队列,之后事件循环才开始运转并到达 timers 阶段。
事件循环的各个阶段
Node 的事件循环来自 libuv,这是 Node 用来处理 I/O 的 C 语言库。在第一轮开始之前,它会先运行已经到期的定时器,之后每一轮按顺序运行以下阶段:
- Pending callbacks:从上一轮推迟下来的少量系统回调,比如某些网络错误。
- Poll:新的 I/O 事件及其回调(文件读取完成、socket 收到数据)。如果没有其他任务排队,事件循环会在这里等待。
- Check:
setImmediate的回调。 - Close callbacks:
'close'事件,比如一个被销毁的 socket。 - Timers:到期的
setTimeout和setInterval回调。
很多教程把 timers 列为每一轮的第一个阶段,这是 Node 20 版本(libuv 1.45)之前的做法。无论哪种说法,回调的执行顺序都一样:一轮的 timers 之后接着下一轮的 poll。
还有两个队列不属于任何阶段:nextTick 队列和微任务队列(Promise 回调和 queueMicrotask)。无论在哪个阶段,Node 都会在每一个回调执行完后清空这两个队列,先清空 nextTick。只要还有需要等待的东西(定时器、打开的文件或 socket、服务器),事件循环就会一直运转;什么都不剩时,进程就会退出。
这是 Node 自己的事件循环。浏览器同样有任务和微任务的概念,但没有阶段,也没有 setImmediate 和 process.nextTick;JavaScript 事件循环页面讲解了语言层面的整体概念。
process.nextTick 与 Promise
两者都在事件循环继续之前执行,但它们不会交替进行。Node 会先运行整个 nextTick 队列,包括运行期间新加入的 tick,然后才运行 Promise 队列。猜猜下面这段代码的输出:
顺序是 tick 1、tick 2、tick from tick、promise 1、promise 2。在 tick 内部安排的 tick 会加入同一个队列,并且仍然在任何 Promise 之前执行,即使 Promise 是先创建的。
在 ES 模块(使用 import 的文件)的顶层,两者的顺序会互换:模块本身的代码运行在一个 Promise 任务里,所以它的 Promise 回调会在 Node 处理 nextTick 队列之前执行完。在回调内部,两种模块系统都遵循 nextTick 优先的规则。
setImmediate 与 setTimeout(fn, 0)
setTimeout(fn, 0) 实际上的意思是“至少 1 毫秒之后,在 timers 阶段执行”。setImmediate(fn) 的意思是“在下一个 check 阶段执行”。哪个先执行,取决于你在哪里调用它们。
在 I/O 回调内部,顺序是固定的。回调运行在 poll 阶段,紧接着就是 check 阶段,之后才是 timers 阶段:
它每次都依次打印 nextTick、setImmediate、setTimeout。所以想要“在当前 I/O 工作之后执行”时,setImmediate 是正确的选择。
在主模块中、不在任何回调里时,两者的顺序是不确定的:
// Order depends on how fast the process started
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
如果事件循环开始时已经过去了至少 1 毫秒,定时器就已到期并先打印;否则 setImmediate 先执行。不要写依赖于其中任何一种结果的代码。
| 执行时机 | 适用场景 | |
|---|---|---|
process.nextTick(fn) | 当前操作刚结束时,在 Promise 之前 | 在调用者绑定好监听器之后再触发事件或错误 |
Promise.then、queueMicrotask | nextTick 队列清空之后 | 普通的异步代码 |
setImmediate(fn) | check 阶段,在 I/O 回调之后 | 在分段工作之间把控制权交还给事件循环 |
setTimeout(fn, 0) | timers 阶段,至少 1 毫秒之后 | 延时执行,不在意确切时刻 |
阻塞事件循环
因为只有一个 JavaScript 线程,一个长时间运行的回调会让其他一切都停下来:定时器、I/O 回调,在服务器中还包括所有其他请求。定时器的延迟只是最小值,不是保证。
先打印 loop finished,然后是 true:定时器在 10 毫秒后就到期了,但直到忙循环把线程交还出来,它才能执行。在服务器中,路由处理函数里的同样一个循环,会让其他所有请求冻结半秒钟。
处理耗时工作的办法是把它拆开,并在各部分之间用 setImmediate 让事件循环转一轮:
定时器在第 1 部分之后就插进来执行了。把 setImmediate(() => work(part + 1)) 换成 work(part + 1) 再运行一次:三个部分会一口气运行完,timer 最后才打印。
对于从头到尾都很繁重的工作,比如图像处理,光拆分是不够的:要用 worker_threads 把它移出主线程。
常见错误
递归调用 process.nextTick。 一个不断安排新 nextTick 的 nextTick 回调永远不会让事件循环继续,定时器和 I/O 都会被饿死。重复性的工作请用 setImmediate。
在服务器中使用同步 API。 fs.readFileSync、crypto.pbkdf2Sync 和大型的 JSON.parse 调用会为所有用户阻塞事件循环。在请求处理函数中请使用异步版本。
以为 setTimeout(fn, 0) 会立即执行。 它要等所有同步代码、每个 nextTick 和每个 Promise 回调执行完之后,并且事件循环到达 timers 阶段时才会执行。
常见问题
Node.js 是单线程的吗?
你的 JavaScript 运行在一个线程上,所以两个回调永远不会同时执行。Node 本身使用了更多线程:libuv 维护一个线程池(默认四个线程),用于文件系统操作、DNS 查询和部分加密运算,再把结果交还给事件循环。
Node.js 程序什么时候退出?
当事件循环没有任何需要等待的东西时:没有定时器,没有待处理的 I/O,没有打开的服务器或 socket。一个正在运行的 setInterval 或一个正在监听的服务器会让进程一直存活,直到你清除或关闭它。
Node.js 的事件循环和浏览器的一样吗?
思路相同,都是一个线程运行排队的回调,但细节不同。浏览器没有 setImmediate 和 process.nextTick,并且会在任务之间穿插渲染;而 Node 运行的是 libuv 的各个阶段。JavaScript 事件循环页面讲解了两者共有的、语言层面的部分。
如何判断事件循环是否被阻塞?
测量它延迟了多久:perf_hooks 中的 monitorEventLoopDelay() 会记录延迟,如果平均值达到几十毫秒,就说明有某个回调占用线程太久了。用 --cpu-prof 运行 Node,就能看出是哪个函数。