题记与说明
- 日期:2026-11-03
- 主题:Node.js 事件循环:microtask、macrotask、I/O、阻塞与 worker threads
- 形式:主题研究(多来源精读 + 本机实证复现)
- 来源(本次实际精读的 5 个来源 + 1 次检索):
- Node.js 官方教程《The Node.js Event Loop, Timers, and process.nextTick()》 — https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
- Node.js 官方指南《Don't Block the Event Loop (or the Worker Pool)》 — https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop
- Node.js 官方 API 文档《worker_threads》(Node v26.12.1 版页面) — https://nodejs.org/api/worker_threads.html
- libuv 官方设计文档《Design overview》(v1.x) — https://docs.libuv.org/en/v1.x/design.html
- Node.js 官方教程《Understanding setImmediate()》 — https://nodejs.org/en/learn/asynchronous-work/understanding-setimmediate
- (检索参考)WebSearch 汇总:队列优先级 nextTick → Promise microtask → macrotask;setImmediate 为替代"递归 nextTick 饿死 I/O"而生(Node 0.10 引入)
- (补充查证)libuv《Threadpool》章节:线程池默认大小 — https://docs.libuv.org/en/v1.x/threadpool.html
- 实证环境:本机 Windows + Node.js v24.22.1(
node --version实测),实验脚本与原始输出见附录 B。
一、核心问题
这组材料共同回答一个分层的问题:
一个默认只有单条 JS 线程的运行时,凭什么能同时服务成千上万的并发 I/O 连接?而当任务真的是 CPU 密集时,又该怎么办?
往下拆成四个具体问题:
- 调度模型:事件循环到底长什么样?libuv 的阶段(phases)如何轮转,每阶段处理什么队列?
- 队列优先级:
process.nextTick、Promise microtask、setTimeout/setImmediate这些 macrotask 之间的执行顺序由什么规则决定?为什么setTimeout(fn, 0)不是"0 毫秒后执行"? - 阻塞的代价:什么样的代码会阻塞主线程?代价仅仅是"慢一点"吗(答案:还可能是吞吐崩塌与 DoS 漏洞)?
- 工程对策:分区(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,循环往复。要点:
- timers:执行
setTimeout/setInterval到期的回调。官方强调(原句):"A timer specifies the threshold _after which_ a provided callback _may be executed_ rather than the exact time"——定时器只承诺"不早于",不承诺"恰好在"。"Technically, the poll phase controls when timers are executed." - poll:取回新 I/O 事件并执行 I/O 回调;"node will block here when appropriate"——没有别的事可做时,事件循环就在 poll 里睡到最近的定时器阈值或有新 I/O。
- check:
setImmediate()回调在此执行。官方称其为"a special timer that runs in a separate phase of the event loop"。 - pending callbacks:延迟到下一轮循环的系统级 I/O 回调(如部分 *nix 系统上的 TCP
ECONNREFUSED)。 - close callbacks:如
socket.on('close', ...)。
版本敏感点(来源 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, thenextTickQueuewill 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):
- 同步核心 API:加密(
crypto.randomBytesSync类、pbkdf2Sync)、压缩(zlib.inflateSync/deflateSync)、文件系统同步 API、子进程(spawnSync/execSync/execFileSync)。指南声明其清单"This list is reasonably complete as of Node.js v9"——内容本身已略旧。 - REDOS 脆弱正则:嵌套量词
(a+)*、重叠或分支(a|a)*、反向引用可致指数时间。官方示例/(\/.+)+$/在 100 个/+ 换行的输入下"the Event Loop will take effectively forever"。缓解:safe-regex/rxxr2检查、或换 node-re2(Google RE2,线性时间,但与 V8 正则非 100% 兼容)。 - JSON DOS:
JSON.parse/stringify虽是 O(n),大 n 时惊人地慢。
复杂计算的两种正解(来源 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."
"Unlikechild_processorcluster,worker_threadscan share memory. They do so by transferringArrayBufferinstances or sharingSharedArrayBufferinstances."
关键工程约束:
- 用池,别每次新建(原句):"In practice, use a pool of Workers for these kinds of tasks. Otherwise, the overhead of creating Workers would likely exceed their benefit."
- 通信遵循 HTML structured clone:可传循环引用、Map/Set/RegExp/BigInt/TypedArray;
transferList里的ArrayBuffer零拷贝转移但发送方即刻不可用;克隆不保留原型链(类实例到接收端变普通对象,Buffer变Uint8Array)。 - Worker 内环境差异:
process.env默认是副本(SHARE_ENV可共享)、process.exit()只杀单线程、信号不经process.on、可被worker.terminate()随时终止。 - 连 stdio 都会受主线程阻塞牵连(原句):"stdio output originating from a Worker can get blocked by synchronous code on the receiving end that is blocking the Node.js event loop."
- 可观测性:
worker.performance.eventLoopUtilization()量化事件循环利用率。
三、方法(这批材料的技术路线)
- 官方一手文档交叉:Node 教程层(事件循环阶段、nextTick/setImmediate 语义)× libuv 设计文档(epoll/kqueue/IOCP 轮询、线程池)× API 文档(worker_threads 能力边界)× 指南(威胁模型与对策),四层拼出完整机制图。
- 代码示例论证:官方材料全部用可运行的极小示例立论(
timeout_vs_immediate.js、asyncAvg分区、REDOS 路由、JSON DOS 脚本)。 - 本机实证复现(本次精读的增量工作):在 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 递归次数 = 1000001fs.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 保住主线程事件循环"的直接定量证据。
五、结论
- 心智模型一句话:Node 并发 = 事件循环(编排 + JS 回调 + 非阻塞网络 I/O)+ libuv 线程池(默认 4 线程扛文件 I/O/DNS/CPU 型原生任务)+ worker_threads(需要 JS 级并行时的共享内存线程)。三层的职责在官方文档里划得非常清楚,混用层级(如在主线程跑
pbkdf2Sync,或给每个 I/O 开 worker)都是反模式。 - 顺序规则可背:同步代码 →
process.nextTick队列 → Promise microtask 队列 → 进入/继续事件循环各阶段(macrotask)。nextTick不属于事件循环、递归可饿死 I/O;I/O 回调内setImmediate恒先于setTimeout(fn,0);ESM 包装会改变 microtask 时序。 - 定时器语义:
setTimeout是"最早不早于阈值",实际执行由 poll 阶段和队列拥堵决定;Node 20+ 的 timers 只在 poll 后运行,旧资料(包括大量"timers 前后各跑一次"的图)已过时。 - 阻塞即风险:主线程同步长任务 = 吞吐损失 + DoS 面 Syndrome(REDOS、JSON DOS、
*SyncAPI、/dev/random式线程池饿死)。公平性是应用责任。 - 对策分层:小任务用闭包分区;原生重活丢内置线程池(
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:事件循环图是简化版("There are actually seven or eight steps"),且 Windows 与 Unix 实现有"slight discrepancy";主模块中
setTimeout(0)vssetImmediate顺序"non-deterministic"。 - 来源 2:同步 API 黑名单"reasonably complete as of Node.js v9",指南偏老;REDOS 检查工具"neither of these will catch all vulnerable regexps";node-re2 与 V8 正则非完全兼容。
- 来源 3:
worker_threads.locks仍是 Experimental(v24.5.0 起);postMessageToThread是 Stability 1.1 Active development。 - 来源 5:ESM 与 CJS 的队列时序差异说明官方自己都承认"同一份代码两种输出"是认知负担。
我推断的(材料未展开):
- 所有教程式图示都没有画出 microtask 检查点——真实实现是在每个 macrotask 边界(乃至每阶段之后)清 microtask 队列,图的抽象层级是"教学模型"而非实现模型。
- libuv 1.45/Node 20 的 timers 行为变更说明:任何基于特定版本的事件循环结论都有保质期,网络上的中文旧教程普遍还停留在 Node ≤18 模型。
- 本次实验 1 中 setImmediate 先于
setTimeout(fn,0)只是本机本次运行的表现,不宜当作规律记忆。
本次研究的局限:
- 实验为单机 Windows、低并发、微基准级别;未做 HTTP 压测验证吞吐结论,未实测
UV_THREADPOOL_SIZE=4对并发异步pbkdf2吞吐的上限效应(官方指南有此论断但我未复现数字)。 - 未精读
perf_hooks.eventLoopUtilization与async_hooks的 AsyncResource worker 池示例源码。 - WebSearch 仅作佐证(setImmediate 起源等),未逐条打开二手博客。
七、对我的启发
- 写 Node 服务/CLI 时的硬检查单(可直接进 TypeScript 项目模板的 code review 清单):回调复杂度有界或输入有界;服务端路径禁
*Sync(尤其fs/crypto/zlib/child_process);对用户输入的正则过safe-regex一类检查,路径/URL 匹配直接用ip-regex类库或 node-re2;JSON 超过百 KB 量级考虑JSONStream。 - "定时器不准"类 bug 的排查入口:先问主线程当时在跑什么同步代码,再怀疑事件循环本身。
performance.eventLoopUtilization()(主线程与worker.performance都有)是把"感觉卡"变成数字的第一工具。 - 与已读主题的连接:这是"事件驱动系统与消息队列"主题的进程内版本——同一公平调度思想,跨进程交给 Kafka/RabbitMQ 的队列,进程内交给 libuv 阶段队列与 microtask 语义;而 Worker Pool 的"任务时间方差最小化"与消息队列的"分区/shard 负载均衡"是同构问题。
- 教学/写作素材:本篇五组实验(尤其 exp3 饿死与 exp5 抖动对比)代码不足百行、结论一目了然,适合改造成一篇"用 5 个实验讲透 Node 事件循环"的博客;exp5 的 A/B/C 三阶段测量法可复用于任何"阻塞 vs 卸载"论证。
- 写 worker 池时的两个纪律:复用 Worker(创建开销可能吃掉收益)+ 用
AsyncResource关联任务与回调以保住异步栈追踪(官方文档点名做法);传输大数据优先transferList零拷贝转移或SharedArrayBuffer共享,避免 structured clone 隐性拷贝整个 Buffer 池。
附录 A:重要原文引述(供回查)
- "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
- "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
- "Starting with libuv 1.45.0 (Node.js 20), the event loop behavior changed to run timers only after the poll phase." —— 来源 1
- "
process.nextTick()is not technically part of the event loop... it allows you to 'starve' your I/O by making recursiveprocess.nextTick()calls." —— 来源 1 - "In essence, the names should be swapped... We recommend developers use
setImmediate()in all cases because it's easier to reason about." —— 来源 1 - "Event loop executes tasks in process.nextTick queue first, and then executes promises microtask queue, and then executes macrotask queue." —— 来源 5
- "_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
- "In truth, the Event Loop does not actually maintain a queue... the Worker Pool uses a real queue." —— 来源 2
- "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
- "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
- "Its default size is 4, but it can be changed at startup time by setting the
UV_THREADPOOL_SIZEenvironment 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)。
exp1_ordering.js:顶层同步/nextTick/Promise/setTimeout/setImmediate 各打一行日志。exp2_io_cycle.js:fs.readFile回调内对调setTimeout(fn,0)与setImmediate。exp3_nexttick_starve.js:100 万次递归process.nextTick,观察fs.readFile回调被推迟到递归结束。exp4_blocking.js:指数膨胀出 ~4.5MB 嵌套对象,测 stringify/parse 耗时及排在其后的setTimeout(fn,0)实际等待。exp5_worker.js:三阶段测 25ms 周期定时器最大抖动——空载 / 主线程反复pbkdf2Sync(200k)/ 同计算移入 Worker;跑两遍验证可复现。
(实验代码为本次精读自建,未改动 notes 目录下其他任何条目。)