读书笔记 · 约 19,320 字 · 预计推送 2 部分

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

目录

题记与说明


来源清单

#来源类型精读方式
1The Node.js Event Loop, Timers, and process.nextTick() — nodejs.org 官方 Learn 指南官方文档全文提取,逐段核对
2Don't Block the Event Loop (or the Worker Pool) — nodejs.org 官方 Learn 指南官方文档全文提取,逐段核对
3worker_threads — Node.js v24 API 文档官方 API 文档全文提取,逐段核对
4Understanding process.nextTick() — nodejs.org 官方 Learn 指南官方文档全文提取(该页内容较短,microtask 队列细节主要来自来源 5 与来源 1)
5The Node.js Event Loop — The Node Book(社区深度文章)社区技术文章全文提取,重点核对它对常见误解的修正

补充检索(未全文精读,仅用于定位):Understanding setImmediate()、Medium 上一篇 phases 讲解文。网络可用,全部结论均基于实际读取的网页内容,非凭记忆。


一、核心问题

这份工作在解决什么问题:

  1. 认知问题:Node.js "单线程" 到底指什么?既然只有一个 JS 线程,它凭什么处理成千上万的并发连接?答案藏在事件循环(event loop)的相位结构里 —— 它是 Node.js 异步模型的心脏,但网络上的大量讲解(包括很流行的图)对它的描述已经过时或干脆是错的。
  2. 排序问题:process.nextTick、Promise.then(microtask)、setTimeout(0)、setImmediate、I/O 回调(macrotask)到底谁先执行?这是面试高频题,也是生产代码真实依赖的执行顺序,而答案对上下文敏感(主模块 vs I/O 回调内)。
  3. 工程问题:什么时候事件循环会被"阻塞",阻塞了会发生什么(吞吐下降、DoS),以及标准解法是什么 —— 分片(partitioning)、卸载到 Worker Pool,还是开 worker_threads?

一句话:理解 Node.js 并发模型的真实结构(事件循环相位 + 双队列 + Worker Pool),并据此写出不阻塞主线程的代码。


二、主要主张

2.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."
"Since most modern kernels are multi-threaded, they can handle multiple operations executing in the background. When one of these operations completes, the kernel tells Node.js so that the appropriate callback may be added to the poll queue to eventually be executed."

即:JS 侧单线程,但阻塞的 I/O 等待被甩给了操作系统内核(通过 epoll/kqueue/event ports/IOCP,见来源 2),完成后内核通知 Node 把回调排进 poll 队列。

2.2 六个相位,每个相位一条 FIFO 队列

来源 1 给出的循环结构(每轮循环依次经过):

   ┌───────────────────────────┐
┌─▶│           timers          │   setTimeout / setInterval 回调
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │     pending callbacks     │   延迟到下一轮的 I/O 回调(如部分系统的 TCP 错误)
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │       idle, prepare       │   仅内部使用
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │            poll           │   计算 poll 阻塞时长;处理 poll 队列事件
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │           check           │   setImmediate() 回调在这里执行
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │       close callbacks     │   如 socket.destroy() 的 'close' 事件
│  └───────────────────────────┘

每个相位把队列"执行到空或达到单相位回调数上限"才进入下一相位。关键细节:

"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."

2.3 microtask / macrotask 的真实规则:两个高优先级队列,每个回调返回点都清空

这是全文最容易被讲错的点。来源 5 的关键主张:

"libuv gives you the native loop engine, and Node adds its own JavaScript scheduling rules on top."
"While JavaScript is running on the call stack, every queued callback waits."(JS 在栈上跑的时候,所有排队的回调都得等)

两条高优先级队列,优先级高于任何相位回调(macrotask):

  1. nextTick 队列(Node 自己维护);
  2. V8 microtask 队列(Promise.then、queueMicrotask())。

清空粒度是"每个回调返回点",不是"每个相位"(来源 5 原文):

"The moment Node reaches a JavaScript callback return point, it drains the next tick queue first, then the V8 microtask queue, before moving to the next event-loop phase."

即每跑完一个回调(或者主模块的同步代码),立刻:nextTick 队列清空 → microtask 队列清空 → 才继续下一个回调/相位。所以同一个相位内,前一个 timer 回调里塞的 Promise 会在下一个 timer 回调之前跑掉(见本地实验 4)。

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."

