读书笔记 · 约 13,367 字 · 预计推送 2 部分

Node.js 事件循环:microtask、macrotask、I/O、阻塞与 worker threads —— 主题精读 未开始

目录

题记与说明


一、核心问题

这组材料共同回答一个分层的问题:

一个默认只有单条 JS 线程的运行时,凭什么能同时服务成千上万的并发 I/O 连接?而当任务真的是 CPU 密集时,又该怎么办?

往下拆成四个具体问题:

  1. 调度模型:事件循环到底长什么样?libuv 的阶段(phases)如何轮转,每阶段处理什么队列?
  2. 队列优先级:process.nextTick、Promise microtask、setTimeout/setImmediate 这些 macrotask 之间的执行顺序由什么规则决定?为什么 setTimeout(fn, 0) 不是"0 毫秒后执行"?
  3. 阻塞的代价:什么样的代码会阻塞主线程?代价仅仅是"慢一点"吗(答案:还可能是吞吐崩塌与 DoS 漏洞)?
  4. 工程对策:分区(partitioning)、内置 libuv 线程池、worker_threads——三者各解决什么问题、边界在哪?

二、主要主张

1. 事件循环的定义:把 I/O 卸载给内核的编排器

官方定义(来源 1,原句):

"The event loop is what allows Node.js to perform non-blocking I/O operations — despite the fact that a single JavaScript thread is used by default — by offloading operations to the system kernel whenever possible."

因为现代内核本身是多线程的,真正等待 I/O 的工作在内核里完成;完成时内核通知 Node,把相应回调放进 poll 队列等待执行。JS 线程从不"等待",只"编排"。

2. 事件循环有六个阶段,每阶段一个 FIFO 队列

来源 1 的经典图与文字:timers → pending callbacks → idle, prepare → poll → check → close callbacks,循环往复。要点:

版本敏感点(来源 1 明确指出,很多旧资料已过时):

"Starting with libuv 1.45.0 (Node.js 20), the event loop behavior changed to run timers only after the poll phase, instead of both before and after as in earlier versions."

3. libuv 机制层:网络 I/O 单线程轮询,文件 I/O 靠线程池

来源 4(libuv 设计文档)给出比 Node 教程更底层的事实:

"all (network) I/O is performed on non-blocking sockets which are polled using the best mechanism available on the given platform: epoll on Linux, kqueue on OSX and other BSDs, event ports on SunOS and IOCP on Windows."
"libuv uses a thread pool to make asynchronous file I/O operations possible, but network I/O is always performed in a single thread, each loop's thread."
"Unlike network I/O, there are no platform-specific file I/O primitives libuv could rely on, so the current approach is to run blocking file I/O operations in a thread pool."

线程池上跑三类操作:文件系统操作、DNS 函数(getaddrinfo/getnameinfo)、uv_queue_work() 提交的用户代码。线程池默认大小(补充查证,来源 7):"Its default size is 4, but it can be changed at startup time by setting the UV_THREADPOOL_SIZE environment variable."

另一条反直觉事实(来源 2):

"In truth, the Event Loop does not actually maintain a queue. Instead, it has a collection of file descriptors that it asks the operating system to monitor... In contrast, the Worker Pool uses a real queue whose entries are tasks to be processed."

4. microtask / nextTick 优先于一切 macrotask;nextTick 甚至不属于事件循环

队列优先级(来源 5,原句):

"Event loop executes tasks in process.nextTick queue first, and then executes promises microtask queue, and then executes macrotask queue."

而 process.nextTick 的特殊性(来源 1,原句):

"process.nextTick() is not technically part of the event loop. Instead, the nextTickQueue will be processed after the current operation is completed, regardless of the current phase of the event loop."

危险随之而来(来源 1,原句):

"it allows you to 'starve' your I/O by making recursive process.nextTick() calls, which prevents the event loop from reaching the poll phase."

setImmediate 正是为绕开这个坑而生(Node 0.10 引入,本次检索佐证),且命名是历史包袱(来源 1 原句):"In essence, the names should be swapped... the names themselves won't change"(改名会破坏 npm 大量包)。官方建议:"We recommend developers use setImmediate() in all cases because it's easier to reason about."

