12k
All articles

Node.js 中的事件循环、Worker 线程与并发

解析 Node.js 事件循环、libuv 线程池、worker threads 和 cluster,清晰说明 I/O 与 CPU 密集型并发。

OpenReplay Team
OpenReplay Team
Node.js 中的事件循环、Worker 线程与并发

Node.js 在由事件循环驱动的单线程上运行你的 JavaScript;并发来自于将等待外包出去——libuv 将阻塞 I/O 交给后台线程池处理——而非并行运行你的 JS。当你需要为 CPU 密集型任务并行运行 JavaScript 时,这是另一套独立机制:worker threads,每个都是拥有独立事件循环的完整 V8 隔离实例。这三者——单 JS 线程、libuv 线程池以及 worker 线程——经常被混为一谈,而这种混淆正是导致接口响应缓慢和服务器卡顿的根源。

本文将精确区分这几个层次,解释事件循环的各个阶段以及微任务/宏任务的划分、libuv 线程池实际服务于哪些操作(以及哪些不是)、worker 线程何时优于普通的 async/await、集群(clustering)与之有何不同,以及如何在它们之间做出选择。代码基于 Node.js 24(Active LTS)编写,Node.js 26 为 Current 版本线;worker 线程已是稳定特性,而非实验性功能。

核心要点

  • Node 在单线程上执行你的 JavaScript;事件循环通过将阻塞 I/O 交给 libuv 的后台线程池来实现并发,而非并行运行你的代码。
  • libuv 线程池默认为 4 个线程,可通过 UV_THREADPOOL_SIZE 最大扩展至 1024 个;它服务于文件系统、dns.lookup、crypto 以及 zlib 操作——但不包括网络套接字,后者直接使用操作系统的 epoll/kqueue/IOCP。
  • worker 线程并非”只是一个 OS 线程”——每个 worker 都是独立的 V8 隔离实例,拥有自己的事件循环和 libuv 循环,这也是为什么 worker 无法共享普通对象,必须通过消息传递进行通信。
  • 微任务并非事件循环的某个阶段:process.nextTick 的回调首先被清空,然后是 Promise 微任务队列,之后事件循环才会推进到下一个宏任务。
  • 根据瓶颈来选择工具:I/O 密集型工作用 async/await,CPU 密集型 JavaScript 用 worker 线程,跨核心扩展 I/O 密集型负载用 cluster。

核心模型:单 JS 线程、事件循环与 libuv

Node.js 在单线程上执行你的 JavaScript,该线程运行事件循环。运行时基于 Google 的 V8 引擎来执行 JavaScript,以及基于 libuv——一个提供事件循环和异步 I/O 的 C 库。让单线程能处理数千个并发连接的关键在于委托:当你的代码调用像文件读取这样的阻塞操作时,Node 不会原地等待。它将操作注册到 libuv,立即返回,当结果就绪时再运行你的回调。

Node 中的并发是将等待外包出去。当文件读取或 DNS 查询挂起时,单 JS 线程可以自由运行其他回调。你的 JavaScript 中没有任何代码是并行运行的——只有一个调用栈——但许多操作可以同时在途,因为慢的部分发生在别处。

这就是为什么”Node 是单线程的”是一个值得纠正的半真半假的说法。JavaScript 执行是单线程的,但运行时不是:libuv 维护着一个后台线程池,操作系统代表 Node 处理网络套接字。将”单线程”理解为关于你的代码运行位置的描述,而非整个进程的描述。

常见误区: “Node 是单线程的。” 你的 JavaScript 运行在一个线程上;但 Node 进程使用了多个线程。这一区别正是本文的核心所在。

并发与并行的精确定义

并发是指多个任务在同一时间段内通过在共享资源上交替执行来取得进展;并行是指多个任务在同一时刻在不同核心上同时执行。运行 Node 的单核机器是并发的,但不是并行的:事件循环在各个在途操作之间快速切换,但任意时刻只有一段 JavaScript 在执行。Worker 线程和集群通过引入额外的执行上下文来实现真正的并行,操作系统可以将这些上下文调度到不同的核心上。

