setTimeout(fn, 0) 到底等多久:我把定时器的账算了一遍

前阵子写一个轮询,代码大概长这样:

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 毫秒。

一块写着 2147483647 的发光路牌立在悬崖边缘,下面是浓雾

什么场景会撞上?任何用乘法算出来的过期时间:

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 毫秒。

几条可以直接抄的结论:

  1. 让出主线程:浏览器用 scheduler.yield()(0.002 ms)或 MessageChannel(0.006 ms),别用 setTimeout(0)(3.99 ms)。Node 用 setImmediate(0.002 ms),别用 setTimeout(0)(1.06 ms)。
  2. 需要"马上执行"而不是"让出":用微任务(await Promise.resolve()),0.0003 毫秒,但要记住它会挡住整个事件循环。
  3. 周期性任务:用递归 setTimeout + 自校正,别用裸 setInterval(2 秒漂 11 毫秒 → 0.34 毫秒)。
  4. 别依赖 nextTick 一定比 Promise 早:只有在真正的宏任务回调里才成立。
  5. 算出来的 delay 要夹一下:Math.min(delay, 2147483647),否则溢出后是"立刻执行"。
  6. 记住定时器是下限:主线程一忙,4 毫秒和 400 毫秒没有区别。

十年了,我一直把 setTimeout(fn, 0) 当成"免费的让出"。它不是。它是我写过最贵的一行看起来免费的代码。