setTimeout(fn, 0) vs setImmediate:主模块里顺序不确定("bound by the performance of the process");I/O 回调内部 setImmediate 恒先——这是官方推荐用 setImmediate 的核心论据。

来源 5 还给出一个容易踩的坑:ESM 下顺序会变。同样的代码,CJS 输出 start foo bar zoo baz,ESM 输出 start bar foo zoo baz,"because the ES Module being loaded is wrapped as an asynchronous operation, and thus the entire script is actually already in the promises microtask queue."

5. "Don't Block":阻塞不只是慢,还是安全问题

来源 2 的总纲(原句):

"_Node.js is fast when the work associated with each client at any given time is 'small'._"

两种线程、两种阻塞后果:

"1. Performance: If you regularly perform heavyweight activity on either type of thread, the _throughput_ (requests/second) of your server will suffer.
2. Security: If it is possible that for certain input one of your threads might block, a malicious client could submit this 'evil input'... This would be a Denial of Service attack."

因为 Node 用极少线程服务大量客户端,公平调度是应用自己的责任(原句:"_The fair treatment of clients is thus the responsibility of your application_")——这与 Apache 一客户一线程、由 OS 强制公平形成根本对比。

三大典型阻塞源(来源 2):

复杂计算的两种正解(来源 2):partitioning(把计算切片,用闭包保存进度,每片 O(1) 后让出事件循环)与 offloading(卸载到 Worker Pool / 独立计算池;"You should _not_ simply create a Child Process for every client... your server might become a fork bomb")。代价是序列化通信开销:"From a Worker, you cannot manipulate a JavaScript object in the Event Loop's namespace."

Worker Pool 本身也要防饿死(来源 2):任务时间方差要最小化("_each relatively long Task effectively decreases the size of the Worker Pool by one_");最坏示例是攻击者诱导服务器 readFile('/dev/random')——"For all practical purposes, /dev/random is infinitely slow",k 个 worker 全被占死。对策是任务分区(fs.read 手动分片或自动分区的 ReadStream)。

6. worker_threads:JS 级并行的正解与边界

来源 3(官方 API 文档)开宗明义(原句):

"Workers (threads) are useful for performing CPU-intensive JavaScript operations. They do not help much with I/O-intensive work. The Node.js built-in asynchronous I/O operations are more efficient than Workers can be."
"Unlike child_process or cluster, worker_threads can share memory. They do so by transferring ArrayBuffer instances or sharing SharedArrayBuffer instances."

关键工程约束:

三、方法(这批材料的技术路线)

  1. 官方一手文档交叉:Node 教程层(事件循环阶段、nextTick/setImmediate 语义)× libuv 设计文档(epoll/kqueue/IOCP 轮询、线程池)× API 文档(worker_threads 能力边界)× 指南(威胁模型与对策),四层拼出完整机制图。
  2. 代码示例论证:官方材料全部用可运行的极小示例立论(timeout_vs_immediate.js、asyncAvg 分区、REDOS 路由、JSON DOS 脚本)。
  3. 本机实证复现(本次精读的增量工作):在 Node v24.22.1 上跑 5 组实验(附录 B),把文档的定性主张变成可复核的定量输出,并验证了 Node 20+ 新行为下的排序结论仍然成立。

四、数据与证据

官方文档给出的数字

证据数值出处
JSON DOS:2^21 尺寸嵌套对象stringify 后 50MB 字符串:stringify 0.7s,indexOf 0.03s,parse 1.3s来源 2
定时器漂移示例100ms 阈值 + 95ms 文件读取 + 10ms 回调 = 实际 105ms 才触发来源 1
libuv 轮询上限"a hard maximum (system dependent) before it stops polling"——防止 poll 饿死定时器来源 1
线程池默认大小UV_THREADPOOL_SIZE = 4,启动时可用环境变量改来源 7
worker 线程栈默认stackSizeMb 默认 4来源 3

本机实证(Node v24.22.1 / Windows,2026-11-03 运行)

实验 1 — 顶层作用域队列优先级(node exp1_ordering.js):

