一次性讲清楚 Node.js 事件循环(Event Loop)

之前在仔细的说过事件循环,但是那个事件循环是基于浏览器背景下实现的。除此之外,javascript还有一个很重要的执行环境------Node.js。在Node中,事件循环有了些许的变化,接下来就仔仔细细的看到底有什么变化。

资料基本来源于Node 官方文档的 "The Node.js Event Loop" :https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick


一、明确概念

为什么 Node 需要自己的事件循环

JavaScript 是单线程的,但 Node 要用它来写服务器------服务器要同时处理成千上万的网络请求、读写文件、查数据库,这些都是耗时的 I/O 操作。如果继续单线程,服务器根本没法用。

浏览器用事件循环解决了这个问题,Node 也需要同样的机制。但 Node 的运行环境和浏览器不同(没有 DOM、没有渲染、却有大量文件和网络 I/O),所以它没有直接照搬浏览器,而是基于一个专门的 C 语言库:libuv来实现事件循环。

先看看libuv做了什么

libuv 是一个用 C 语言编写的跨平台异步 I/O 库,是 Node "单线程却不阻塞"的底层支柱。它主要做三件事:

第一,提供事件循环本身。 整个循环机制(timers → pending → poll → check → close,后面会详细说明)就是 libuv 实现的,Node 的 JS 层只是调用它。

第二,封装跨平台的异步 I/O。 不同操作系统的高效 I/O 机制不一样------Linux 用 epoll、macOS 用 kqueue、Windows 用 IOCP。libuv 把这些差异抹平,对上层提供统一接口,这样 Node 代码才能在三大平台行为一致。

第三,管理一个线程池。 这点最关键、也最容易被误解。很多人以为"Node 完全是单线程的",其实不准确------JavaScript 的执行是单线程的,但 libuv 背后有一个线程池(默认 4 个线程)。

为什么需要线程池?因为不是所有操作都有操作系统级的异步接口。网络 I/O 大多有原生异步支持(靠 epoll/kqueue/IOCP,不占线程池),但文件系统操作、DNS 解析、一些 CPU 密集的加密操作 (如 crypto.pbkdf2)没有可靠的跨平台异步方案,libuv 就把这些丢进线程池去跑,跑完再通过事件循环把回调交回主线程。

所以关于"Node 是不是单线程",准确的表述是:

Node 执行 JavaScript 的主线程是单线程的 ,但 libuv 用线程池 + 操作系统的异步机制,在背后并发处理耗时 I/O,完成后把回调塞回主线程的事件循环。这就是"单线程却非阻塞"的真相。

Node事件循环的不同阶段

浏览器的事件循环只有一个笼统的"宏任务队列",而 Node 把宏任务细分成了几个有固定执行顺序的阶段(phase)。每一轮事件循环,都会按固定顺序走过这些阶段:

复制代码
   ┌───────────────────────────┐
┌─>│           timers          │  执行到期的 setTimeout / setInterval 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  执行上一轮延迟的系统级 I/O 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  仅 libuv 内部使用,JS 层碰不到
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │  最核心:获取并执行 I/O 回调,必要时阻塞等待
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │  执行 setImmediate 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │  执行 close 事件回调,如 socket.on('close')
   └───────────────────────────┘

(idle/prepare 是内部阶段,JS 层无法访问,日常可忽略,下面不展开):

timers(定时器阶段) :执行已到期setTimeoutsetInterval 回调。注意是"到期"才执行------定时器设定的是"至少等这么久",不是精确时间,实际由 poll 阶段控制何时回到这里。

pending callbacks(待定回调阶段) :执行一些被推迟到本轮的系统级 I/O 回调,比如某些类型的 TCP 错误(如收到 ECONNREFUSED)。这个阶段和业务代码关系不大。

poll(轮询阶段)整个事件循环最核心的阶段 ,做两件事------获取新的 I/O 事件并执行对应回调(几乎所有 I/O 回调,如 fs.readFile、网络数据到达,都在这里执行);以及在没有其他任务时,决定是否阻塞在这里等待新的 I/O。它是事件循环"停下来等活干"的地方。

check(检查阶段) :专门执行 setImmediate 的回调。它紧跟在 poll 阶段之后。

close callbacks(关闭回调阶段) :执行各种关闭事件的回调,比如 socket.on('close', ...)

每个阶段都有自己的先进先出(FIFO)回调队列。事件循环进入一个阶段后,会执行完该阶段队列里的回调(或达到系统上限),才进入下一阶段。走完 close 后,绕回 timers 开始新一轮。

Node中的微任务

上面讲的是"宏任务分阶段"。但 Node 里还有优先级更高的微任务 ,它们不属于任何阶段,而是在阶段之间被清空。Node 里有两类微任务,优先级还不一样:

  • process.nextTick 队列 :优先级最高,自成一队。
  • Promise 微任务队列.then/.catch/.finallyawait 之后的代码、queueMicrotask):优先级次之。

记住他们的优先级,整个 Node 事件循环差不多都能记住了:

