你大概写过这样的“优雅”轮询:while (!done) await check();。它看起来是异步的,每次await都“让出了事件循环”,于是你放心地把它丢在后台。直到某天发现,排在它前面的一条setTimeout(save, 0)整整几秒没执行——数据没落盘、心跳超时、新请求接不进来。问题不在await,在于一个流传很广的共识:“await每次循环都会让出事件循环。”这句话半对半错,而错的那一半,足以在生产环境里静默地冻住你的服务。
背景:为什么这件事值得写
Promise.resolve().then()、queueMicrotask()、await的后续段,全都属于微任务(microtask)。它们被设计成“高优先级、本 tick 内紧接着跑完”,用来做低延迟的异步衔接。但“高优先级”的另一面,是微任务队列会在两个 macrotask 之间被彻底抽干——只要队列里还在生成新的微任务,事件循环就一直卡在抽微任务的阶段,根本不会走到下一个 macrotask。
而定时器、I/O 回调、UI 事件、requestAnimationFrame,全是 macrotask。于是矛盾出现了:一段“看起来异步”的微任务循环,对定时器/I/O/渲染而言,和一段同步死循环没有区别——只是它披着await的外衣,让你在 code review 时放松了警惕。
真实的起火点往往很不起眼:轮询状态直到条件满足的while,批量处理里随手写的await Promise.resolve()当作“让出”,递归的.then链。它们单个都不慢,但合起来能把排在前面或同期的定时器整段往后推。
解剖一:微任务为什么总能“插队”到 macrotask 前面
事件循环的每一圈(tick)遵循一个固定顺序:先把当前同步代码跑完 → 把整个微任务队列抽干(包括抽干过程中新生成的微任务)→ 浏览器还要趁机渲染一帧 → 才去执行一个macrotask,然后重复。
图1:一个 tick 里,微任务队列被彻底抽干后,macrotask(含定时器)才有机会执行;只要微任务持续自生成,macrotask 就被无限推迟。
关键在“抽干”二字。macrotask 是“一个 tick 一个”,微任务是“一个 tick 全部清空”。所以:
- 定时器不是“到点就跑”,而是“到点后,等当前及之后所有微任务都清空了才跑”。
await把函数后半段变成了一个微任务,它只让出给同周期内的其他微任务,并不把控制权交还给事件循环的 macrotask 阶段。- 因此
while (!done) await check()里那百万次await,每两次之间只让出了给微任务,从未让出给定时器。
解剖二:setTimeout(0) 为什么从不是 0
“setTimeout(fn, 0)等于‘下一轮就跑’”也是一个常见的误解。这里的 0 是下限不是承诺:
- HTML 规范要求:嵌套调用的定时器,嵌套层级超过 5 之后,delay 会被强制抬到至少4 ms。
- 后台标签页里,定时器会被节流到至少1000 ms(Chrome 还分多级节流)。
- 即便在前台,同一轮事件循环里排进去的 0 延迟定时器,也要等前面的微任务抽干,实际落地时间远大于 0。
所以在 Node 里,连续嵌套的setTimeout(0)实测间隔稳定在 ~15 ms——它从来不是 0。把它当成“立即执行”来用,是第二个坑。
实证:一次真实时序测量
我在 Node v22(managed node 22.22.2)下跑了三个对照实验,复现命令就是下面这段,可直接贴回去验证:
node -e ' const N=2000000; (async()=>{ const s=performance.now(); setTimeout(()=>{const t=performance.now()-s; console.log("competing timer delayed(ms):",t.toFixed(1));},0); for(let i=0;i<N;i++) await Promise.resolve(); console.log("microtask burst(ms):",(performance.now()-s).toFixed(1)); })(); '实验 A —— 微任务洪流延迟竞争定时器。先注册一个setTimeout(0),紧接着跑 2,000,000 次await Promise.resolve()。结果:循环本体只花54.3 ms,但那个排在循环前面的定时器,被推迟了54.4 ms——几乎等于整个微任务洪流的时长。定时器不是“没排上”,而是必须等微任务队列彻底清空才轮得到它。
图2:左图为微任务洪流——竞争定时器被整段推迟;右图为每步主动让出 macrotask——竞争定时器在第一个 macrotask 轮次即触发,但循环本身代价陡增。
实验 B —— 主动让出能救活定时器,但很贵。把同一段循环改成每步await new Promise(r=>setTimeout(r,0))(即每步主动让出到 macrotask),竞争定时器在15.4 ms就触发了;代价是 300 次迭代花掉4594 ms,每步约15.3 ms。对比实验 A 的每步约0.027 µs,让出一次比纯微任务慢了约55 万倍。
实验 C —— 嵌套 setTimeout(0) 的实际间隔。连续嵌套 8 次setTimeout(tick, 0),测量相邻回调间隔,得到[15.3, 15.4, 16.0, 15.5, 15.5, 15.5, 15.5, 15.5]ms。每个都是 ~15 ms,从不为 0——印证了“0 只是下限”。
图3:横轴为嵌套深度,纵轴为相邻回调间隔(ms);实测稳定落在 ~15 ms,且浏览器在嵌套 5 层后还有 4 ms 下限,进一步说明“0 延迟”是误称。
局限:哪些情况不会饿死、我测不到什么
- Node 与浏览器在“无限链”上表现不同。我额外测了一个无终止的
Promise.resolve().then(loop)链:在 Node 的 1 秒窗口里,竞争定时器最终还是触发了——因为 Node 每个事件循环周期都会重新进入定时器阶段,微任务链只是拖慢了节奏,没有永久饿死定时器。真正会“永久冻住”的是浏览器:非终止的微任务链会阻塞事件循环走向下一个 macrotask,定时器与渲染都拿不到执行权。所以本文用“有界洪流”来证明机制(仍把竞争定时器整段推迟),而不是在 Node 里声称“无限饿死”。 - 我没有在这里直接测浏览器掉帧。本机无 DOM,所以“UI 冻结”的结论来自规范与公开资料,而非本次基准;掉帧时长需你在浏览器里用
requestAnimationFrame与 Performance 面板补充验证。 - 54 ms 是 2M 次紧密循环的数字。真实业务的时延分布不同,重要的是机制而非绝对值;轮询间隔、批大小都会改变具体数值。
- 让出的 ~15 ms/步是 Node 调度开销,浏览器与跨平台同量级,但具体数字会随运行时波动。
结论与下一步
一句话方法论:await只在当前 macrotask 周期内让出,不把控制权交还给定时器/I/O/渲染;长循环要么用 macrotask 主动让出,要么就接受它在运行期间会饿死竞争任务。落到可执行的三条规则:
- 轮询/批量循环要主动让出:每 K 步
await一次 macrotask(setTimeout(0)、MessageChannel、scheduler.yield()),而不是无限await Promise.resolve()。 - 不要用无限微任务链:任何基于
.then/queueMicrotask的自递归,必须带明确的停止条件或改成 macrotask 节奏。 setTimeout(0)当“让出”用,别当“立即”用;并用perf_hooks的 EventLoopDelayMonitor 或浏览器 Performance 面板监控事件循环 lag,lag 突增而无重同步函数,就先怀疑微任务饥饿。
开源地址:
- 矩阵门户:GitHub - wangzifan396-wzf/WB: nano-tools: 1200+ single-file, zero-dependency, local-first web utilities in one portal. Offline and private, nothing leaves your browser. Binary & protocol parsers, crypto, dev, audio, visualization, productivity. · GitHub
- 单文件工具聚合器:GitHub - wangzifan396-wzf/nano-workbench: Single-file tabbed launcher for the nano-tools matrix - one tab, 382 curated tools (of 1228), instant switching. Zero-dep. Part of nano-tools. · GitHub
- GitHub 组织主页:wangzifan396-wzf (WangZi) · GitHub