事件循环解释了已经就绪的代码怎样被调度,却还没有回答另一个更底层的问题:在回调重新进入队列以前,文件读取、名称解析、加密和网络请求究竟由谁完成?如果 JavaScript 线程并不忙,异步任务为什么仍然会排队?
答案藏在 Node.js 与操作系统之间,也藏在一次“看起来不像阻塞”的延迟事故里。
我遇到过一次很迷惑的线上抖动:批量导出功能上线后,接口的 p99 延迟突然翻了几倍。奇怪的是,进程 CPU 没跑满,内存没有明显上涨,事件循环延迟也很平稳;健康检查几乎秒回,但文件读取、密码派生,以及部分新建的外部 HTTP 请求会一起变慢。
我的第一反应是存储或上游网络出了问题。可磁盘指标正常,用 IP 直连的请求也不慢。接着我又怀疑 Promise.all() 一次提交太多任务,把 JavaScript 主线程堵住了,但 CPU profile 里看不到长时间运行的 JavaScript。
真正的转折来自一组分阶段耗时:慢请求不是卡在响应下载,而是卡在连接前的名称解析;同一时间,异步文件读取也在排队。导出流程恰好并发执行了多次 crypto.pbkdf2() 和压缩任务。它们与文件操作、默认的 dns.lookup() 共用 libuv 线程池。默认的四条工作线程被长任务占满后,JavaScript 线程虽然空闲,新的文件和名称解析任务却只能排队。
这场故障最值得记住的不是“把线程池调大”,而是一个更基础的判断:异步只说明调用方不用留在原地等,并不代表工作没有容量上限,也不代表结果会立刻回到 JavaScript。

事故刚发生时,我们把“异步”“非阻塞”“并发”和“并行”混着用了。每个人说的都像有道理,排查方向却完全不同。后来我强迫自己每次只问三个问题:结果什么时候交付?等待时哪条线程被占住?真正的工作交给了谁?
同步调用在返回时交付结果。调用方拿不到结果,就无法继续执行依赖它的代码:
import { readFileSync } from 'node:fs';
const content = readFileSync('package.json', 'utf8');
console.log(content.length);readFileSync() 返回以前,content 不存在,后面的 console.log() 也不会执行。
异步调用则先返回一个“稍后交付结果”的约定。这个约定可以是回调、Promise,也可以是流上的事件:
import { readFile } from 'node:fs/promises';
console.log('准备读取');
const pending = readFile('package.json', 'utf8');
console.log('已经拿到 Promise');
const content = await pending;
console.log('文件读取完成:', content.length);readFile() 很快返回 Promise,不等于文件已经读完。await 暂停的是当前 async 函数剩余的逻辑,不是整条事件循环线程。同一线程仍可处理其他已经就绪的回调;文件读取完成、Promise 兑现后,await 后的代码才作为微任务继续执行。
阻塞描述的是:当前线程在等待期间无法处理其他任务。readFileSync() 既同步交付结果,也会阻塞调用它的事件循环线程。
非阻塞描述的是:调用发出后,当前线程不必守在原地。异步网络接口,以及 node:fs 的回调和 Promise 接口,对 JavaScript 线程呈现的都是非阻塞行为。但“JavaScript 线程没被阻塞”不能推导出“系统里没有线程在等待”。文件系统调用可能在 libuv 工作线程里阻塞,网络请求可能在内核里等待数据,后续回调也可能在事件循环里排队。
异步不是“更快”的同义词。磁盘读取和远程请求该花多久,仍取决于设备、网络与对端服务。异步的主要收益,是等待期间不独占事件循环线程,让同一进程还能照顾其他连接。
为了确认时间到底消失在哪,我把一次 readFile() 或 fetch() 拆成了四段。这个拆法后来也成了定位 Node.js 延迟问题时最好用的心智模型。
JavaScript 调用 Node.js 内置 API。参数在 JavaScript 与原生绑定层被检查、转换,回调或 Promise 的后续逻辑也在这里登记。
原生绑定把跨平台 I/O 工作交给 libuv。libuv 维护事件循环、I/O 句柄、请求对象和工作线程池,并为不同操作选择合适的系统能力。
libuv 根据操作类型创建套接字、发起连接、读取文件,或等待描述符就绪。一个 Node API 背后可能有多个系统调用,两者并不是一一对应的。
内核或工作线程报告完成后,libuv 标记请求已就绪。只有等事件循环运行到合适的阶段、调用栈空下来,Node 才会执行 JavaScript 回调或兑现 Promise。