1: sync start
2: sync end
3: process.nextTick    [nextTick queue]
4: Promise.then        [microtask queue]
6: setImmediate        [check phase]
5: setTimeout(fn, 0)   [timers phase]

完全符合"同步 → nextTick → Promise microtask → macrotask"。主模块里本次运行 setImmediate 先于 setTimeout(fn,0)——这正是官方所说的"顺序不确定、取决于进程性能"的表现。

实验 2 — I/O 回调内 setImmediate 恒先(node exp2_io_cycle.js):

after I/O: setImmediate      [check phase]
after I/O: setTimeout(fn,0)  [timers phase]

复现了官方 timeout_vs_immediate.js 的结论:check 阶段紧随 poll 之后,timers 要等下一轮。

实验 3 — 递归 nextTick 饿死 I/O(node exp3_nexttick_starve.js):

nextTick 递归结束: 1000001 次调用, 耗时 72ms
I/O 回调终于运行: 距开始 74ms, 被跳过的 nextTick 递归次数 = 1000001

fs.readFile 的回调被 100 万次递归 nextTick 完全压制,直到递归耗尽才得以运行——官方"starve your I/O"警告的实证。这也解释了 setImmediate 为何被发明:同样的递归若用 setImmediate,每轮循环 I/O 都有机会。

实验 4 — 大 JSON 同步操作压住定时器(node exp4_blocking.js):

JSON.stringify: 4.5 MB, 耗时 20 ms (主线程同步阻塞)
JSON.parse:      4.5 MB, 耗时 50 ms
setTimeout(fn,0) 实际等了 50 ms (排在 stringify/parse 的同步计算之后才被调度)

4.5MB 就已让一次 setTimeout(fn,0) 从"尽快"变成"等完 50ms 的 parse 才轮到"——官方 50MB → 1.3s 的数字按比例可信。

实验 5 — CPU 密集任务:主线程 vs Worker 的定时器抖动(node exp5_worker.js,跑两遍):

单次 pbkdf2Sync(200000 迭代) 耗时 ~26 ms
A 空载基线               : 25ms 周期 setInterval 最大抖动 = 10 ms   (第二遍 7 ms)
B 主线程同步 pbkdf2Sync : 25ms 周期 setInterval 最大抖动 = 43 ms   (第二遍 41 ms)
C pbkdf2Sync 在 Worker 内: 25ms 周期 setInterval 最大抖动 = 8 ms    (第二遍 7 ms)

同样的计算(每次 ~26ms):放主线程,25ms 周期定时器最大抖动 41-43ms(接近基线的 5 倍);放 worker_threads,抖动 7-8ms,与空载基线无差异。这是"worker_threads 保住主线程事件循环"的直接定量证据。

五、结论

  1. 心智模型一句话:Node 并发 = 事件循环(编排 + JS 回调 + 非阻塞网络 I/O)+ libuv 线程池(默认 4 线程扛文件 I/O/DNS/CPU 型原生任务)+ worker_threads(需要 JS 级并行时的共享内存线程)。三层的职责在官方文档里划得非常清楚,混用层级(如在主线程跑 pbkdf2Sync,或给每个 I/O 开 worker)都是反模式。
  2. 顺序规则可背:同步代码 → process.nextTick 队列 → Promise microtask 队列 → 进入/继续事件循环各阶段(macrotask)。nextTick 不属于事件循环、递归可饿死 I/O;I/O 回调内 setImmediate 恒先于 setTimeout(fn,0);ESM 包装会改变 microtask 时序。
  3. 定时器语义:setTimeout 是"最早不早于阈值",实际执行由 poll 阶段和队列拥堵决定;Node 20+ 的 timers 只在 poll 后运行,旧资料(包括大量"timers 前后各跑一次"的图)已过时。
  4. 阻塞即风险:主线程同步长任务 = 吞吐损失 + DoS 面 Syndrome(REDOS、JSON DOS、*Sync API、/dev/random 式线程池饿死)。公平性是应用责任。
  5. 对策分层:小任务用闭包分区;原生重活丢内置线程池(pbkdf2 异步版、ReadStream、zlib 异步版);纯 JS 重活用 worker_threads 池;都不行就质疑 Node 是否合适(官方原话:"Node.js excels for I/O-bound work, but for expensive computation it might not be the best option")。