每当事件循环执行完一个宏任务(阶段里的一个回调)后,会先清空整个 nextTick 队列,再清空整个 Promise 微任务队列,然后才继续下一个宏任务或进入下一阶段。

一句话优先级排序:同步代码 > process.nextTick > Promise 微任务 > 任何阶段的宏任务。


二、关键 API

Node 提供了三个安排"稍后执行"的核心 API,它们落在事件循环的不同位置,理解它们的区别是理解整个模型的关键。

setTimeout / setInterval ------ timers 阶段

setTimeout(fn, delay) 安排一个回调在至少 delay 毫秒后 执行,回调进入 timers 阶段setInterval(fn, delay) 类似,但每隔 delay 毫秒重复执行

要点:delay 是"最小延迟"而非精确时间;setTimeout(fn, 0)0 会被 Node 设置成最小 1ms 。它们返回的是一个 Timeout 对象 (不是数字),可以传给 clearTimeout/clearInterval 取消。

setImmediate ------ check 阶段

setImmediate(fn) 安排回调在 check 阶段 执行,也就是当前这一轮 poll 阶段结束后立即执行。它是 Node 独有的,浏览器没有。

它和 setTimeout(fn, 0) 看起来都像"尽快执行",但落点不同:一个在 check 阶段,一个在 timers 阶段。这个差别导致了它俩顺序的微妙问题(见后面的题)。

process.nextTick ------ 不属于任何阶段,优先级最高

process.nextTick(fn) 安排的回调不属于事件循环的任何阶段 ,而是在当前操作结束后、事件循环继续之前立刻执行,优先级比 Promise 微任务还高。

它强大也危险:如果你递归调用 process.nextTick,会不断往 nextTick 队列里塞任务,导致事件循环永远无法进入下一阶段 (比如永远到不了 poll),这叫"饿死 I/O"。官方因此建议------大多数情况优先用 setImmediate,它更容易推理

一个常被误解的点:EventEmitter 的 emit 是同步的

顺带澄清一个和事件循环相关的高频误区。EventEmitter(Node 的发布-订阅基础类,serverstream 等都继承自它)的 emit() 默认是同步执行的------触发事件时,所有监听器会被立刻依次调用,不进任何队列:

js 复制代码
const EventEmitter = require('node:events');
const e = new EventEmitter();
e.on('event', () => console.log('2'));
console.log('1');
e.emit('event');   // 同步调用监听器
console.log('3');
// 输出:1 2 3(不是 1 3 2)

这也解释了一个经典的坑:如果在构造函数里 emit 一个事件,此时使用者还没来得及 on 注册监听器,事件就丢了。解决办法是用 process.nextTick 把 emit 推迟到构造函数执行完、监听器绑定之后。


三、Node 与浏览器的对比

Node 和浏览器的事件循环大方向一致,但细节差异不少。详细对比如下:

事件循环机制对比

维度 浏览器 Node
底层实现 各浏览器引擎自己实现 基于 libuv
宏任务组织 一个笼统的宏任务队列 细分成 timers/pending/poll/check/close 多个阶段
微任务 Promise 微任务、MutationObserver Promise 微任务 + 额外的 process.nextTick(优先级更高)
微任务清空时机 每个宏任务后清空 每个宏任务后先清 nextTick、再清 Promise 微任务
渲染步骤 有(每轮可能重绘) 无(服务端无渲染)

核心差异一句话:浏览器是"宏任务队列 + 微任务队列"两层;Node 是"多阶段宏任务 + nextTick 队列 + Promise 微任务队列"三层,且 nextTick 优先级最高。

定时器 API 对比

setTimeoutsetInterval 两个环境都有、用法一致,但有几处区别;此外各自有独占的 API:

API 浏览器 Node 说明
setTimeout / setInterval 用法一致
定时器返回值 数字 ID Timeout 对象(带 unref() 等方法) 都可传给 clear 函数取消
回调去向 宏任务队列 libuv 的 timers 阶段 ---
嵌套 5 层强制 4ms 最小延迟 有(HTML 规范) 浏览器特有的防滥用规则
setImmediate 有(check 阶段) Node 独有
process.nextTick 有(最高优先级) Node 独有
requestAnimationFrame 有(与渲染同步) 浏览器独有,用于动画

要点提炼:setTimeout/setInterval 通用但返回值类型和底层调度不同;setImmediateprocess.nextTick 是 Node 独有;requestAnimationFrame 是浏览器独有。


四、答题时间到~

下面几道题覆盖 Node 事件循环的高频考点,每题先自己推一遍,再看答案和解析。

题目 1:三类任务的优先级

js 复制代码
console.log('1');                                // 同步
setTimeout(() => console.log('2'), 0);           // 宏任务(timers)
setImmediate(() => console.log('3'));            // 宏任务(check)
Promise.resolve().then(() => console.log('4'));  // Promise 微任务
process.nextTick(() => console.log('5'));        // nextTick(最高优先级)
console.log('6');                                // 同步

先想想:输出顺序是什么?哪些是确定的,哪些不一定?

