之前在仔细的说过事件循环,但是那个事件循环是基于浏览器背景下实现的。除此之外,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(定时器阶段):执行已到期的setTimeout和setInterval回调。注意是"到期"才执行——定时器设定的是"至少等这么久",不是精确时间,实际由 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/.finally、await之后的代码、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 的发布-订阅基础类,server、stream等都继承自它)的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 微任务、MutationObserver | Promise 微任务 +额外的 process.nextTick(优先级更高) |
| 微任务清空时机 | 每个宏任务后清空 | 每个宏任务后先清 nextTick、再清 Promise 微任务 |
| 渲染步骤 | 有(每轮可能重绘) | 无(服务端无渲染) |
核心差异一句话:浏览器是"宏任务队列 + 微任务队列"两层;Node 是"多阶段宏任务 + nextTick 队列 + Promise 微任务队列"三层,且 nextTick 优先级最高。
定时器 API 对比
setTimeout和setInterval两个环境都有、用法一致,但有几处区别;此外各自有独占的 API:
| API | 浏览器 | Node | 说明 |
|---|---|---|---|
setTimeout/setInterval | 有 | 有 | 用法一致 |
| 定时器返回值 | 数字 ID | Timeout 对象(带unref()等方法) | 都可传给 clear 函数取消 |
| 回调去向 | 宏任务队列 | libuv 的 timers 阶段 | — |
| 嵌套 5 层强制 4ms 最小延迟 | 有(HTML 规范) | 无 | 浏览器特有的防滥用规则 |
setImmediate | 无 | 有(check 阶段) | Node 独有 |
process.nextTick | 无 | 有(最高优先级) | Node 独有 |
requestAnimationFrame | 有(与渲染同步) | 无 | 浏览器独有,用于动画 |
要点提炼:setTimeout/setInterval通用但返回值类型和底层调度不同;setImmediate和process.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是确定的,然后2和3的顺序不确定。
逐步推演:
- 先跑同步代码:
console.log('1')和console.log('6')→ 打印1、6。 - 同步代码跑完、栈清空,进入微任务清算。按优先级,先清nextTick 队列:执行
5→ 打印5。 - 再清Promise 微任务队列:执行
4→ 打印4。 - 微任务都清完,事件循环正式开始第一轮,进入 timers 阶段。此时看那个
setTimeout(0)到期没有——0被钳到最小 1ms,而从进程启动到这一刻的耗时是飘忽不定的:如果已 ≥ 1ms,timers 阶段执行2,2在前;如果 < 1ms,定时器没到期,跳过 timers,走到 check 阶段先执行3,3在前。
关键认知:1 6 5 4由铁律保证(同步 > nextTick > Promise 微任务),完全确定;但在主模块顶层,setTimeout(0)和setImmediate谁先是一场受启动耗时影响的赛跑,顺序不确定。很多人会把这道题的答案背成固定的1 6 5 4 2 3,这是不严谨的——2 3和3 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
解析:
console.log('sync')是同步代码,最先执行 →sync。- 同步代码跑完,进入微任务清算。Node 里nextTick 队列的优先级高于 Promise 微任务队列,所以先执行
nextTick→nextTick。 - 再清 Promise 微任务 →promise。
关键认知:在 Node 里,process.nextTick比Promise.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 是同步的。
解析:EventEmitter的emit()默认同步调用所有监听器——它不进任何队列,触发的那一刻就直接、立即依次执行监听器。所以顺序就是老实的从上到下:开始→ emit 立即调用监听器打印监听器→结束。
关键认知:emit是同步的,别把它当异步。这解释了那个经典坑:在构造函数里emit,此时监听器还没on上去,事件就丢了;正确做法是用process.nextTick把 emit 推迟到构造完成之后。