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

📅 2026/7/3 4:32:05 👁️ 阅读次数 📝 编程学习
一次性讲清楚 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()默认是同步执行的——触发事件时,所有监听器会被立刻依次调用,不进任何队列:

constEventEmitter=require('node:events');conste=newEventEmitter();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 微任务、MutationObserverPromise 微任务 +额外的 process.nextTick(优先级更高)
微任务清空时机每个宏任务后清空每个宏任务后先清 nextTick、再清 Promise 微任务
渲染步骤有(每轮可能重绘)无(服务端无渲染)

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

定时器 API 对比

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

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

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


四、答题时间到~

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

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

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 回调里

constfs=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

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 会饿死事件循环

constfs=require('node:fs');fs.readFile(__filename,()=>console.log('I/O 回调执行了'));functionloop(){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 是同步的

constEventEmitter=require('node:events');conste=newEventEmitter();e.on('event',()=>console.log('监听器'));console.log('开始');e.emit('event');console.log('结束');

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

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

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

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