实际意义在于:并发解决等待问题(I/O),并行解决计算问题(CPU)。选错工具是大多数 Node 性能问题的根源。

事件循环的六个阶段与微任务划分

事件循环按固定的六阶段循环运行,每个阶段都有自己的回调队列,会在进入下一阶段前将其完全清空。根据官方的 Node.js 事件循环指南,各阶段依次为:

  1. Timers(定时器) —— 运行由 setTimeout()setInterval() 调度的、阈值已到期的回调。
  2. Pending callbacks(待处理回调) —— 执行上一轮循环中延迟的某些系统级回调。
  3. Idle, prepare(空闲、准备) —— 仅供内部使用。
  4. Poll(轮询) —— 获取新的 I/O 事件并运行其回调;如果没有其他任务,循环将在此阻塞等待 I/O。
  5. Check(检查) —— 运行 setImmediate() 的回调。
  6. Close callbacks(关闭回调) —— 运行关闭处理程序,如 socket.on('close', ...)

注意第三阶段:许多解释文章只列出五个阶段,省略了 idle/prepare,该阶段确实存在,但保留给 libuv 内部使用。它是真实存在的,只是你永远不会直接向其中调度任务。

微任务并非某个阶段

微任务并非事件循环的某个阶段。process.nextTick 的回调首先被清空,然后是 Promise 微任务队列,之后事件循环才会推进到下一个宏任务——因此 process.nextTick 优先级高于 Promise.then,后者又高于 setTimeout。Node 指南明确指出,process.nextTick 在技术上并非事件循环的一部分;无论当前处于哪个阶段,其队列都会在当前操作完成后立即处理,Promise 队列紧随其后——两者都在循环推进之前执行。

这给出了清晰的三层优先级:process.nextTick → Promise 微任务 → 宏任务(定时器、I/O、setImmediate

具体的执行顺序演示

经典的困惑是 setImmediatesetTimeout(0) 的对比。在 I/O 回调内部,顺序是确定的;在顶层则不然。

// 运行于 Node.js 24.16.0
const fs = require('node:fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('1: setTimeout(0)'), 0);
  setImmediate(() => console.log('2: setImmediate'));
  Promise.resolve().then(() => console.log('3: promise'));
  process.nextTick(() => console.log('4: nextTick'));
});

输出:

4: nextTick
3: promise
2: setImmediate
1: setTimeout(0)

nextTick 和 Promise 都在循环推进前被清空,nextTick 优先。然后,由于这些回调是在 I/O 循环(poll 阶段)内部调度的,循环接下来进入 check 阶段,因此 setImmediate 在循环回到 timers 阶段之前触发。Node 指南确认,当两者都在 I/O 循环内调度时,setImmediate() 总是在定时器之前执行。如果在顶层调度同样的两者,顺序则是不确定的——不要在 I/O 回调之外依赖这一顺序。

libuv 线程池:默认 4 个,最大 1024 个——以及哪些操作实际使用它

libuv 线程池是一组固定数量的后台线程——默认 4 个,最多可扩展至 1024 个——libuv 用它来运行那些没有非阻塞 OS 原语的操作。根据 libuv 线程池文档,最大值通过 UV_THREADPOOL_SIZE 环境变量设置。(上限从 128 提升至 1024 是在 libuv 1.30.0 中——引用”128”的旧文章已过时。)该线程池在进程内所有事件循环之间共享。

使用它的是一个具体且有限的列表。Node.js 关于 UV_THREADPOOL_SIZE 的 CLI 文档列出了使用者:fs API(除文件监视器和显式同步变体外)、dns.lookup(),以及异步 crypto 和 zlib 操作,如 crypto.pbkdf2()crypto.scrypt()crypto.randomBytes()crypto.generateKeyPair()zlib 压缩。

