把 Node.js 放回“JavaScript 运行时”的位置后,最先冒出来的问题不是 API 怎么记,而是这个运行时内部到底怎样分工。前面那段同步循环已经证明,JavaScript 执行线程一旦被占住,整个服务都会失去响应;可一个真实进程里明明还有 V8、libuv、操作系统和工作线程,它们分别在做什么,任务又在哪里排队?
为了回答这个问题,我们把同一种阻塞放进一个真正承接流量的服务里。
那次故障最反常的地方,是一个报表接口拖慢了所有接口。
上午流量刚起来,监控里的请求延迟突然呈锯齿状上升:每隔一小段时间,健康检查、登录、订单查询会一起卡住一两秒。数据库耗时正常,下游没有告警,机器总 CPU 也没满。最初我怀疑连接池不够,接着怀疑网络抖动,甚至盯了一阵垃圾回收;可这些猜测都解释不了一件事——为什么一个只在本机计算报表的请求,能让毫不相关的健康检查也失去响应。
真正的转折来自两条时间线。第一条是单个 CPU 核心周期性跑满,第二条是事件循环延迟在同一时刻陡升。沿着火焰图再往下看,报表接口会解析一大块 JSON,然后用一个长循环聚合数据。这些代码没有访问数据库,也没有等待网络,却连续占用了 JavaScript 执行线程。它运行期间,其他请求不是处理得慢,而是根本没机会开始处理。
我以前也会顺口说「Node.js 是单线程、非阻塞、事件驱动的」。这句话每个词都对,连在一起却很容易制造错觉:进程里似乎只有一条线程,async/await 似乎天然不会阻塞,所有异步任务似乎都会被扔进后台线程。那次排障让我换了一种记法:先找 JavaScript 在哪里排队,再找等待工作被谁接走,最后找结果从哪里回来。
Node.js 运行模型最关键的一条线是:普通 JavaScript 默认在事件循环所在的线程上逐段执行;网络等待通常交给操作系统,文件、部分 DNS、加密和压缩任务通常交给 libuv 工作线程池;底层工作完成后,对应的 JavaScript 仍要回到事件循环线程排队执行。
一个普通 Node.js 进程启动后,会先执行入口模块、创建对象并注册回调,然后由事件循环不断调度已经就绪的 JavaScript。默认情况下,请求处理器、定时器回调、I/O 完成回调都在事件循环所在的同一条线程上执行。
如果请求 A 的回调正在运行,而请求 B 已经就绪,B 不会从 A 的函数中间抢走执行权。A 当前这段同步调用要一直运行到返回,B 才可能被调度。这就是常说的「运行至完成」。它带来一个很实用的判断:只要某段同步 JavaScript 没有交还控制权,其他已经就绪的 JavaScript 就只能排队。
这也解释了故障现场。报表聚合连续算了近两秒,这两秒里健康检查的网络数据也许早已到达,处理健康检查的回调却没有执行窗口。连接仍然存在,不代表应用层正在响应。

