setTimeout(fn, 0) 到底等多久:我把定时器的账算了一遍
- 作者:Bougie
- 创建于:2026-10-07
前阵子写一个轮询,代码大概长这样:
async function drain(queue) {
while (queue.length) {
await handle(queue.shift())
await new Promise((r) => setTimeout(r, 0)) // 让出一下,别卡住 UI
}
}
我觉得这一行是免费的——setTimeout(fn, 0),不就是"让出一个 tick"嘛,能有多贵。
后来我给这段加了个计时,发现处理 300 个任务要 1.2 秒,而任务本身加起来才 20 毫秒。多出来的 1.18 秒,全在那一行"免费"的让出上。

下面所有数字都是我在本机 Linux x86_64、Node v24.16.0 和 Chrome 149 上跑出来的。浏览器部分用的是 headless Chrome,脚本不依赖任何网络请求,你可以照着复现。
# 一、Node 的答案:1 毫秒,而且是强制的
先测 3000 次:
for (let i = 0; i < 3000; i++) {
const t0 = performance.now()
await new Promise((r) => setTimeout(r, 0))
d.push(performance.now() - t0)
}
setTimeout(fn, 0) × 3000:min 0.001 / p50 1.053 / p95 1.058 / max 1.39 ms
中位数 1.053 毫秒。不是 0,也不是 0.1,就是 1 毫秒多一点。
这不是巧合。Node 的定时器实现里有一条硬规则:delay 小于 1 或者在 [1, 2^31-1] 之外,会被强制设成 1。所以我又扫了一遍:
setTimeout(fn, 0) 中位 1.055 ms
setTimeout(fn, 0.4) 中位 1.054 ms
setTimeout(fn, 1) 中位 1.053 ms
setTimeout(fn, 1.4) 中位 1.053 ms
0、0.4、1、1.4,四种写法跑出来一模一样。setTimeout(fn, 0) 在 Node 里就是 setTimeout(fn, 1),写 0 只是自我安慰。
顺带说一个小离群值:500 次 setTimeout(fn, 1) 里,有 2 次实际只等了不到 1 毫秒(最快的 0.001 ms)。原因是 libuv 在每次事件循环迭代开始时缓存一次当前时间,后面都用这个缓存值判断定时器是否到期。如果你在循环中途注册定时器,缓存的时间已经"落后"于真实时间了,于是定时器会提前一点点触发。这是实现细节,不是 bug,但如果你在写一个对时序极度敏感的东西,值得知道。
同一 tick 里注册 5 个 setTimeout(fn, 0),它们几乎同时触发:
[1.19, 1.21, 1.22, 1.22, 1.22] ms
也就是说它们共享同一个 1 毫秒的地板,不会排成 1、2、3、4、5 毫秒。
# 二、Chrome 的答案:0 毫秒,和 4 毫秒
浏览器不一样。同样的循环,在 Chrome 149 里:
setTimeout(fn, 0) × 2000:min 0 / p50 4.1 / p95 4.2 / max 4.2 ms
中位数 4.1 毫秒,是 Node 的 4 倍。但最小值是 0——说明开头几次是快的,后来才变慢。
这就是 HTML 规范里那条著名的规则:如果定时器的嵌套层级(nesting level)大于 5,且 delay 小于 4,就把 delay 抬到 4 毫秒。
我上面那段 for 循环,每一次 setTimeout 都是在上一次 setTimeout 的回调里注册的,嵌套层级单调递增,所以从第 6 次开始就全是 4 毫秒了。
为了把这条规则看得更清楚,我改了实验:每次采样前先用 MessageChannel 发一个消息,把嵌套层级归零(因为 MessageChannel 的消息任务不是定时器任务,不会累加层级):
const kick = () =>
new Promise((r) => {
const c = new MessageChannel()
c.port1.onmessage = () => r()
c.port2.postMessage(0)
})
然后分别嵌套 1 到 8 层,测最里面那次的延迟:
层级 1 -> p50 0 ms
层级 2 -> p50 0 ms
层级 3 -> p50 0 ms
层级 4 -> p50 0 ms
层级 5 -> p50 0 ms
层级 6 -> p50 4.1 ms ← 这里跳变了
层级 7 -> p50 4.1 ms
层级 8 -> p50 4.1 ms