这里最重要的是哪些操作使用它。网络 I/O 不接触线程池。 正如官方的 Don’t Block the Event Loop 指南所解释的,网络套接字由操作系统的轮询机制处理——Linux 上是 epoll,macOS/BSD 上是 kqueue,Windows 上是 IOCP——并直接在 poll 阶段呈现。DNS 还有一个细节:dns.lookup()(调用 getaddrinfo)使用线程池,但 dns.resolve*() 系列(使用 c-ares)则不使用,因此”DNS 使用线程池”这一笼统说法是错误的。

# 在 Node 启动前设置——线程池在首次使用时预分配。
UV_THREADPOOL_SIZE=8 node server.js

一个运维上的注意事项:libuv 在线程池首次使用时会预分配最大数量的线程,因此 UV_THREADPOOL_SIZE 必须在此之前设置——实际上,要在 Node 启动之前设置。在线程池被触及之后再修改 process.env.UV_THREADPOOL_SIZE 不会有任何效果。

常见误区: “增大 UV_THREADPOOL_SIZE 来加速 CPU 工作。” 提高 UV_THREADPOOL_SIZE 只能加速并发 I/O,永远无法加速 CPU 密集型 JavaScript——对于 CPU 工作,你需要 worker 线程,因为线程池不执行你的 JavaScript,它运行的是 libuv 的 C 级别操作,而非你的 JS 函数。

Worker 线程:CPU 密集型 JavaScript 的真正并行

Worker 线程在独立线程上并行运行 JavaScript,其目的只有一个——处理那些否则会阻塞单 JS 线程的 CPU 密集型工作——它们对 I/O 密集型工作没有帮助,因为 Node 内置的异步 I/O 已经能更高效地处理这类工作。worker_threads 文档指出,worker 适用于执行 CPU 密集型 JavaScript 操作,对 I/O 密集型工作帮助不大,因为 Node 内置的异步 I/O 比 worker 更高效。

大多数文章忽略的关键准确性要点是:worker 线程并非”只是一个 OS 线程”。每个 worker 都是独立的 V8 隔离实例,拥有自己的事件循环自己的 libuv 循环。正是这种隔离性决定了 worker 无法共享普通 JavaScript 对象,以及你通过 postMessage 传递的所有内容都会被复制。

常见误区: “Worker 只是 OS 线程。” 每个 worker 都是在一个线程中运行的完整 V8 隔离实例加上其自己的事件循环和 libuv 循环——这就是为什么主线程和 worker 之间没有共享的全局变量,也没有共享的闭包作用域。

隔离与消息传递

Worker 通过消息传递进行通信,且负载会被深拷贝。你通过 workerDatapostMessage() 传递的数据按照 HTML 结构化克隆算法进行克隆——函数、类原型和活引用在传递过程中不会保留。唯一能避免复制的方式是共享内存:worker 线程只能通过 SharedArrayBuffer 共享内存(或通过转移 ArrayBuffer,这是转移所有权而非复制)。其他所有内容都会被结构化克隆。

// main.js — 运行于 Node.js 24.16.0
const { Worker } = require('node:worker_threads');

const worker = new Worker('./fib-worker.js', { workerData: { n: 42 } });
worker.on('message', (result) => console.log('fib(42) =', result));
worker.on('error', (err) => console.error(err));
// fib-worker.js
const { parentPort, workerData } = require('node:worker_threads');