进程本身远不止一条线程。V8 可能使用辅助线程完成垃圾回收等工作,libuv 有工作线程池,程序还可以显式创建 Worker Thread。准确的说法应该是:
因此,单线程不等于一次只能维持一个连接。一个进程可以同时等待大量连接,只要每次回到 JavaScript 的工作足够短,事件循环就能快速轮转。单线程也不等于没有并发问题:一段同步代码不会被另一段 JavaScript 横插一刀,但一次业务操作只要跨过 await,就可能被其他请求穿插。
排障时我会把这两个词分开问:一段时间里能否推进多个任务,这是并发;同一时刻是否真的有多份工作在执行,这是并行。
Node.js 可以用一条 JavaScript 执行线程承载很高的 I/O 并发;与此同时,操作系统、libuv 工作线程和显式创建的 Worker 又可能并行工作。说它「单线程」或「背后有很多线程」都只说中了半张图。
「JavaScript 默认单线程」只保证同步代码片段不会被另一个 JavaScript 回调抢占,不保证跨越异步边界的业务操作仍然不可分割,也不保证底层资源没有竞争。
发现报表接口堵住了 JavaScript 线程后,我重新检查了服务里所有「看起来会等待」的调用。一个调用是否安全,不能看它有没有 await,而要看 await 右边到底启动了什么。
先用文件读取走一遍完整路径。把下面代码保存为 read-demo.mjs,放进含有 package.json 的目录,再执行 node read-demo.mjs:
import { readFile } from 'node:fs/promises';
console.log('1. 准备读取文件');
const filePromise = readFile('./package.json', 'utf8');
console.log('2. 读取已经提交,继续执行当前脚本');
const content = await filePromise;
console.log(`3. 文件读取完成,共 ${content.length} 个字符`);前两条日志会连续出现。readFile() 返回 Promise 时,数据通常还没准备好。执行到 await 后,暂停的是当前模块的后续流程,不是整个进程;事件循环线程已经可以去处理其他就绪工作。文件读取完成,Promise 被兑现,await 后的代码才重新获得执行机会。
把这次往返拆开,就能看见每一段工作落在哪里:
JavaScript 调用 Node.js API。参数检查、请求对象创建等准备工作仍在当前线程同步发生。
Node.js 把等待工作送往合适的底层路径。网络套接字通常由操作系统的非阻塞 I/O 机制监视;文件系统操作通常交给 libuv 工作线程池。
API 很快返回。遇到 await 时,只是当前异步函数让出执行权,其他 JavaScript 因此可以继续运行。
底层操作结束后,libuv 收到完成通知,对应任务进入事件循环的调度范围。

这是我在复盘里最想纠正的一处误解。TCP、UDP 等网络 I/O 通常依赖非阻塞套接字:libuv 请操作系统监视一批套接字,等它们可读或可写时再取得就绪信息。Linux 常见 epoll,macOS 和 BSD 常见 kqueue,Windows 则使用 IOCP。具体接口不同,libuv 在上面提供了统一抽象。
文件系统通常走另一条路。为了在不同平台上提供异步文件 API,libuv 会让工作线程执行底层文件调用,完成后通知事件循环。dns.lookup()、部分加密 API 和 zlib 压缩也使用这个线程池。
所以,fs.readFile() 的文件工作可以在线程池执行,回调却不会因此在那条工作线程里随意修改普通 JavaScript 对象。回调最终仍回到事件循环线程。判断性能瓶颈时,这个区别很重要:网络连接多,首先考验操作系统、文件描述符和应用处理能力;依赖线程池的任务多,则还可能在线程池队列里拥堵。
「事件循环永远不等待」同样不准确。当没有 JavaScript 可执行时,它可以在 I/O 轮询处高效休眠,等待受监视的一批事件中任意一个就绪。这种等待不会空转烧 CPU,也没有把线程绑定在某个慢客户身上。
连接可读、文件工作完成或定时器到期时,事件循环醒来并安排相应工作。真正危险的不是这种有组织的休眠,而是某个回调已经拿到执行权,却迟迟不归还。
await 能让流程暂停,却不能替你消除阻塞我在故障代码里看到的函数也标了 async,调用处也写了 await。这正是错误猜测能维持那么久的原因:代码长得像异步代码,不代表其中每一步都是非阻塞的。
在 Node.js 服务里,我判断阻塞时只问:这项工作完成之前,同一事件循环上的其他 JavaScript 能不能继续?
同步和异步描述的则是结果怎样交付。同步 API 直接返回结果或抛出异常,异步 API 稍后交付结果。在 Node.js 标准库里,这两组概念经常成对出现:带 Sync 后缀的 I/O API 通常同步且阻塞,回调版或 Promise 版通常异步且非阻塞。