名字有误导性 —— 它根本不等"下一个 tick",它是比 microtask 还急的插队(来源 4 原文):

"When we pass a function to process.nextTick(), we schedule it to run immediately after the current call stack completes, before the event loop continues and before any other queued tasks or phases are processed"

来源 1 还给了官方态度:

"We recommend developers use setImmediate() in all cases because it's easier to reason about."

且 nextTick 标注为 Legacy,优先用 queueMicrotask()(来源 5)。递归 process.nextTick 会饿死 I/O(事件循环永远到不了下一个相位)—— 这既是坑也是少数合法用途(如 EventEmitter 在构造函数里发事件,见来源 1 示例:用 process.nextTick 保证调用者来得及挂 handler)。

2.4 主模块里 setTimeout(0) vs setImmediate 顺序不定;I/O 回调内 setImmediate 永远先

来源 1 的经典结论(原文):

"If both are called from within the main module, then timing will be bound by the performance of the process."
"However, if you move the two calls within an I/O cycle, the immediate callback is always executed first"
"The main advantage to using setImmediate() over setTimeout() is setImmediate() will always be executed before any timers if scheduled within an I/O cycle, independently of how many timers are present."

原因(来自 2.1/2.3 的模型):I/O 回调在 poll 相位跑完,下一个相位就是 check(setImmediate),而 setTimeout(0) 要等到下一轮循环的 timers 相位。

2.5 阻塞的定义与代价:Node 有两类线程,公平性是应用的责任

来源 2 的核心框架(原文):

"In Node.js there are two types of threads: one Event Loop (aka the main loop, main thread, event thread, etc.), and a pool of k Workers in a Worker Pool (aka the threadpool)."
"While a thread is blocked working on behalf of one client, it cannot handle requests from any other clients."
"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."

阻塞的两重动机:性能(throughput 下降)与安全("a malicious client could submit this 'evil input', make your threads block, and keep them from working on other clients. This would be a Denial of Service attack.")。

会阻塞 Event Loop 的典型元凶(来源 2):

"Avoid nested quantifiers like (a+)*."
"Avoid OR's with overlapping clauses, like (a|a)*."
"Avoid using backreferences... No regexp engine can guarantee evaluating these in linear time."

官方示例:filePath.match(/(\/.+)+$/) 遇到 100 个 / + \n 会 "take effectively forever"。对策:safe-regex/rxxr2 检测,或换 node-re2(Google RE2 引擎,线性时间,但不 100% 兼容 V8 正则);简单字符串匹配直接用 indexOf("never take more than O(n)")。

Worker Pool(libuv 线程池,默认 4 线程,可用 UV_THREADPOOL_SIZE 最大调到 1024,见来源 5)承担哪些工作(来源 2):

Worker Pool 也会被堵死(原文):

"each relatively long Task effectively decreases the size of the Worker Pool by one until it is completed."

最坏情况是攻击者反复读 /dev/random("for all practical purposes, /dev/random is infinitely slow"),占满 k 个 Worker 后所有依赖 Worker Pool 的请求全部卡死。解法是任务分片:把"时长不可控的大任务"拆成"成本相近的小任务"(官方例子:用 fs.read()/ReadStream 替代一次提交整个文件的 fs.readFile();对 crypto.randomBytes() 同理)。注意监控指标要用"完成的任务数"而不是"完成的子任务数"。

2.6 解法谱系:分片(Event Loop 上)vs 卸载(Worker Pool / worker_threads / child_process)

来源 2 给的两条路线:

"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... you have to serialize and deserialize any objects you wish to share."

选型判断(原文):

"For simple tasks, like iterating over the elements of an arbitrarily long array, partitioning might be a good option. If your computation is more complex, offloading is a better approach... if your server relies heavily on complex calculations, you should think about whether Node.js is really a good fit. Node.js excels for I/O-bound work."

2.7 worker_threads:真正的并行 JS 线程,但只该用在 CPU 密集场景

来源 3(API 文档)的关键主张(原文):

"The node:worker_threads module enables the use of threads that execute JavaScript in parallel."
"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."

与 child_process/cluster 的本质区别:

"Unlike child_process or cluster, worker_threads can share memory. They do so by transferring ArrayBuffer instances or sharing SharedArrayBuffer instances."