答案:1 6 5 4 是确定的,然后 23 的顺序不确定。

逐步推演:

  1. 先跑同步代码:console.log('1')console.log('6') → 打印 16
  2. 同步代码跑完、栈清空,进入微任务清算。按优先级,先清 nextTick 队列 :执行 5 → 打印 5
  3. 再清 Promise 微任务队列 :执行 4 → 打印 4
  4. 微任务都清完,事件循环正式开始第一轮,进入 timers 阶段。此时看那个 setTimeout(0) 到期没有------0 被钳到最小 1ms,而从进程启动到这一刻的耗时是飘忽不定 的:如果已 ≥ 1ms,timers 阶段执行 22 在前;如果 < 1ms,定时器没到期,跳过 timers,走到 check 阶段先执行 33 在前。

关键认知:1 6 5 4 由铁律保证(同步 > nextTick > Promise 微任务),完全确定;但在主模块顶层,setTimeout(0)setImmediate 谁先是一场受启动耗时影响的赛跑,顺序不确定。 很多人会把这道题的答案背成固定的 1 6 5 4 2 3,这是不严谨的------2 33 2 都可能出现。

题目 2:setTimeout(0) vs setImmediate 在 I/O 回调里

js 复制代码
const fs = require('node:fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});

先想想:这次 timeout 和 immediate 谁先?还是不确定吗?

答案:immediate 永远先执行,这次是确定的。

解析:区别就在于这两个定时器是在 fs.readFile 的回调里 安排的,而这个回调本身是在 poll 阶段执行的(I/O 回调都在 poll 阶段)。执行完这个回调后,看阶段顺序:

复制代码
poll(当前在这)→ check(setImmediate 在这)→ ...下一轮... → timers(setTimeout 在这)

poll 阶段结束后,紧接着就是 check 阶段immediate 立刻执行;而 timeout 属于 timers 阶段,得等事件循环绕完一整圈、到下一轮才轮到。所以 immediate 必然先于 timeout

关键认知:同样两行代码,在主模块里顺序不确定(题1),在 I/O 回调里 immediate 必先(本题)------差别来自"代码在哪个阶段执行"。 poll 紧邻 check,是 immediate 在 I/O 回调里稳赢的根本原因。这也是判断这类题的通用方法:先问"这段代码运行在哪个阶段"。

题目 3:nextTick 高于 Promise

js 复制代码
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');

先想想:三行的输出顺序?

答案:sync → nextTick → promise

解析:

  1. console.log('sync') 是同步代码,最先执行 → sync
  2. 同步代码跑完,进入微任务清算。Node 里 nextTick 队列的优先级高于 Promise 微任务队列 ,所以先执行 nextTicknextTick
  3. 再清 Promise 微任务 → promise

关键认知:在 Node 里,process.nextTickPromise.then 更"急"。 尽管两者都在"同步代码之后、下一个宏任务之前"执行,但 nextTick 自成一个更高优先级的队列,永远排在 Promise 微任务前面。这是 Node 特有的,浏览器里没有 nextTick 这一层。

题目 4:递归 nextTick 会饿死事件循环

js 复制代码
const fs = require('node:fs');
fs.readFile(__filename, () => console.log('I/O 回调执行了'));

function loop() {
  process.nextTick(loop);   // 递归安排 nextTick
}
loop();

先想想:那句"I/O 回调执行了"会被打印吗?为什么?

答案:永远不会打印。

解析:loop 每次执行都用 process.nextTick 安排下一个 loop。回想那条规则------事件循环在进入下一阶段之前,必须先把整个 nextTick 队列清空 。但这个队列在清空的过程中,每执行一个 loop 又塞进一个新的 loop,队列永远清不完

结果:事件循环被死死卡在"清 nextTick 队列"这一步,永远无法推进到 poll 阶段 ,于是 fs.readFile 的回调(在 poll 阶段执行)永远得不到机会。这就是"饿死 I/O"。

关键认知:process.nextTick 的高优先级是把双刃剑------递归调用会让它霸占事件循环,阻止任何阶段推进。 这也是官方建议"优先用 setImmediate"的原因:setImmediate 是宏任务(check 阶段),每轮只执行一次已排队的,不会阻止循环推进,用它做递归/切片是安全的。

题目 5:EventEmitter 的 emit 是同步的

js 复制代码
const EventEmitter = require('node:events');
const e = new EventEmitter();

e.on('event', () => console.log('监听器'));

console.log('开始');
e.emit('event');
console.log('结束');

先想想:输出顺序是什么?"监听器"会不会被异步推迟?

答案:开始 → 监听器 → 结束,emit 是同步的。

解析:EventEmitteremit() 默认同步 调用所有监听器------它不进任何队列,触发的那一刻就直接、立即依次执行监听器。所以顺序就是老实的从上到下:开始 → emit 立即调用监听器打印 监听器结束

关键认知:emit 是同步的,别把它当异步。 这解释了那个经典坑:在构造函数里 emit,此时监听器还没 on 上去,事件就丢了;正确做法是用 process.nextTick 把 emit 推迟到构造完成之后。