事件循环原理:谁在执行,谁在等待
从 V8、libuv 与操作系统的分工理解异步,区分阶段、微任务与计时阈值,通过可重放实验建立顺序保证的边界。
建议先读:从终端认识 Node:第一个程序与进程生命周期模块系统:ESM、CommonJS 与依赖图
本页内容
目标与前置#
这一章不要求你背诵一张事件循环阶段图。目标是当团队助手同时读取文档、响应请求和计算摘要时,能解释哪些工作可以重叠等待,哪些代码仍会占住 JavaScript 线程,以及为什么一个零毫秒定时器也可能很晚才执行。前置是第一章的进程生命周期与第二章的模块模式;理解 Promise 语法即可,不需要先掌握线程实现。
前端经验很有帮助:页面中一个长循环会卡住交互,Node 主线程中的长循环也会延迟其他请求。但服务器上受影响的通常不是一个组件,而是同一进程里的多位用户。把函数声明为 async 并不会改变循环实际在哪个线程上运行。我们会通过实验看到这一点,再讨论可以选择的调度方法。
第一层:JavaScript 执行栈没有因为异步而复制#
当一个回调正在执行普通 JavaScript,另一个回调不会在同一个执行栈中途强行插进来。当前同步代码需要先结束,运行时才有机会处理后续任务。这是很多局部推理成立的基础:一个没有 await 的短函数执行到一半时,其他普通回调不会突然修改它的局部变量。可是函数里一旦主动等待,后续执行就可能与其他请求交错。
异步通常意味着“发起工作之后先返回,把完成通知安排到以后”。真正的等待可以发生在操作系统、线程池或其他组件中;等结果可用后,JavaScript 回调仍需要被调度执行。把等待和处理结果分开,你就能理解为什么网络请求可以大量并发,却仍可能被一个巨大的 JSON 解析拖慢。
V8 负责执行 JavaScript 与管理相关内存。Node 把文件、网络等能力连接到平台实现。libuv 提供事件循环和跨平台异步机制,其中一部分操作使用线程池,网络 I/O 通常依赖操作系统的事件通知设施。它们的具体协作有平台差异,因此“所有异步都在线程池里跑”与“Node 只有一条线程”都过于粗糙。
第二层:等待资源与执行结果是两种成本#
读取磁盘文件的等待可以交给异步机制,但读取结束后你在回调里统计一千万个单词,统计本身仍占用执行线程。类似地,远程模型等待了两秒不一定消耗两秒 CPU;收到大响应后同步解析和加工却可能产生明显 CPU 峰值。性能分析应把排队、外部等待和本地计算分开,不要只看总耗时。
线程池也有容量。文件操作、部分加密和名称解析工作可能共享有限资源,某类重任务占满池后,其他任务会排队。增加线程池大小不是无条件加速:它可能增加内存、上下文切换或对磁盘的竞争。先证明瓶颈确实来自该队列,再考虑参数调整,通常比看见异步就盲目提高并发更有效。
操作系统通知数据可读,也不代表 JavaScript 会立刻处理。若主线程被长任务占住,结果只能等待。这里产生的延迟对所有连接都可能可见。团队助手处理上传时应持续接收小块数据,而不在一个回调中进行巨量同步转换;CPU 密集部分可以分片或交给工作线程,具体选择在后续章节展开。
第三层:阶段是任务类别,不是每轮固定发生的清单#
事件循环中常见的概念包括计时器、部分待处理回调、轮询 I/O、检查阶段和关闭回调。轮询阶段既负责处理可用 I/O,也影响可以等待多久;setImmediate 安排的回调在检查阶段执行;资源关闭还可能产生关闭通知。不是每一轮都会有每一种任务,也不是每个阶段只允许执行一个回调。
官方文档记录,从 libuv 1.45、对应 Node 20 起,每轮的计时器处理调整到轮询之后;为兼容,在进入循环前仍可能有一次计时器处理。这个变化说明老文章画出的顺序图可能依赖版本。学习时应该优先掌握可依赖的局部关系,再把具体版本的阶段实现用于解释实验,不要把某张图当成语言标准。
计时器的延迟值表示达到某个阈值后可以被调度,不是精确的预约时刻。等待二十毫秒期间若主线程正在处理一百毫秒的同步计算,回调只能更晚执行。系统调度、同时就绪的工作以及循环状态也会影响时间。业务超时应理解为“何时开始执行取消或判定逻辑”,不是保证外部工作在那个时刻绝对停止。
微任务、nextTick 与当前执行上下文#
Promise 的反应回调和 queueMicrotask 进入微任务队列,它们会在适当的执行边界被处理。process.nextTick 有 Node 特有的调度机制,不属于普通阶段图中的下一阶段。大量递归安排微任务或 nextTick 可能长期占住处理机会,让定时器和 I/O 无法及时推进。把工作拆成 Promise 链,不等于已经把机会让给网络事件。
ESM 顶层求值本身涉及异步模块执行,因此在 ESM 文件顶层观察到的 nextTick 与 Promise 微任务相对顺序,可能不同于 CommonJS 顶层。这个细节很容易让背诵的面试结论失效。本章的确定性实验把相关安排放进明确的 I/O 回调里,并明确说明上下文;顶层顺序只作观察,不写成跨场景保证。
新代码需要普通微任务时,通常可以优先使用跨环境的 queueMicrotask;只有确实需要 Node 的 nextTick 契约时再使用它。它们都接收一个回调,没有延迟参数。回调里的同步异常会沿各自机制报告,不能把 queueMicrotask 当成一个会返回可 await Promise 的函数。错误传播会在下一章进一步拆开。
实验一:在明确的 I/O 边界观察调度顺序#
环境 Node 22.22.0,无依赖。保存为 loop-order.mjs,它读取自身文件作为一个本地 I/O 操作,并使用断言验证本实验中明确的先后关系。
import { readFile } from 'node:fs';
import assert from 'node:assert/strict';
const order = [];
readFile(new URL(import.meta.url), (error) => {
if (error) throw error;
order.push('io');
process.nextTick(() => order.push('nextTick'));
queueMicrotask(() => order.push('microtask'));
Promise.resolve().then(() => order.push('promise'));
setImmediate(() => order.push('immediate'));
setTimeout(() => {
order.push('timer');
assert.deepEqual(order, ['io', 'nextTick', 'microtask', 'promise', 'immediate', 'timer']);
console.log(order.join(' -> '));
}, 0);
});
执行 node loop-order.mjs,预期输出 io -> nextTick -> microtask -> promise -> immediate -> timer。这里的关键条件是这些安排都发生在同一个 I/O 回调内部:当前回调先结束,随后处理相应的优先队列;微任务按本例的入队顺序执行;从 I/O 回调安排的 immediate 会先于这里的计时器运行。
不要把文件读取回调删掉,再要求顶层实验永远得到同一串结果。顶层的 immediate 与零延迟计时器顺序不应该被当成业务契约;模块模式也会影响优先队列观察。可靠程序需要顺序时,应直接使用回调依赖或 await 表达“完成以后再做”,而不是挑一个看上去总是排在后面的调度 API。
逐段理解断言:它验证的是这个具体安排产生的局部顺序,不验证文件读取耗时,不验证操作系统中没有其他活动,也不说明用户代码能控制事件循环每一步。好的实验要把结论限制在证据支持的范围。这样以后面对某台机器上的不同日志,也知道应该先检查上下文和版本,而不是直接断言 Node 出错。
实验二:async 函数也能阻塞计时器#
保存为 loop-blocking.mjs。程序先启动一个短计时器,再在 async 函数中执行约四十毫秒的同步工作。这个时长仅用于展示关系,不应作为性能基准。
import assert from 'node:assert/strict';
import { performance } from 'node:perf_hooks';
const order = [];
const started = performance.now();
const timer = new Promise((resolve) => {
setTimeout(() => { order.push('timer'); resolve(); }, 0);
});
async function scanDocument() {
order.push('scan-start');
const until = performance.now() + 40;
while (performance.now() < until) { /* 模拟同步计算占用 */ }
order.push('scan-end');
}
await scanDocument();
order.push('after-await');
await timer;
assert.deepEqual(order, ['scan-start', 'scan-end', 'after-await', 'timer']);
console.log(order.join(' -> '));
console.log(`计时器实际等待约 ${Math.round(performance.now() - started)}ms`);
执行 node loop-blocking.mjs,预期顺序断言通过,计时器等待通常至少接近四十毫秒,实际数值随机器负载变化。async 函数在到达自己的等待点之前仍同步执行;本例函数内部没有等待点,因此调用它时就完成了长循环。外部 await 等待的是已经完成计算后得到的 Promise,不会倒过来把已经执行的计算移走。
performance.now() 返回单调递增的高精度时间读数,适合计算区间,不是面向用户的日期时间。示例用差值观察延迟,不把读数当作绝对时间戳。忙等循环只是可控实验,真实业务不应该为了等待时间而忙等,因为这会白白占用处理请求的机会。
什么才叫让出执行机会#
把循环分成小批次,每批结束后等待一个 immediate,可以让事件循环有机会处理其他类别的工作。这会改善响应性,但不减少总计算量,甚至因为调度开销略增总耗时。它适合可以切分的中等规模工作;计算特别重、需要并行利用多核时,可以考虑工作线程;需要独立权限或可单独重启时,可能更适合子进程。
分片大小应来自测量。批次太大,其他请求仍然等待很久;批次太小,调度与状态保存的开销占比升高。也不要把每个数组元素都包成一个 Promise,希望自动得到并行。那通常只是创建了大量对象,并把更多工作堆到同一线程。数据规模、单项计算量、响应时间目标一起决定合适粒度。
业务顺序也需要明确。如果每处理一批就让出,其他请求可能在两批之间修改共享状态。原本依赖“同步函数中间不会被插入”的推理不再适用。可以先取得不可变快照,或在每批使用版本检查;涉及数据库状态时使用事务或乐观并发策略。响应性优化不能以偷偷放弃一致性为代价。
练习:分批统计任务编号,并证明其他事件有机会执行#
实现处理一万个整数的求和,每批一千个,批次之间等待 immediate。最终结果应是四千九百九十九万五千,并且预先安排的 immediate 要在全部处理完成前执行。提示:使用 node:timers/promises 的 setImmediate,它返回 Promise,不需要手写包装。
参考答案:完整分片实验
import assert from 'node:assert/strict';
import { setImmediate as yieldTurn } from 'node:timers/promises';
let observerRan = false;
setImmediate(() => { observerRan = true; });
let sum = 0;
for (let start = 0; start < 10000; start += 1000) {
for (let index = start; index < start + 1000; index += 1) sum += index;
// 让事件循环推进,不只是继续堆叠微任务。
await yieldTurn();
}
assert.equal(sum, 49995000);
assert.equal(observerRan, true);
console.log(`sum=${sum}, observer=${observerRan}`);
执行 node loop-yield.mjs,预期 sum=49995000, observer=true。Promise 版 immediate 的第一个可选参数是兑现值,另有包含 signal 与 ref 的选项;本例使用默认值即可。它在这里承担调度职责,不代表处理过程自动获得线程隔离,也不保证每批都与某个网络请求严格交替。
把“先后”写成接口,而不是猜测运行时#
团队助手需要先保存任务,再发送更新通知。若保存函数返回 Promise,调用方可以明确等待它成功,然后通知;若保存失败,就不会进入成功通知。这条依赖关系与事件循环当前处于哪个阶段无关。相反,先发起保存再安排一个短计时器发送通知,只是猜测保存大概已经完成,在磁盘变慢时就会暴露错误。
多个操作可以并发发起,并不意味着可以忽略彼此关系。读取两个互不依赖的文档可以同时等待;第二次写入若依赖第一次产生的编号,就应明确串联。不要为了“异步性能”把所有 await 删除,也不要因为担心并发就把所有读取逐个等待。先把依赖图画清楚,再决定哪些边可以同时执行,这个思路与前端页面请求编排相通。
取消同样需要明确契约。一个超时 Promise 先完成,只代表调用方停止等待这个竞争结果,原来的文件读取或网络请求可能还在进行。若底层支持 AbortSignal,就把取消信号传进去;若不支持,就需要在结果回来时检查任务是否仍有效,并处理占用资源。事件循环能够安排超时回调,但不会替业务撤销已经发生的副作用。
还有一种容易混淆的现象:某个回调执行很快,却经常很晚才开始。它的自身耗时低,不代表用户延迟低,因为队列等待也属于响应时间。诊断应分别记录进入请求、开始处理、外部操作完成和响应结束的时刻。只有这些边界清楚,才知道应该优化算法、减少排队还是调整外部服务超时。
公平性也不是自动平均分配。一个回调可以一次处理很多数据,另一个回调只做少量工作;若前者持续补充高优先级队列,后者可能被拖延。为任务设定批次上限、并发上限和队列长度上限,是让系统在负载增加时仍可解释的方式。所谓高吞吐,并不应来自无限积压尚未处理的请求。
最后,不要用更换微任务 API 来修复数据竞争。nextTick、Promise 和 immediate 改变的是何时有机会继续执行,不会把多个读改写步骤变成事务。只要跨越等待边界,其他逻辑就可能观察并修改状态。运行时顺序知识能帮助定位交错,但正确性仍需要适当的数据模型和并发控制。
调试观察与真实项目边界#
观察调度时少写无关日志,因为控制台输出本身也有成本。优先记录事件名称到数组,在需要的边界统一输出。涉及性能时进行预热、重复测量,并记录运行版本与输入规模;某一次快慢可能来自文件缓存、垃圾回收或系统负载。顺序实验与性能基准需要不同的验收方式,不要把两者混用。
团队助手的首页请求不应该和大文档统计共享一个无法中断的长回调。可以先用本章模型画出每一步是在等待还是计算,再决定:限制输入大小、流式处理、分批让出、工作线程或后台任务。只有当瓶颈明确时,框架切换或线程参数调整才有讨论依据。
验收要求:解释实验一为何限定在 I/O 回调内部;运行阻塞实验并说明 async 没有转移 CPU 工作;运行分片实验并验证观察回调执行。自测一:pending Promise 一定表示有线程在后台工作吗?答案:不一定,它只是未来结果的状态。自测二:微任务链是否一定能让 I/O 及时推进?答案:不能,持续补充微任务也可能造成饥饿。自测三:零延迟计时器能否作为两个业务步骤的可靠依赖关系?答案:不能,应显式等待前一步完成。
一次实验观察到的精确顺序,应与实验的模块模式、调度位置和版本一起记录。把这些前提删掉之后,剩下的输出序列就不能继续承担同样强度的结论。学习原理的价值,是知道何时可以推理,何时必须通过明确接口建立保证。
本章实际验证范围#
三个程序在 Node 22.22.0 实际通过。I/O 内顺序与断言一致,阻塞实验观察约四十一毫秒等待,分片求和得到 49995000。具体时间只是本次观察,未作为通用性能阈值。
官方参考#
阶段与版本变化见 Node 事件循环教程;计时 API 见 Timers;线程池与阻塞边界见 不要阻塞事件循环或工作池;时间观测见 Performance hooks。