下面的对比比术语更直观:
import { readFile, readFileSync } from 'node:fs';
// 同步、阻塞:读完之前,下一行不会执行。
const syncContent = readFileSync('./package.json', 'utf8');
console.log('同步读取长度:', syncContent.length);
// 异步、非阻塞:提交后立即继续,结果稍后进入回调。
readFile('./package.json', 'utf8', (error, asyncContent) => {
if (error) {
console.error(error);
readFileSync() 后面的日志必须等文件读完。异步部分会先打印「异步读取已经提交」,随后才打印读取长度。文件小时,耗时差异可能小得看不出来,调度关系却没有变化。
await 只暂停当前异步流程把同一件事写成 Promise,业务顺序会很自然:
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const server = createServer(async (request, response) => {
try {
const packageJson = await readFile('./package.json', 'utf8');
response.writeHead(200, { 'content-type'
等待文件时,这个请求处理器已经让出执行权,Node.js 可以处理其他连接。文件完成后,函数的后半段作为 Promise 的延续重新在 JavaScript 线程执行。await 改善的是异步流程的写法,不是底层工作的性质。
await 不会自动把右侧表达式搬到后台。如果右侧在调用 Promise 之前先做了大规模同步计算,或者内部使用 readFileSync()、复杂正则、超大 JSON.parse()、死循环,事件循环照样会被占住。
同步 API 也并非绝对禁用。在进程启动阶段读取一份很小、必需的配置,或者在一次性命令行脚本中处理文件,阻塞影响通常可控。真正该警惕的是高频请求路径:那里的一次慢同步调用会把延迟扩散给同一进程内的所有请求。
排除主线程阻塞后,我用另一组请求确认了正常的交错方式。请求 A 查询数据库,大约等待 80 毫秒,真正执行 JavaScript 只有 2 毫秒;请求 B 直接返回内存数据。即使 A 先到,B 仍然可以先完成。
事件循环调用 A 的处理器。它校验参数、发起查询,然后在等待结果时交还执行权。
A 等数据库期间没有占住 JavaScript 线程。B 就绪后,事件循环调用 B 的处理器,B 立即写回响应。
数据库返回 A 的结果,对应 Promise 后续代码获得执行机会,继续组装并发送响应。

从客户视角看,A 和 B 在并发推进;从 JavaScript 线程视角看,它只是执行 A 的前半段、再执行 B、最后回来执行 A 的后半段。高 I/O 并发的秘密不是一条线程变得会分身,而是等待时间没有占着执行窗口。
先提交的慢操作,完全可能晚于后提交的快操作完成。异步代码只保证显式写出的依赖,不会自动保留提交顺序。「先读再删」「先扣库存再记账」这类约束,必须用 await、Promise 链、队列或数据库事务明确表达。
反过来,互不依赖的 I/O 如果连续 await,会白白失去并发机会:
// 有依赖:第二步需要第一步的结果。
const user = await loadUser(userId);
const orders = await loadOrders(user.id);
// 无依赖:先一起发起,再等待全部结果。
const [profile, recommendations] = await Promise.all([
loadProfile(userId),
loadRecommendations(userId),
]);Promise.all() 不创建新线程,它只是尽早启动多个异步操作。还有一个容易被忽视的边界:如果输入来自一个十万项数组,无界的 Promise.all() 会瞬间把压力推给连接池、文件系统或下游。并发需要根据资源容量设上限。
服务器连接、可读流和套接字关闭都可以表示为事件。我们注册监听器,声明事件发生时要做什么,不需要让一条 JavaScript 线程不断询问状态。
但事件不天然异步。EventEmitter.emit() 会按注册顺序同步调用监听器:
import { EventEmitter } from 'node:events';
const bus = new EventEmitter();
bus.on('order:created', (orderId) => {
console.log(`2. 开始处理订单 ${orderId}`);
});
console.log('1. 发出事件之前');
bus.emit('order:created', 'A-1024');
console.输出严格是 1、2、3。网络数据何时到达可能是异步的,真正调用监听器时,其中的同步代码仍会占用事件循环线程。把重计算塞进监听器,并不会因为它叫「事件」就自动进入后台。
报表计算移走后,服务大部分延迟恢复了,但文件读取偶尔仍比基线慢。事件循环延迟此时很平稳,说明 JavaScript 执行窗口没有再被长时间占用。新的线索来自登录流量:一批密码派生任务与文件读取的慢点重合。
这次堵的不是事件循环,而是 libuv 工作线程池。
网络套接字主要依靠操作系统的就绪通知,不需要为每个连接占一条工作线程。等待数据可以交给操作系统;数据到达之后,解析请求、执行业务逻辑、序列化 JSON 仍会消耗事件循环线程。
libuv 的工作线程池默认有 4 条线程,并且由进程中的事件循环共享。常见使用者包括:
dns.lookup()、dns.lookupService();crypto.pbkdf2()、crypto.scrypt() 等部分加密操作;
当四条工作线程都在执行昂贵的 scrypt,稍后提交的 readFile() 就会在线程池任务队列里等待。此时健康检查等纯 JavaScript 回调可能仍然灵敏,CPU 和事件循环延迟也未必异常,但所有依赖同一线程池的操作都会出现相关性很强的排队延迟。
可以在 Node.js 进程启动前设置 UV_THREADPOOL_SIZE 调整池大小,但我不会把它当成通用修复。线程更多意味着额外内存和 CPU 竞争,磁盘、数据库连接池、下游配额也可能先到极限。更可靠的顺序是:确认究竟是哪类任务排队,限制无界并发,隔离或拆分昂贵工作,再用压测决定是否调大线程池。
非阻塞只说明事件循环没有被某次调用一直占住,不代表任务无需排队,更不代表容量无限。工作线程池、数据库连接池、文件描述符、内存和下游限流都是实际的并发边界。
为了让团队直观看到第一次故障,我做了一个心跳实验。把下面程序保存为 block-demo.mjs,再执行 node block-demo.mjs:
import { performance } from 'node:perf_hooks';
const startedAt = performance.now();
const heartbeat = setInterval(() => {
const elapsed = Math.round(performance.now() - startedAt);
console.log(`心跳:${elapsed} ms`);
}, 200);
setTimeout(() =>
前几个心跳大约每 200 毫秒出现一次。进入 while 后,输出会空白约两秒。定时器并没有失效;到期只代表回调具备了被调度的条件,而正在运行的 JavaScript 没有归还线程,它就只能等。

超大 JSON 的同步解析、灾难性回溯的正则表达式、图片处理、大数组运算,以及同步压缩或密码散列,都可能制造同一种现象。单次几十毫秒听起来不长,但出现在高频请求路径上,会明显抬高尾延迟;达到几百毫秒,用户通常已经能感到整个实例「卡住」。
async 不会把计算搬到后台下面的函数虽然声明为 async,循环依然在调用它的 JavaScript 线程上同步执行:
async function calculate() {
let total = 0;
for (let index = 0; index < 1_000_000_000; index += 1) {
total += index;
}
return total;
}
const result = await calculate();
console.log(result);async 只保证函数返回 Promise,不会创建线程,也不会自动把循环切成小段。调用 calculate() 时,循环先完整执行,调用方才拿到已经兑现的 Promise;外层的 await 无法追回已经被占用的时间。
少量计算直接在当前线程完成最简单。真正昂贵的 JavaScript 计算适合交给 Worker Thread,生产环境通常还会复用 Worker 池,避免每个请求都付出创建线程的成本。需要运行外部程序时可以使用子进程;要利用多核承载更多 HTTP 流量,可以启动多个 Node.js 进程,再由进程管理器、容器平台或负载均衡器分发连接。
Worker Thread 适合 CPU 密集的 JavaScript,不适合给普通文件或网络 I/O 多包一层。Node.js 已经为这些 I/O 提供了成熟的异步路径,多加一层线程只会增加通信和管理成本。
修复性能问题时,我顺手检查了另一个常见推论:「既然 JavaScript 单线程,就不需要考虑竞态。」这个推论的问题藏在 await 两侧。
下面的库存示例可以直接运行:
import { setTimeout as delay } from 'node:timers/promises';
let stock = 1;
async function buy(name) {
if (stock <= 0) {
console.log(`${name}:库存不足`);
return;
}
console.log(`${name}:看到库存还有 ${stock}`
A 读到库存为 1,在 await 处让出执行权;B 随即也读到 1。等待结束后,两段后续代码虽然还是先后执行,却都基于过时判断扣减库存,结果可能变成 -1。

这里从未有两段 JavaScript 同时修改内存,真正被拆开的,是「检查库存并扣减」这个业务整体。实际系统里的库存、余额和幂等性,通常要在数据归属处保证:例如用数据库事务,或者执行 UPDATE ... SET stock = stock - 1 WHERE stock > 0 并检查受影响行数。只在单个 Node.js 进程里加内存锁,挡不住多进程和多实例。
同理,请求状态应放在局部变量、闭包或明确的请求上下文里,不要用可变全局变量保存「当前用户」「当前订单」。全局缓存可以存在,但要有明确的键、过期策略和并发更新规则。单线程让同步执行更容易推理,不会替业务状态提供一致性。
那次故障最终拆成了两个问题:报表计算占住事件循环,密码派生任务又让 libuv 线程池发生排队。它们表面上都叫「请求变慢」,修复方式却完全不同。现在遇到类似情况,我会按下面的顺序画出资源等待关系:
先区分等待型工作与计算型工作。数据库、网络和文件访问主要花时间等待;解析、正则、加密、图片处理和大循环主要消耗 CPU。
再确认代码实际占用哪项资源:事件循环线程、libuv 工作线程池、数据库连接池、下游配额,还是 Worker 池。不要用「它是异步的」代替定位。
等待型工作优先使用异步 API,同时设置超时、取消和并发上限;计算型工作先测单次耗时和调用频率,必要时拆分或移到 Worker 池、子进程、独立服务。
检查每个异步边界两侧的共享状态。依赖顺序要显式表达,数据一致性要在真正持有数据的系统中保证。
如果只能留下几条编码习惯,我会选这些:请求回调保持短小;等待优先走异步接口;只有互不依赖的 I/O 才并发;重计算离开事件循环;跨 await 后重新验证状态;任何形式的并发都必须有上限。
Node.js 的运行模型并不神秘。它只是把不同工作放进不同队列:JavaScript 回调共享一条默认执行线程,网络等待大多由操作系统管理,一部分阻塞式底层工作由 libuv 线程池承接。高并发来自对等待时间的复用;延迟事故则常常来自某个执行窗口或资源池被长期占满。排障时只要把「谁在执行、谁在等待、谁在排队」三个问题问清楚,许多看似随机的卡顿就会显出结构。
运行模型让我们知道了谁负责执行、谁负责等待、谁可能被挤在队列里,但它还没有给出一张“下一段代码到底何时运行”的时间表。定时器到点、I/O 完成、Promise resolve,只代表任务具备了继续执行的条件;真正决定先后顺序的,是事件循环怎样在不同阶段和队列之间交接执行权。
因此,接下来的问题从“工作由谁完成”进一步收紧为“已经就绪的工作为什么还没执行”。这正是很多延迟抖动最隐蔽的来源。
轮到它时,回调或 Promise 后续代码回到 JavaScript 线程执行。
最后用数据验证:同时观察吞吐量、延迟分位数、单核 CPU、事件循环延迟和各资源池的排队情况,再通过压力测试确认修复没有把瓶颈推到别处。