第 5 层和第 6 层之间是一道悬崖:0 毫秒直接跳到 4.1 毫秒,min 4.0 / max 4.2,非常稳定。
而"非嵌套"的 setTimeout(fn, 0)(每次都先归零层级)跑 300 次,中位数就是 0 毫秒,最大 0.1 毫秒。所以 Chrome 里 setTimeout(fn, 0) 真的可以几乎不等待——前提是你不在定时器回调里注册它。
而 await new Promise(r => setTimeout(r, 0)) 这种写法,在循环里天然就是嵌套的。这是它变贵的原因。
顺带一提,Node 里完全没有这条规则。我把嵌套从 1 层加到 10 层,中位延迟全部是 1.053 毫秒,一点变化都没有——4 毫秒钳制是浏览器的东西,Node 不背这个锅。
# 三、让出主线程,有四种价钱
既然知道了嵌套会触发 4 毫秒,那"让出一下"这件事到底该用什么?我把常见的四种方式各跑了 300 次,算单次平均耗时:
Chrome 149
| 写法 | 单次耗时 | 说明 |
|---|---|---|
await Promise.resolve() | 0.0003 ms | 只让出微任务,没有真正让出主线程 |
scheduler.yield() | 0.002 ms | 让出给优先级更高的任务,但自己排在前面 |
MessageChannel | 0.006 ms | 真正的宏任务让出 |
setTimeout(fn, 0)(嵌套) | 3.986 ms | 被 4 毫秒钳制卡住 |
requestAnimationFrame | 16.26 ms | 必须等下一帧 |
Node v24.16.0
| 写法 | 单次耗时 | 说明 |
|---|---|---|
await Promise.resolve() | 0.0001 ms | 只让出微任务 |
process.nextTick | 0.0014 ms | nextTick 队列,不让出宏任务 |
setImmediate | 0.002 ms | check 阶段,真正的让出 |
MessageChannel | 0.055 ms | 可用但没必要 |
setTimeout(fn, 0) | 1.06 ms | 有 1 毫秒地板 |

浏览器里 setTimeout(fn, 0) 比 MessageChannel 贵 665 倍。Node 里 setTimeout(fn, 0) 比 setImmediate 贵 530 倍。
这不是理论差异。我把一个长任务切成 200 片,每片 10 毫秒,片间让出:
用 MessageChannel 让出:总耗时 2000.2 ms,期间渲染 120 帧
用 setTimeout(0) 让出: 总耗时 2778.9 ms,期间渲染 167 帧
同样的活儿,用 setTimeout(0) 多花了 778 毫秒——正好是 200 次 × 4 毫秒。而且渲染帧数只多了 47 帧,性价比极低。
所以那行"免费的让出"应该改成:
// 浏览器
const yieldToMain = () =>
new Promise((r) => {
const c = new MessageChannel()
c.port1.onmessage = () => r()
c.port2.postMessage(0)
})
// 或者直接用 scheduler.yield()(Chrome 129+ / Safari 18.2+)
await scheduler.yield()
// Node
await new Promise((r) => setImmediate(r))
# 四、微任务能把宏任务活活饿死
微任务队列有个特性:只要还有微任务,事件循环就不会往下走。这句话听起来抽象,我做了个极端实验——先注册一个文件读取,然后开始无限(上限 300 万次)递归 process.nextTick:
fs.readFile(__filename, () => {
ioFired = true
})
// 然后开始 300 万次 nextTick 递归
连续 nextTick 3000000 次后停止;期间 fs 回调是否执行过: false
300 万次 nextTick 期间,文件读完了,回调一次都没执行。 数据在内核里躺着,I/O 阶段就在那儿,事件循环就是不过去。
把 process.nextTick 换成 queueMicrotask,把文件读取换成 setTimeout(fn, 0):
连续 queueMicrotask 3000000 次后停止;期间 setTimeout(0) 是否执行过: false
一样的结论。浏览器里同样成立(Chrome 里 300 万次 queueMicrotask 期间,setTimeout(0) 没执行过)。