(child_process 是多进程、内存隔离、要走 IPC 序列化;worker_threads 是同进程多线程,可共享内存,也省掉了进程启动开销。)

机制要点:

最重要的实践警告(原文):

"The above example spawns a Worker thread for each parseJSAsync() call. In practice, use a pool of Workers for these kinds of tasks. Otherwise, the overhead of creating Workers would likely exceed their benefit."

即:别每个任务都 new 一个 Worker,要么复用(piscina 之类的池),要么任务要足够大才值得。

2.8 三者关系一图流(综合来源 1/2/3/5)

                        ┌──────────────────────────────────────────────┐
                        │              一个 Node.js 进程                 │
                        │                                              │
   JS 代码 ──同步执行──▶ │  Event Loop(主线程)                          │
                        │   每个回调返回点:nextTick 队列 → V8 microtask │
                        │   相位轮转:timers → pending → poll → check   │
                        │             → close                          │
                        │                                              │
   fs/dns.lookup/crypto │  Worker Pool(libuv 线程池,默认 4 线程)       │ ◀── 也可能被"堵死",需任务分片
   /zlib 异步 API ────▶ │  真队列排队;完成后回调进 poll                  │
                        │                                              │
   纯 CPU 计算 ───────▶ │  worker_threads(自建 N 个,每个独立 V8+事件循环)│ ◀── 与主线程并行,不阻塞事件循环
                        │  SharedArrayBuffer / postMessage(结构化克隆)   │
                        └──────────────────────────────────────────────┘

三、方法

本次主题研究的技术路线(研究方法论,非被研究对象的 method):

  1. 来源分层检索:先 WebSearch 拿候选池(官方 Learn 指南、API 文档、社区深度文),按"官方文档优先、社区文章用来交叉验证和纠偏"的原则选 5 个精读对象。
  2. 全文提取精读:对每个来源用 WebFetch 做定向全文提取(而非只读摘要),保留英文原句以便引述核对。
  3. 本地实验复现:对文档中"可被代码证伪"的排序与阻塞主张,用本地 node v24.22.1 写了 4 组实验(/tmp/eloop-verify/order1.js、order2.js、order4.js、block.js)实际运行验证,详见第四节。
  4. 冲突裁决:以官方文档为准绳;社区文章(来源 5)的主张(如 per-callback 清空粒度、timers 移到 poll 后)先对照来源 1 的原文确认,再用实验 4 复现。

四、数据与证据

4.1 本地实验(本次实际运行,环境 node v24.22.1 / Windows)

实验 1:主模块中五种调度顺序(node /tmp/eloop-verify/order1.js,连续运行 5 次)

代码结构:同步 log ×2 + setTimeout(0) + setImmediate + Promise.resolve().then + process.nextTick。

稳定部分(5/5 次): sync start → sync end → process.nextTick → promise microtask
可变部分:           setImmediate 与 setTimeout(0) 的先后
run1: setImmediate → setTimeout(0)
run2: setImmediate → setTimeout(0)
run3: setTimeout(0) → setImmediate    ← 翻车了一次!
run4: setImmediate → setTimeout(0)
run5: setImmediate → setTimeout(0)

证据意义:直接实证了官方"主模块内顺序不定"("timing will be bound by the performance of the process")—— 5 次里出现 1 次 setTimeout 先。也验证了完整优先级链:同步代码 → nextTick → Promise microtask → macrotask。

实验 2:I/O 回调内 setTimeout(0) vs setImmediate vs microtask vs nextTick(node /tmp/eloop-verify/order2.js,跑 2 次每次 2 轮 I/O,共 4 个样本,全部一致)

nextTick inside I/O cb
microtask inside I/O cb
immediate first?      ← setImmediate 永远先于 setTimeout(0)
timeout first?

证据意义:实证了"I/O cycle 内 setImmediate 永远先"的官方结论,且复现粒度正确 —— 在 I/O 回调里塞的 nextTick/microtask 也在该回调返回后立刻清空,之后才进 check 相位。

实验 3:microtask 清空粒度 —— 每个回调返回点 vs 每个相位(node /tmp/eloop-verify/order4.js)

timer1
  microtask-after-timer1     ← 第一个 timer 的微任务在 timer2 之前就跑了
