AudioWorklet 的三种时钟:精度灭杀与绕过 V8 冻结

2026-04-13 · 8 min read

当你试图在 AudioWorkletProcessor 里记录时间戳时,performance.now() 返回 0。这不是 Bug,是浏览器对 Spectre 漏洞的防御响应。

高精度计时器配合共享内存是侧信道攻击的弹药,浏览器直接在 Worklet 线程里抹掉了全局时钟的精度。

0ms 的幽灵

在主线程,performance.now() 精度到 5µs。但在 AudioWorklet 里,它被浏览器内核降级到了极致——连续两次调用返回相同值,delta 永远是 0。

原因:Spectre 漏洞。高精度计时器配合共享内存是侧信道攻击的弹药,浏览器直接在 Worklet 线程里抹掉了全局时钟的精度。

Date.now() 也一样。你怀疑 SAB 坏了、Atomics 出 bug 了、Int32 溢出了——全错。真相是 Worklet 线程里根本没有可用的高精度挂钟。

三种时钟,三个命运

1. Wall Clock — performance.now()

// ❌ 在 Worklet 里:delta 永远是 0
process() {
  const now = performance.now();
  const delta = now - this.lastTime;  // 0, 0, 0, 0...
  this.lastTime = now;
}

Chrome 把 Worklet 线程的 performance.now() 精度降到 100µs ~ 1ms。Safari 更狠,直接 1ms。AudioWorklet 每 128 帧回调一次,48kHz 下就是 2.667ms。当时钟精度被降到 1ms,2.667ms 内两次调用很可能返回相同值。

这是系统性的精度灭杀,不是偶发 bug。

2. Context Clock — currentTime

// 看起来像挂钟,其实是帧时钟的秒数表达
process() {
  const delta = (currentTime - this.lastTime) * 1000;
  // 2.667, 2.667, 2.667...
  this.lastTime = currentTime;
}

currentTime 是 AudioContext 的累计运行时间(秒)。在大多数浏览器实现中,currentTime = currentFrame / sampleRate。它和帧时钟是同一个东西的两种单位。

STW 冻结后恢复时,currentTime 不会跳——因为底层帧计数器也停了。

3. Sample Clock — currentFrame(物理时钟)

// ✅ 唯一可靠的帧级时间源
process() {
  const nowMs = (currentFrame / sampleRate) * 1000;
  const delta = nowMs - this.lastTime;  // 2.667, 2.667...
  this.lastTime = nowMs;
}

currentFrame 是 AudioWorklet 的全局变量,每次 process() 精确递增 128。在 48kHz 下:

128 / 48000 = 0.0026667s = 2.667ms

这个步进由声卡硬件锁死。不管 V8 怎么折腾,不管主线程卡死几百毫秒,这个步进不会变。

它测不到 STW——因为 STW 时帧计数器也停了。但它给了你一个绝对确定性的基准:如果 Worklet 的心跳在跑,音频子系统就是活的。

正确架构:Worklet 只管写,主线程负责判

[ V8 Main Thread ]  <---(SharedArrayBuffer)--->  [ Audio Render Thread ]
       |                                                    |
  performance.now()                                    currentFrame
  (精度 5µs,能感知 STW)                            (帧时钟,2.67ms 步进)
       |                                                    |
       |              Atomics.write(frame)  ──────────►      |
       |              Atomics.read(frame)  ◄──────────       |
       |                                                    |
  对比两侧时钟偏差                                    忠实记录帧序号
  → 推算真实延迟                                       不关心挂钟

Worklet 用 currentFrame 标记每一帧,写入 SAB。主线程用 performance.now()(主线程精度正常)读取,通过两侧时钟的偏差推算实际延迟。

Worklet 不需要挂钟——它只需要忠实记录"我处理到第几帧了"。主线程负责把帧号映射回真实世界的时间。这就是 stw-sentinel 的核心架构。

为什么 2.67ms 纹丝不动是对的

面板上 Worklet Δ 显示 2.67ms 一动不动,不是假数据。这恰恰证明了线程隔离

它不抖,说明音频子系统正常。它抖了,才说明出了大事——OS 级调度异常,或者 V8 STW 穿透了线程隔离(极罕见,但确实存在)。

验证

打开 diffserv.xyz/lab,点「启动探针」:

  1. 正常状态:绿线稳在 2.67ms,黄线在 16.6ms 附近波动
  2. 点「超新星」:黄线飙到数百 ms,绿线纹丝不动 → 线程隔离生效
  3. 点「阻塞主线程」:黄线消失 200ms,绿线平稳 → Worklet 不受主线程阻塞影响

绿线出现红色尖峰 = 全进程 STW,极罕见但确实存在。

备忘录

时间源精度能感知 STW用途
performance.now()被降到 ≥1ms精度不够❌ 不要在 Worklet 里用
currentTime= currentFrame / sampleRate❌ 不能✅ 标记帧时间戳
currentFrame / sampleRate精确到采样❌ 不能✅ 标记帧序号
主线程 performance.now()5µs ~ 100µs✅ 能✅ 主线程侧做时间比对

规则就一条:Worklet 用帧时钟写数据,主线程用挂钟读数据。

LIVE LAB

帧时钟 vs 挂钟:128 帧 = 2.67ms

你的浏览器里,Worklet 心跳是否精确到采样级?进来看 →