日常代码里你不会写 300 万次递归,但这个机制的低配版本每天都在发生:一个递归的 Promise 链、一个自己调自己的 async 函数、一个每次 await 后又产生新微任务的状态机——只要微任务的产生速度不低于消耗速度,渲染、I/O、定时器就全部停摆。页面"卡住但没报错",往往就是这么来的。
判断方法很简单:如果页面卡住但 CPU 跑满,多半是微任务饿死或者同步阻塞;如果页面卡住但 CPU 空闲,那是真的在等 I/O。
# 五、执行顺序那张表,还有个坑
在一个真正的宏任务(比如一个 timer 回调)里,把这些全注册一遍:
setTimeout(() => order.push('setTimeout(0)'), 0)
setImmediate(() => order.push('setImmediate'))
Promise.resolve().then(() => order.push('promise.then'))
queueMicrotask(() => order.push('queueMicrotask'))
process.nextTick(() => order.push('process.nextTick'))
fs.readFile(__filename, () => order.push('fs.readFile'))
Node 跑出来的顺序是:
["process.nextTick", "promise.then", "queueMicrotask", "setImmediate", "fs.readFile", "setTimeout(0)"]
这是教科书版本:nextTick 队列 → 微任务队列 → check 阶段(setImmediate)→ poll 阶段(I/O)→ timers 阶段(setTimeout)。
但同样的注册顺序,放到顶层 ESM 代码里(await 之后的同步代码),顺序变了:
["promise.then", "queueMicrotask", "process.nextTick", "setImmediate", "setTimeout(0)"]
process.nextTick 掉到了微任务后面。
原因是:ESM 的顶层 await 续体本身就是 V8 的一个微任务。当你的代码正跑在微任务里时,V8 会先把微任务队列排空,然后才轮到 Node 去清 nextTick 队列。所以"nextTick 永远比 Promise.then 早"这句话,只在真正的宏任务回调里成立。
如果你写过"用 nextTick 保证在 Promise 之前执行"的代码,而调用方恰好把它放在顶层 await 后面,这条假设就 silently 失效了。
还有一条我测了很多轮的:setImmediate 永远比 setTimeout(fn, 0) 先执行。
主模块里谁先(300 轮): { setImmediate: 300 }
fs.readFile 回调里谁先(100 轮):{ setImmediate: 100 }
不是"通常",是 400 轮里 400 轮都是 setImmediate 先。因为 setImmediate 在 check 阶段,紧接在 poll 之后;而 timers 阶段要绕一圈才回来。
# 六、setInterval 会漂,而且不会补
跑 40 次 50 毫秒的 setInterval:
setInterval 50ms × 40:期望 2000 ms,实际 2011 ms
末次偏差 11.01 ms,偏差中位数 5.82 ms,最大 11.01 ms
只跑了 2 秒就漂了 11 毫秒。 而且这个偏差是单向累积的——每次回调的执行时间、事件循环的调度抖动,全都叠加进去,不会自动修正。
如果每轮回调还要做 20 毫秒的活儿,20 轮(期望 1000 ms)后偏差是 23.72 ms。
改成递归 setTimeout 并且每次自校正:
const step = () => {
const err = now() - start - ++m * INTERVAL
if (m >= N) return
setTimeout(step, Math.max(0, INTERVAL - err))
}
递归 setTimeout(自校正):末次偏差 -0.34 ms,偏差中位数 -0.24 ms,最大 0.34 ms
偏差从 11 毫秒降到 0.34 毫秒。代价是代码丑一点,好处是它不会越跑越偏。做动画、做心跳、做任何"每 N 毫秒一次"的东西,值得这么写。