timer2
  microtask-after-timer2
check1 (immediate)

证据意义:验证了来源 5 的关键主张 "The moment Node reaches a JavaScript callback return point, it drains the next tick queue first, then the V8 microtask queue" —— 如果是"每相位清一次",microtask-after-timer1 会推迟到整个 timers 相位结束后。同时 check 相位在当前轮 timers 相位之后执行。

实验 4:阻塞事件循环 vs worker_threads(node /tmp/eloop-verify/block.js block 和 node /tmp/eloop-verify/block.js worker)

[block]  sync loop burned main thread for 400ms
[block]  100ms timer actually fired after 401ms     ← 同步 CPU 循环把 100ms 定时器拖到 401ms
[worker] 100ms timer actually fired after 100ms     ← Worker 在别处烧 400ms,主线程定时器分毫不差
[worker] worker reports: burned own thread 400ms, main thread unaffected

证据意义:一组对照实验同时证明了两个核心主张:① "A timer specifies the threshold... rather than the exact time"(阈值非承诺)—— 事件循环被同步代码占住时定时器只能干等;② worker_threads 的 CPU 工作与主线程事件循环真正并行("threads that execute JavaScript in parallel")。

4.2 文档给出的量化数据(来源 2,未复现,转述)

4.3 证据充分性评估

排序主张(2.2–2.4)有官方文档 + 社区文 + 本地实验三重支撑,充分;worker_threads 主张(2.7)来自官方 API 文档原文 + 实验 4,充分;来源 2 的量化数据(4.2)为文档转述,未在本地复现其数值(其结论方向与实验 4 一致,风险低)。


五、结论

  1. Node.js 的"单线程"是 JS 执行的单线程,不是 I/O 的单线程:事件循环借内核多路复用(epoll/IOCP 等)做非阻塞 I/O,CPU 型异步操作(fs/crypto/zlib)走 libuv Worker Pool。真正会让"整站卡死"的只有两类:Event Loop 上的同步长任务,和 Worker Pool 被长任务占满。
  2. 执行顺序的完整心智模型(已实验证实):
    • 同步代码跑完 → 清空 nextTick 队列 → 清空 V8 microtask 队列 →(此时若队列有东西,回到上一步)→ 进入下一个回调/相位;
    • 每个回调返回点都重复上述清空,不是每相位一次;
    • macrotask 按相位排队:I/O 回调(poll)→ setImmediate(check,且 I/O 回调内调度时先于一切 timer)→ setTimeout(下一轮 timers);
    • setTimeout(fn, 0) 的 0 是阈值下限不是承诺(实验 4:400ms 阻塞下 100ms 定时器等到 401ms)。
  3. 实践决策表(综合来源 1/2/3):
场景正确做法别用
想在当前栈后立即跑(同一 tick 内)process.nextTick(仅限错误处理/事件先于监听等正当理由)滥用会饿死 I/O
常规"下个回合"异步setImmediate(官方推荐:"easier to reason about")或 queueMicrotask遗留的 nextTick
主模块内依赖 timer/immediate 顺序不要依赖(顺序不定)—
I/O 回调内需要"下一个循环相位"setImmediate(有确定性保证)setTimeout(0)
大数组/可分步的简单重活分片 + setImmediate 让路(asyncAvg 模式)一口吞的同步循环
复杂 CPU 计算worker_threads(配线程池,如 piscina)每请求 new Worker;每请求 child_process(fork bomb)
超大 JSON / 大文件流式(JSONStream / fs.readStream)同步 fs API、一次性 JSON.parse
正则处理用户输入node-re2 或先过 safe-regex 审计嵌套量词/重叠 OR/回溯引用
I/O 密集并发内置异步 API(比 Worker 高效,来源 3 原文)worker_threads("do not help much with I/O-intensive work")
  1. 监控(来源 5):用 monitorEventLoopDelay 和 performance.eventLoopUtilization() 量化事件循环健康度 —— 阻塞问题应该被测量而不是靠直觉。
  2. 一句话总结官方的安全底线(来源 2 结语,原文):
"To write a high-throughput, more DoS-proof web server, you must ensure that on benign and on malicious input, neither your Event Loop nor your Workers will block."

六、局限

