我们把一段确定性计算拆成 120,000 个 Promise 回调。5 次实验里,下一次 requestAnimationFrame 回调和预先排好的计时器,都等到 120,000 个单元全部执行完成后才运行。
代码确实“异步”了,页面却没有在中途获得一次插入机会。异步只说明回调不在当前调用栈里立刻执行,不保证浏览器会在微任务之间获得一次渲染或处理其他 task 的机会。
Promise 回调仍在主线程中执行
需要先区分两种执行语义。new Promise(executor) 会同步调用 executor;这里聚焦 .then(...) reaction。它不会转移到另一条线程,只是离开当前调用栈,排到稍后执行。
实验中的自补充链可以缩成这段代码:
function step() {
workOneUnit();
if (++done < 120000) Promise.resolve().then(step);
}
Promise.resolve().then(step);
ECMAScript 先把 Promise reaction 包装成 Job,再交给宿主入队;在浏览器 Window 中,WHATWG HTML 把它接入微任务队列。Promise 把回调推迟到微任务队列,却没有承诺在它之前或之间给页面一次渲染机会。
因此,这段代码和同步递归的调用栈形状不同,却仍在同一条主线程上消耗 CPU。判断它是否为页面保留响应机会,需要观察事件循环何时能离开当前检查点,不能只看是否使用了 Promise。
微任务队列会在渲染前持续清空
一个 task 结束后,浏览器会执行微任务检查点。只要队列不空,就取出最早的微任务运行。上面的 step 每完成一次又补入下一次,所以队列直到固定上限跑完才真正为空。

在 Chrome 151、V8 15.1 的可见独立页面实验中,自补充 Promise 链的三项中位数分别是工作 50.7 ms、首个 rAF 回调 51.0 ms、计时器回调 50.9 ms。更重要的是顺序:5/5 次里,两种回调看到的完成数都是 120,000。
每次试验都先在一个 rAF 回调后对齐起点,再同时安排“下一次 rAF”与一个计时器标记,随后启动工作。记录直接给出工作完成相对两种回调在同一轮试验里的先后。
rAF 在渲染更新流程中回调,但发生在实际绘制之前,所以这里只能把它当作一次渲染更新机会的代理,不能说像素已经上屏。短小 Promise 链也会先于 rAF 清空:500 单元基线的中位工作耗时只有 0.8 ms。这说明风险来自同一检查点里的总工作量,而非 Promise API 本身。
任务边界以总耗时换取响应机会
我们保持相同的 120,000 个计算单元和相同累加结果,只改成每 4,000 个单元结束当前 task,再用 setTimeout 安排下一批。在同一 Chrome 151 对照里,5/5 次 rAF 都在全部工作完成前运行,中位首个 rAF 回调为 6.3 ms,中位总工作耗时为 752.8 ms。

分批没有让计算更快;本次结果用更长总耗时换来了其他工作获得机会。分批需要反复排入计时器 task,HTML 对深层嵌套计时器规定了至少 4 ms 的下限,观测值还包含本机调度与页面状态的影响。实验没有拆分这些因素各自的贡献,这个耗时差距不能外推成固定损失比例。setTimeout(0) 不保证零延迟,任务边界也不保证每批之间都有一帧;它只是本实验里最容易观察的对照手段。
两个长工作组逐轮得到相同 accumulator,说明计算内容和数量一致。预排计时器也在分批组的 5/5 次试验里早于全部工作完成;但这仍不能推出每个批次之间都会执行一次计时器或渲染。
验收一段异步计算是否实际让出调度机会,可以同时记录工作完成时间、下一次 rAF 回调、预排计时器回调,以及两种回调发生时已完成的单元数。再引入明确的 task 边界,以相同工作量重跑。若回调只能看到“全部完成”,异步语法没有带来中途响应机会。
本次三组各 5 次实验只覆盖这台机器、可见 Chrome 页面和固定参数;它不代表其他浏览器或 Node.js,也没有比较 Worker、scheduler.yield() 等生产方案。机制依据见 WHATWG HTML 的 事件循环与微任务检查点,以及 ECMAScript 2026 的 Promise reaction。