还有一个反直觉的行为:如果回调比间隔还长,setInterval 不会堆积补偿,而是直接把间隔拉长到回调的耗时。
setInterval(50ms),每次回调阻塞 120ms:
Chrome -> [50.2, 120.2, 120, 120, 120, 120, 120, 120, 120, 120]
Node -> [50.4, 120.1, 120, 120, 120, 120, 120, 120, 120, 120]
第一次还是 50 毫秒,之后就变成 120 毫秒——也就是"回调一结束,立刻再来一次"。中间那 70 毫秒的"欠账"不会被补上,也不会攒着连续触发 3 次。浏览器和 Node 在这点上行为完全一致。
# 七、2147483647 毫秒的悬崖
setTimeout 的 delay 是 32 位有符号整数,上限 2^31 - 1 = 2147483647 毫秒,换算过来是 24.8 天。
setTimeout(fn, 2147483646) -> 正常排队,1.5s 内没触发
setTimeout(fn, 2147483647) -> 正常排队,1.5s 内没触发
setTimeout(fn, 2147483648) -> 回调在 0.84 ms 触发 ← 溢出了
setTimeout(fn, 4294967296) -> 回调在 1.13 ms 触发 ← 溢出了
多 1 毫秒,从"24.8 天"变成"立刻执行"。 这不是慢,是溢出后被当成了 1 毫秒。

什么场景会撞上?任何用乘法算出来的过期时间:
setTimeout(expire, ttlDays * 24 * 60 * 60 * 1000)
只要 ttlDays 大于等于 25 天,或者它本身是个大得离谱的数(比如后端返回了个以毫秒为单位的"永久"),你的"永不过期"就变成了"立刻过期"。
浏览器这边对非法 delay 的处理反而是宽容的,我在 Chrome 里试了一遍各种参数:
setTimeout(fn, 0) -> 0 ms
setTimeout(fn, -5) -> 0.1 ms (负数当 0)
setTimeout(fn, 1.9) -> 1 ms (取整)
setTimeout(fn, "10") -> 10 ms (字符串被转成数字)
setTimeout(fn, undefined) -> 0 ms
setTimeout(fn, NaN) -> 0 ms
NaN 和 undefined 都当 0 处理,不会报错。所以一个算错了的 delay 不会抛异常,只会安静地变成"立刻执行"。
# 八、定时器从来不保证"按时"
最后一条,也是最容易被忘的一条:定时器保证的是"不早于",不是"恰好"。
我在注册完 setTimeout(fn, 0) 之后立刻同步阻塞 300 毫秒:
Node: 回调实际延迟 300.02 ms
Chrome: 回调实际延迟 300.00 ms
定时器早就到期了,但主线程被占着,它只能等着。所以 setTimeout 的延迟是下限,不是承诺。
再补一个测量本身的坑:我在 Chrome 里连续调用 5 万次 performance.now(),只得到了 50 个不同的值。浏览器为了防止侧信道攻击,把 performance.now() 的精度粗糙化到了 100 微秒。所以你拿它去测一个 0.1 毫秒级的差异,测出来的是量子的台阶,不是真实值。想测更细的东西,得靠多次采样取统计值——我上面所有数字都是这么来的。
# 九、我现在的结论
setTimeout(fn, 0) 从来就不是"免费让出一个 tick"。 它在 Node 里稳定 1.05 毫秒,在浏览器里非嵌套时接近 0,但只要你把它写进循环(这几乎是必然的),它就会稳定吃掉 4 毫秒。
几条可以直接抄的结论:
- 让出主线程:浏览器用
scheduler.yield()(0.002 ms)或MessageChannel(0.006 ms),别用setTimeout(0)(3.99 ms)。Node 用setImmediate(0.002 ms),别用setTimeout(0)(1.06 ms)。 - 需要"马上执行"而不是"让出":用微任务(
await Promise.resolve()),0.0003 毫秒,但要记住它会挡住整个事件循环。 - 周期性任务:用递归
setTimeout+ 自校正,别用裸setInterval(2 秒漂 11 毫秒 → 0.34 毫秒)。 - 别依赖 nextTick 一定比 Promise 早:只有在真正的宏任务回调里才成立。
- 算出来的 delay 要夹一下:
Math.min(delay, 2147483647),否则溢出后是"立刻执行"。 - 记住定时器是下限:主线程一忙,4 毫秒和 400 毫秒没有区别。
十年了,我一直把 setTimeout(fn, 0) 当成"免费的让出"。它不是。它是我写过最贵的一行看起来免费的代码。