六、局限

材料自身承认的:

我推断的(材料未展开):

本次研究的局限:

七、对我的启发

  1. 写 Node 服务/CLI 时的硬检查单(可直接进 TypeScript 项目模板的 code review 清单):回调复杂度有界或输入有界;服务端路径禁 *Sync(尤其 fs/crypto/zlib/child_process);对用户输入的正则过 safe-regex 一类检查,路径/URL 匹配直接用 ip-regex 类库或 node-re2;JSON 超过百 KB 量级考虑 JSONStream。
  2. "定时器不准"类 bug 的排查入口:先问主线程当时在跑什么同步代码,再怀疑事件循环本身。performance.eventLoopUtilization()(主线程与 worker.performance 都有)是把"感觉卡"变成数字的第一工具。
  3. 与已读主题的连接:这是"事件驱动系统与消息队列"主题的进程内版本——同一公平调度思想,跨进程交给 Kafka/RabbitMQ 的队列,进程内交给 libuv 阶段队列与 microtask 语义;而 Worker Pool 的"任务时间方差最小化"与消息队列的"分区/shard 负载均衡"是同构问题。
  4. 教学/写作素材:本篇五组实验(尤其 exp3 饿死与 exp5 抖动对比)代码不足百行、结论一目了然,适合改造成一篇"用 5 个实验讲透 Node 事件循环"的博客;exp5 的 A/B/C 三阶段测量法可复用于任何"阻塞 vs 卸载"论证。
  5. 写 worker 池时的两个纪律:复用 Worker(创建开销可能吃掉收益)+ 用 AsyncResource 关联任务与回调以保住异步栈追踪(官方文档点名做法);传输大数据优先 transferList 零拷贝转移或 SharedArrayBuffer 共享,避免 structured clone 隐性拷贝整个 Buffer 池。

附录 A:重要原文引述(供回查)

  1. "The event loop is what allows Node.js to perform non-blocking I/O operations — despite the fact that a single JavaScript thread is used by default — by offloading operations to the system kernel whenever possible." —— 来源 1
  2. "A timer specifies the threshold after which a provided callback may be executed rather than the exact time a person wants it to be executed." / "Technically, the poll phase controls when timers are executed." —— 来源 1
  3. "Starting with libuv 1.45.0 (Node.js 20), the event loop behavior changed to run timers only after the poll phase." —— 来源 1
  4. "process.nextTick() is not technically part of the event loop... it allows you to 'starve' your I/O by making recursive process.nextTick() calls." —— 来源 1
  5. "In essence, the names should be swapped... We recommend developers use setImmediate() in all cases because it's easier to reason about." —— 来源 1
  6. "Event loop executes tasks in process.nextTick queue first, and then executes promises microtask queue, and then executes macrotask queue." —— 来源 5
  7. "_Node.js is fast when the work associated with each client at any given time is 'small'._" / "The fair treatment of clients is thus the responsibility of your application." —— 来源 2
  8. "In truth, the Event Loop does not actually maintain a queue... the Worker Pool uses a real queue." —— 来源 2
  9. "network I/O is always performed in a single thread, each loop's thread." / "the current approach is to run blocking file I/O operations in a thread pool." —— 来源 4
  10. "Workers (threads) are useful for performing CPU-intensive JavaScript operations. They do not help much with I/O-intensive work." / "use a pool of Workers... the overhead of creating Workers would likely exceed their benefit." —— 来源 3
  11. "Its default size is 4, but it can be changed at startup time by setting the UV_THREADPOOL_SIZE environment variable." —— 来源 7

附录 B:本机实证脚本与运行方式

脚本目录:C:\Users\马鑫悦\.zcode\workspace\draft-438a36ca-1ee9-4ad0-9f51-71a4e441e47e\exp-node-eventloop\
运行命令:node exp1_ordering.js … node exp5_worker.js(Node v24.22.1,Windows)。

(实验代码为本次精读自建,未改动 notes 目录下其他任何条目。)

← 资料库今日播报 →