应用运行在用户空间,不能直接控制网卡、磁盘或进程调度。创建 socket、发起连接、读取数据、等待文件描述符变化,都要借助内核提供的系统调用。跨越用户空间与内核空间会有参数检查、权限切换和数据复制等成本,但服务最怕的不是“调用过系统调用”,而是把事件循环线程留在一次可能长期等待的调用中。
libuv 在这里做了两件很关键的事:把平台差异封装起来,也把“等待 I/O”与“执行 JavaScript”分开。Linux 的网络就绪通常由 epoll 管理,macOS 和 BSD 常见 kqueue,Windows 则使用 IOCP 的完成通知模型。实现方式不同,但 Node.js 最终都能收到统一的完成信号。
“把 I/O 交给操作系统”不等于“操作系统替我们执行 JavaScript”。内核负责等待和搬运数据,libuv 接收通知并安排后续工作;业务回调最终仍回到事件循环线程执行。
这也是那次排查里第二个容易误判的地方。网卡已经收到完整响应,只能说明 I/O 条件满足了。如果事件循环正在解析一大段 JSON、执行同步 API,或被一个很长的回调占住,响应对应的 JavaScript 仍然无法执行。
所以我现在会把一次异步操作的可观察耗时拆成三段:
如果上游 20 ms 就返回,而应用日志里的 await fetch() 却用了 200 ms,缺少分阶段数据时不能直接判定“网络慢”。多出来的 180 ms 既可能发生在 DNS、连接或下载阶段,也可能是响应已经到达,主线程却正被同步工作占用。
TCP 服务器不会为每个连接分配一条 JavaScript 线程。libuv 把网络 socket 设为非阻塞状态,再让操作系统监视大量 socket。某个连接可读、可写或完成连接时,操作系统通知事件循环,JavaScript 才回来处理结果。
我喜欢把它想成餐厅的取餐屏:服务员登记订单后继续接待新客人,不必守着每一道菜。屏幕提示某份订单完成,服务员再回来交付。内核和 libuv 负责“登记与通知”,JavaScript 回调负责“回来处理”。
一次 HTTP 请求也不是一个不可拆分的等待。它可能经历名称解析、建立 TCP 连接、TLS 握手、发送请求、首字节到达和分批下载。Node 的 HTTP 能力把 socket 上的一系列事件封装成请求与响应,流上的 'data'、'end'、'error' 也说明数据往往是分段抵达的。
这里藏着那次事故的关键例外:网络 socket 本身通常不占 libuv 线程池,但连接使用主机名、客户端采用系统名称解析时,背后的 dns.lookup() 会在 libuv 线程池里执行同步的系统名称解析。名称解析结束后,TCP 连接与收发才回到操作系统的网络机制。于是线程池饱和可能伪装成“HTTP 在连接前卡了很久”。
下面这个脚本只使用 Node 内置能力:本机服务把每个响应延后约 300 ms,客户端同时发出三个请求。总耗时通常接近 300 ms,而不是三个等待简单相加后的 900 ms。
// network-concurrency.mjs
import { createServer } from 'node:http';
import { once } from 'node:events';
import { performance } from 'node:perf_hooks';
const server = createServer((_request, response) => {
setTimeout(() => {
response.writeHead(200, { 'content-type': 'application/json' });
运行它:
node network-concurrency.mjs这能证明多个等待可以同时在途,却不能证明三段 JavaScript 在三个 CPU 核上运行。定时器回调和响应解析仍由事件循环逐段执行,只是三个请求的等待时间重叠了。
普通文件不像网络 socket 那样天然适合统一的就绪通知。为了提供跨平台的异步文件 API,libuv 会把文件系统操作提交到全局线程池。工作线程执行可能阻塞的文件调用,结束后再把完成消息交回事件循环。
因此,fs.readFile() 没有阻塞事件循环线程,不代表底层从未发生阻塞;阻塞被移到了 libuv 工作线程。除了异步文件操作,常见的线程池使用者还有:
dns.lookup() 与 dns.lookupService();crypto.pbkdf2()、crypto.scrypt()、部分随机数和密钥生成操作;dns.resolve4() 等 resolve* 方法则会直接进行异步 DNS 网络查询,不使用这座线程池。不过它们与 dns.lookup() 的语义并不完全相同,例如不会沿用 /etc/hosts 等系统名称解析配置,不能为了绕过线程池就无条件替换。
这解释了为什么看似互不相干的业务会互相拖慢:大批慢文件读取可以让 dns.lookup() 排队,密集的 PBKDF2 也能挤压文件与压缩任务。它们争夺的不是 JavaScript 主线程,而是同一个底层工作池。

不要根据 API 名里有没有 async 猜执行路径,也别把数据库连接池与 libuv 线程池当成一回事。大多数数据库驱动通过网络 socket 通信;连接池限制的是可复用数据库连接,libuv 线程池承载的是文件、部分 DNS、加密等底层任务。两者可能都出现排队,但排的不是同一种资源。
那次事故里,Promise.all() 不是根因,却放大了瞬时压力:它让许多本来就会占用线程池的任务同时入队。要说清这件事,必须先把并发与并行分开。
请求 A 等数据库、请求 B 等上游 HTTP、请求 C 的响应已经就绪时,事件循环可以先处理 C;A 就绪后再回来处理 A。多个任务在一段时间内交错推进,这叫并发。只要每次进入 JavaScript 的工作都很短,一条事件循环线程就能照顾许多连接。
并行则要求多个执行单元在同一时刻工作。例如 libuv 的多条工作线程可以同时执行 PBKDF2,多个 worker_threads 可以同时运行 JavaScript 计算。它们是否落到不同 CPU 核,仍由可用硬件和操作系统调度决定。

Promise.all() 做的事情,是同时订阅多个 Promise,并在全部兑现后交付结果。如果 Promise 包装的是网络 I/O,等待可以重叠;如果每个任务一开始就执行很长的同步 JavaScript,它不会凭空创建线程:
const burnCpu = (id) => {
const end = Date.now() + 200;
while (Date.now() < end) {
// 故意占住当前 JavaScript 线程
}
return id;
};
const result = await Promise.all([
Promise.resolve().then(()
三个微任务仍会依次占用事件循环,总耗时约 600 ms。需要长期占用 CPU 的 JavaScript 更适合放进 node:worker_threads、子进程或独立计算服务;libuv I/O 线程池不会替普通 JavaScript 代码并行计算。
线程池饱和时,简单健康检查仍可能很快,因为事件循环本身没有被占住。同步 API 导致的故障恰好相反:一个请求执行 readFileSync(),同一进程里的其他连接、定时器、Promise 后续逻辑和已经完成的 I/O 回调都得等它返回。

下面这个小服务会在系统临时目录创建演示文件,并提供 /sync 与 /health 两条路由。文件故意设得较大,让阻塞更容易观察;结束进程时会删除它。
// blocking-server.mjs
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const demoFile = join(tmpdir(), `node-blocking-demo-${process.pid}.bin`);
await
先启动服务:
node --trace-sync-io blocking-server.mjs再从其他终端反复请求两条路由:
curl http://127.0.0.1:3000/sync
curl http://127.0.0.1:3000/health当 /sync 正在读取时,/health 也可能被拖慢。文件系统缓存、设备速度和机器负载都会影响现象,所以这个实验适合观察机制,不适合推算生产容量。
同步文件、child_process.execSync()、同步加密和同步压缩很容易识别。更隐蔽的是那些名字里没有 Sync、却直接在事件循环上运行的重活:
因此,“代码里没有 Sync API”只能排除一类嫌疑。真正该测的是每次回调连续占用事件循环的时间。
同步 API 也不是绝对禁区。进程尚未接收流量时,读取一小份配置、证书或模板,用同步写法往往更直白;按顺序完成单一任务的命令行程序也未必需要异步化。危险边界是共享服务开始承载并发流量以后:请求处理器、定时器与消息消费者共用事件循环,一个同步等待会把影响扩散给其他任务。
不要在 HTTP 请求路径中反复用 readFileSync 读取配置,也不要为了让代码看起来顺直,就把异步加密换成同步版本。启动时做一次同步初始化,与每个请求都同步执行,是完全不同的负载模型。
libuv 线程池默认有 4 条工作线程,并在进程内全局共享。以网络 I/O 为主的服务未必会碰到它的上限;一旦大量文件操作、dns.lookup()、异步加密与压缩同时出现,四条线程很快就会被占满,后续任务只能进入队列。
我用下面的脚本复现过这种“主线程很闲,异步任务却在排队”的状态。八个 crypto.pbkdf2() 都会进入 libuv 线程池。输出时间受 CPU 和机器负载影响,真正值得观察的是任务是否分批完成。
// pool-lab.mjs
import { pbkdf2 } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { promisify } from 'node:util';
const deriveKey = promisify(pbkdf2);
const allStartedAt = performance.now();
const jobs = Array.from({ length: 8 }, async (_, index)
在线程池首次使用前,通过环境变量改变大小:
UV_THREADPOOL_SIZE=4 node pool-lab.mjs
UV_THREADPOOL_SIZE=8 node pool-lab.mjs默认配置下,前四个任务先占住工作线程,其余任务排队。设为 8 后,更多任务可以同时开始;但如果机器只有少量 CPU 核,八个 PBKDF2 会争抢计算资源,单任务甚至可能更慢。线程数不能脱离吞吐、尾延迟、CPU 与内存单独评估。
UV_THREADPOOL_SIZE 默认是 4,libuv 接受的绝对上限是 1024。线程池第一次被使用时,会按配置初始化工作线程,因此应在进程启动前通过启动命令、容器环境或进程管理器设置,而不是等应用跑起来后再修改 process.env。
把数值粗暴调到几百并不会创造更多磁盘带宽或 CPU。线程越多,线程栈地址空间、调度和任务切换的成本越高;等待位置也可能只是从应用移到磁盘、解析器或其他下游。更重要的是,这个变量只影响使用 libuv 线程池的 API:它不会让 socket 收发更快,不会并行执行普通 JavaScript,也不会缓解 readFileSync() 对主线程的阻塞。
确认慢操作是否真的使用 libuv 线程池。远程 HTTP 下载慢、数据库连接池耗尽、同步 JSON 解析,都不是 UV_THREADPOOL_SIZE 能直接解决的问题。
确认真正耗尽的资源。文件任务可能受磁盘吞吐限制,PBKDF2 受 CPU 限制,dns.lookup 还受系统解析器影响;工作线程只是承载者,不会生成额外的硬件能力。
用接近真实流量的并发量建立基线,同时记录吞吐、p95/p99、CPU、内存和事件循环延迟,再分别尝试 8、16 等有限配置。每轮只改一个变量。
如果增加线程只让下游更拥挤,就在入口限制并发、分批处理或引入任务队列。让排队发生在可观测、可控制的位置,通常比放任任务争抢更稳定。

即使 API 都是异步的,一次创建十万个文件任务仍会占内存、拉长队列,并让超时与错误处理变得困难。最简单的批处理可以这样写:
import { readFile } from 'node:fs/promises';
async function readInBatches(paths, batchSize = 8) {
const result = [];
for (let index = 0; index < paths.length; index += batchSize) {
const batch = paths.slice(index, index + batchSize);
const files
这个版本等一批结束再启动下一批,吞吐未必最大,但同时在途的任务数是明确的。真实服务还需要结合超时、取消、重试与错误隔离,也可以使用并发限制器做滑动窗口。核心原则不变:容量有限,就应该主动施加背压,而不是把所有任务一次塞进不可见的队列。
Node 内置的 node:perf_hooks 可以观察事件循环延迟和利用率。下面的代码每五秒输出一次 p99 延迟,以及该区间内的事件循环利用率:
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
const delay = monitorEventLoopDelay({ resolution: 20 });
delay.enable();
let previous = performance.eventLoopUtilization();
setInterval(() => {
const utilization = performance.eventLoopUtilization(previous);
previous = performance.eventLoopUtilization();
console.log
monitorEventLoopDelay() 记录的单位是纳秒,所以示例除以 1e6 转成毫秒。延迟升高只说明采样回调没有按时运行,不会自动指出是哪段代码造成的;利用率高也只说明事件循环繁忙。还需要结合 CPU profile、请求追踪和分阶段计时继续定位。
--trace-sync-io 可以帮助发现事件循环启动后的同步 I/O。dns.lookup() 同时变慢: 很像 libuv 线程池饱和。分别记录操作提交与完成时间,再用有限的线程池配置做对照实验。经历那次故障后,我把排查“Node I/O 卡住”固定成下面这条路径:
fs、dns.lookup()、异步加密和 zlib 的并发量,寻找共享线程池争用;只看到“接口耗时 800 ms”,几乎无法判断应该拧哪个旋钮。把它拆成入口排队、I/O、线程池排队、回调等待与业务计算,问题才会从一句模糊的“异步也很慢”,变成可以验证的假设。
回头看开头那场故障,完整链路已经很清楚:JavaScript 发起任务后很快拿到 Promise;PBKDF2、压缩、文件读取和系统名称解析却在共享线程池里竞争。线程池排队没有堵住事件循环,因此健康检查正常;名称解析迟迟拿不到工作线程,又让外部 HTTP 看起来像网络变慢。
最后起作用的不是一个万能参数,而是组合处理:压低批量导出的并发,避免长加密任务瞬间灌满队列;用受控压测评估线程池大小;给名称解析、连接和文件操作分别打点。这样既缩短了尾延迟,也保留了以后继续定位的证据。
以后再遇到“Node.js 明明异步,为什么还是卡”,我会先画出三条队列:事件循环上是否有长回调,libuv 线程池里是否有共享任务排队,外部系统是否已经达到容量。异步 I/O 真正强大的地方,从来不是消灭等待,而是允许我们看清等待、重叠等待,并把排队放到能够治理的位置。
运行时、事件循环和系统 I/O 至此形成了一条完整链路。可真正的后端不会停留在单文件实验里:代码会被拆分、依赖会被安装、不同模块格式会混在一起,开发机上能跑的程序还要在另一台机器上被准确复现。运行问题解决以后,工程问题随之出现,而它的第一道边界就是模块系统。