news 2026/8/27 11:10:49

2 百万次 await 不慢、却把排在它前面的 setTimeout 卡了 54ms:await 会“让出事件循环”这条共识的实测复盘

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
2 百万次 await 不慢、却把排在它前面的 setTimeout 卡了 54ms:await 会“让出事件循环”这条共识的实测复盘

你大概写过这样的“优雅”轮询: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 主动让出,要么就接受它在运行期间会饿死竞争任务。落到可执行的三条规则:

  1. 轮询/批量循环要主动让出:每 K 步await一次 macrotask(setTimeout(0)MessageChannelscheduler.yield()),而不是无限await Promise.resolve()
  2. 不要用无限微任务链:任何基于.then/queueMicrotask的自递归,必须带明确的停止条件或改成 macrotask 节奏。
  3. 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
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/27 11:08:56

Blue Gecko蓝牙模块实战:从选型到量产避开射频与功耗坑

做智能硬件这几年&#xff0c;我越来越觉得大多数项目真正的拦路虎根本不是软件&#xff0c;而是射频。蓝牙BLE尤其典型&#xff1a;芯片能买到&#xff0c;例子代码能跑通&#xff0c;可一到天线匹配、PCB走线、过认证的阶段&#xff0c;项目就卡住了。后来我开始在项目里用Si…

作者头像 李华
网站建设 2026/8/27 11:06:03

数学建模竞赛解题规范与内容安全准则

我无法生成关于“2023年数维杯国际大学生数学建模挑战赛A题”的博文内容。 原因如下&#xff1a; 输入信息严重缺失 &#xff1a;项目正文为空&#xff0c;关键词为空&#xff0c;摘要描述为空。整篇输入仅有一个赛事名称和题号&#xff08;“A题”&#xff09;&#xff0c;…

作者头像 李华
网站建设 2026/8/27 11:05:34

5G-Ready物联网模组的安全架构与实战选型指南

1. 项目概述&#xff1a;5G-Ready IoT模组到底解决了什么问题拿到"5G-Ready IoT Module Provides Advanced Security"这个标题时&#xff0c;我第一反应是&#xff1a;这其实不是一个产品描述&#xff0c;而是一份需求清单。它同时把通信代际、硬件形态、安全能力三个…

作者头像 李华
网站建设 2026/8/27 11:03:57

跨境ETF统计套利策略:基于均值回归与股指期货对冲的量化模型

1. 项目概述与核心问题拆解 跨境ETF套利&#xff0c;听起来像是华尔街精英们在玻璃幕墙后操弄的复杂游戏&#xff0c;但实际上&#xff0c;它的核心逻辑非常朴素&#xff1a;利用同一资产在不同市场的价格差异来赚钱。2023年大湾区杯数学建模A题将这个问题抛给了参赛者&#xf…

作者头像 李华
网站建设 2026/8/27 11:03:08

TOPSIS决策分析法:从原理到Python实战,告别拍脑袋做选择

1. 项目概述&#xff1a;从“拍脑袋”到“算分数”&#xff0c;TOPSIS如何让决策更靠谱&#xff1f; 做项目、选方案、评绩效&#xff0c;甚至买东西&#xff0c;我们每天都在做决策。但很多时候&#xff0c;所谓的“决策”不过是“拍脑袋”或者“凭感觉”&#xff0c;尤其是当…

作者头像 李华
网站建设 2026/8/27 11:00:18

C语言数据结构详解

C语言数据结构是计算机科学的基石。理解数据在内存中如何组织&#xff0c;以及如何高效地操作它们&#xff0c;是写出高性能程序的关键。 下面我将由浅入深&#xff0c;为你系统性地讲解C语言中的核心数据结构&#xff0c;并配上代码示例。第一部分&#xff1a;预备知识 在开始…

作者头像 李华