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 一动不动,不是假数据。这恰恰证明了线程隔离:
- V8 GC 的 Stop-The-World 冻结主线程,Worklet 不受影响
- 主线程 JavaScript 卡顿传导不到音频线程
- 页面渲染(layout/paint/composite)干扰不到音频调度
它不抖,说明音频子系统正常。它抖了,才说明出了大事——OS 级调度异常,或者 V8 STW 穿透了线程隔离(极罕见,但确实存在)。
验证
打开 diffserv.xyz/lab,点「启动探针」:
- 正常状态:绿线稳在 2.67ms,黄线在 16.6ms 附近波动
- 点「超新星」:黄线飙到数百 ms,绿线纹丝不动 → 线程隔离生效
- 点「阻塞主线程」:黄线消失 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 心跳是否精确到采样级?进来看 →
GitHub:hlng2002/stw-sentinel