作者承认/文档明示的:

  1. process.nextTick 官方标注 Legacy(来源 5),但大量存量代码(包括 Node 内部)仍依赖它,文档仍建议用 setImmediate 替代 —— 存量与推荐之间存在迁移张力。
  2. 正则工具链不完美:node-re2 "not 100% V8-compatible"(来源 2),safe-regex 只做静态估计。
  3. Worker Pool 共享是把双刃剑:fs、dns.lookup、crypto、zlib 共用默认 4 线程池,互不相干的异步操作会互相拖累;来源 2 自己也说"按任务类别分池"要 "consider this approach after careful analysis"(分池本身有线程开销与 CPU 竞争)。
  4. 官方文档明确未覆盖的:来源 2 结尾说 "This list is reasonably complete as of Node.js v9" —— 同步 API 清单可能随版本漂移。

我推断的(阅读中注意到的缺口):

  1. 时序保证的"文档豁免"问题:官方文档从未承诺 microtask 清空粒度(per-callback)是规范行为,实验 3 只在 v24.22.1 上验证了当前实现;跨版本(尤其 V8 大版本升级)时应以实测为准,生产代码不要依赖过于精细的微任务插入点。
  2. setImmediate 主模块顺序不定(实验 1 出现 1/5 反例),任何"我本机每次都是 setImmediate 先"的经验归纳都不可靠。
  3. 来源 4(官方 nextTick 指南页)内容很薄,没有讲 microtask 队列与 nextTick 的相对顺序 —— 该结论实际依赖来源 1/5 与实验 1 的支撑,而非官方单一页面。
  4. 本次未实测 UV_THREADPOOL_SIZE 调整、SharedArrayBuffer 并发写、piscina 等池化方案的性能差异;worker_threads 的"开销 vs 收益"临界点(任务多大才值得开 Worker)文档只给了定性警告,没有数字。
  5. monitorEventLoopDelay / performance.eventLoopUtilization() 的阈值与告警实践未展开(来源 5 只点名 API)。

七、对我的启发

  1. 把"排序题"降维成"模型题":与其背 "nextTick → Promise → setTimeout/setImmediate" 的顺序表,不如记住唯一结构事实 —— 每个回调返回点清 nextTick + microtask 两条高优先级队列,macrotask 按相位轮转。模型对了,任何组合都能现场推出来(实验 1–3 都是现场推 + 复现的)。
  2. 写 Code Review 检查项时新增一条"事件循环预算":任何会被用户输入控制规模的循环/正则/JSON 解析,都等于把服务器的响应延迟交到最坏输入手里。来源 2 的 "bounding the input and rejecting inputs that are too long"(限制输入并拒收过长输入)应该成为 API 网关层的默认动作。
  3. worker_threads 的选型口诀:"I/O 用内置异步,CPU 用 Worker,服务器级伸缩用 cluster"(三者不互换,来源 5)。而 Worker 本身要池化(piscina)—— 每任务 new Worker 的开销会吃掉收益,这与 Web 服务器里"每请求 fork 进程 = fork bomb"是同构的教训:线程/进程的生命周期管理必须与请求生命周期解耦。
  4. 可迁移到浏览器端:microtask 清空粒度、REDOS、长任务分片(浏览器里对应 requestIdleCallback/scheduler.yield)与 Node 同源;理解 Node 的 phase 模型后,浏览器 "render between macrotasks" 的模型也更容易类比。差异点要记牢:浏览器没有 process.nextTick 和 phases。
  5. 对自己写作的启发:来源 5 之所以比一般公众号文好,是因为它专门做"纠偏"(timers 位置变了、清空粒度是 per-callback、nextTick 是 Legacy)—— 每个技术主题都值得先问一句"流行讲法里哪一条已经过时了"。
  6. 运维侧:monitorEventLoopDelay / performance.eventLoopUtilization() 值得放进 Node 服务的标准健康检查 —— 阻塞是渐变的,等到用户投诉就已经是 DoS 了。

附录:关键原文引述备查

事件循环定义与相位(来源 1)

阻塞与 Worker Pool(来源 2)

worker_threads(来源 3)

microtask 粒度(来源 5)


*本笔记为 knowledge-v_9xs19r-2 精读任务产物,仅此一份;未改动其他条目笔记、matrix.csv、backlog 或 nightly-log.md。*

← 资料库今日播报 →