function fib(n) {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

parentPort.postMessage(fib(workerData.n));

当你不想拆分文件时,isMainThread 可以让单个文件在两种角色之间切换。关于完整的 API 接口——MessageChannelMessagePort、transfer lists、receiveMessageOnPort——请参阅 worker_threads 文档。与 C++ 或 Java 中的线程相比,这种模型将默认共享内存(以及随之而来的锁、互斥量和竞态条件)换成了默认隔离:更安全,代价是数据复制。

使用线程池,而非每个任务创建一个 worker

为每个请求创建一个 Worker 是浪费的。Node 文档明确指出,在实践中应该使用 worker 池,因为否则创建 worker 的开销可能会超过其带来的收益。社区标准的线程池是 piscina。Piscina 是一个快速、高效的 Node.js worker 线程池实现;其最新版本为 5.2.0。在你的 package.json 中写 5.x 的版本范围,并在 CI 中锁定具体的小版本号。

有一个推论常常让人踩坑:不要将已经是异步的工作移入 worker。异步 crypto、fs 和 zlib 已经在 libuv 的后台线程上运行,因此将它们包装在 worker 中只是让一个线程去调度另一个线程,毫无收益。Worker 只在同步、CPU 密集型 JavaScript 上才能发挥价值。

实战示例:阻塞所有人的 CPU 密集型路由

主线程上的同步 CPU 任务会冻结所有在途请求的事件循环,而不仅仅是触发它的那一个。以下是 Express 处理程序中的失败场景:

// server-blocking.js — 运行于 Node.js 24.16.0, express 5.x
const express = require('express');
const app = express();

function fib(n) {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

app.get('/report', (req, res) => {
  res.json({ value: fib(45) }); // 阻塞事件循环数秒
});

app.get('/health', (req, res) => res.send('ok'));

app.listen(3000);

fib(45) 运行时,/health 没有任何响应——单线程正忙于计算,所有并发请求都在其后排队。解决方案是将 CPU 工作卸载到线程池并 await 结果,从而释放事件循环继续处理其他请求:

// server-pooled.js — 运行于 Node.js 24.16.0, express 5.x, piscina 5.2.0
const express = require('express');
const Piscina = require('piscina');
const path = require('node:path');

const pool = new Piscina({ filename: path.resolve(__dirname, 'fib-task.js') });
const app = express();

app.get('/report', async (req, res) => {
  const value = await pool.run({ n: 45 }); // 在 worker 上运行;事件循环保持空闲
  res.json({ value });
});

app.get('/health', (req, res) => res.send('ok'));

app.listen(3000);
// fib-task.js
module.exports = ({ n }) => {
  const fib = (x) => (x < 2 ? x : fib(x - 1) + fib(x - 2));
  return fib(n);
};

现在 fib(45) 在 worker 上运行,事件循环保持响应,/health 在重型路由后台计算时立即返回响应。

被阻塞的事件循环在生产环境中有一个典型特征:由于单个同步 CPU 任务会冻结所有在途请求的单 JS 线程,它表现为许多并发用户在同一时刻同时卡顿——而非某一个用户的网络问题。这种关联性冻结模式正是会话回放在多个同时进行的会话中所呈现的,你也可以在运行时通过 node:perf_hooks 中的 monitorEventLoopDelay 来确认——高 p99 值表明循环(或线程池)已饱和。

集群与多进程:跨核心扩展 I/O

集群通过 fork 多个进程来将 Node 应用扩展到多个 CPU 核心,每个进程运行完整的应用,拥有自己的事件循环和内存,共享一个监听套接字。cluster 模块是内置的实现方式;当你的瓶颈是 I/O 密集型吞吐量,且希望利用机器的所有核心而非仅一个时,它是正确的工具。

与 worker 线程的区别很重要。Cluster worker 是独立的进程,拥有完全隔离的内存,通过 IPC 通信;worker 线程是同一进程内的独立线程,可以通过 SharedArrayBuffer 共享内存。Cluster 是为了跨核心处理更多并发请求;worker 线程是为了将 CPU 密集型 JavaScript 从请求线程上移走。在实践中,高吞吐量服务通常两者兼用:用 cluster(或进程管理器/容器副本)跨核心分布,并在每个进程内使用 worker 池来吸收偶发的 CPU 峰值。

决策指南:async、pool、worker 还是 cluster?

根据你的瓶颈来选择:I/O 密集型工作用 async/await,CPU 密集型 JavaScript 用 worker 线程,跨核心扩展 I/O 密集型负载用 cluster(或多进程),当你否则需要为每个请求承担 worker 启动成本时,使用像 piscina 这样的 worker

工具并行运行 JS?共享内存?最适合主要代价
async/await + 事件循环不适用(单线程)I/O 密集型工作(网络、数据库、文件)CPU 工作会导致阻塞
libuv 线程池否(运行 C,非你的 JS)不适用并发 fs/dns.lookup/crypto/zlib固定大小;不适用于你的 JS
Worker 线程(+ 池)仅通过 SharedArrayBufferCPU 密集型 JavaScript启动开销 + 结构化克隆复制
Cluster / 多进程否(仅 IPC)跨核心扩展 I/O 密集型负载进程开销;无共享状态

快速规则:

  • I/O 密集型且尚未并行化? 使用 async/await。线程池和操作系统已经免费提供了并发能力。
  • 某个路由执行大量同步计算? 卸载到 worker 线程,并使用池来复用。
  • 在并发流量下单核已饱和? 跨核心使用 cluster(或运行多个容器副本)。
  • 想通过提高 UV_THREADPOOL_SIZE 来提速? 仅当你在池支持的操作上存在 I/O 瓶颈时(大量并发 fs/crypto)才有效。它永远无法加速 CPU 密集型 JavaScript。

对于今天编写的代码,有一个前瞻性说明:从 2026 年 10 月的 Node.js 27 开始,该项目每年发布一个主版本,结束奇偶版本号的发布节奏。请跟踪 Node.js 发布计划,并基于当前 LTS 版本进行构建。

让这一切保持清晰的心智模型是:停止问”Node 是单线程的吗?“,开始问”我的瓶颈是什么?“等待是事件循环的职责;并行计算是 worker 池的职责;跨核心分散负载是 cluster 的职责。分析慢速接口,判断它是卡在等待还是卡在计算,正确的工具自然浮现——然后在上线前用 monitorEventLoopDelay 验证修复效果。

常见问题

Node.js 中 worker 线程与 cluster 模块有什么区别?

Worker 线程是同一进程内的独立线程,每个都是拥有自己事件循环的 V8 隔离实例,可以通过 SharedArrayBuffer 共享内存;cluster 则 fork 出独立进程,进程间内存完全隔离,通过 IPC 通信并共享监听套接字。使用 worker 线程将 CPU 密集型 JavaScript 从请求线程上移走,使用 cluster 跨 CPU 核心扩展 I/O 密集型负载。高吞吐量服务通常两者兼用。

为什么提高 UV_THREADPOOL_SIZE 无法让我的 CPU 密集型代码更快?

libuv 线程池运行的是 C 级别操作,如文件系统调用、dns.lookup、异步 crypto 和 zlib,而非你的 JavaScript。提高 UV_THREADPOOL_SIZE 只会增加这些 I/O 类操作的并发数量;它永远无法加速 CPU 密集型 JavaScript,因为这些代码仍然在单 JS 线程上执行。对于 CPU 工作,你需要 worker 线程,它在自己的 V8 隔离实例中并行运行 JavaScript。

Node.js 是否在 libuv 线程池上处理网络请求?

不是。网络套接字由操作系统的轮询机制处理——Linux 上是 epoll,macOS 和 BSD 上是 kqueue,Windows 上是 IOCP——并直接在事件循环的 poll 阶段呈现。libuv 线程池服务于文件系统操作、通过 getaddrinfo 的 dns.lookup、异步 crypto 和 zlib,而非网络 I/O。注意,dns.resolve 系列函数使用 c-ares 库,同样绕过了线程池,因此'DNS 使用线程池'只对 dns.lookup 成立。

Worker 线程能与主线程共享 JavaScript 对象吗?

不能。Worker 通过消息传递进行通信,通过 workerData 或 postMessage 传递的数据使用 HTML 结构化克隆算法进行深拷贝,因此函数、类原型和活引用在传递过程中不会保留。共享内存的唯一方式是 SharedArrayBuffer,或者转移 ArrayBuffer(这是转移所有权而非复制)。这种默认隔离避免了 C++ 或 Java 等语言中共享内存线程模型所带来的锁和竞态条件问题。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.