V8 冻结 700ms,AudioWorklet 心跳 2.67ms
2026-04-13 · 5 min read
同一个浏览器,同一个标签页。
主线程:V8 Major GC 触发 Stop-The-World,684.5ms 的绝对静止。
AudioWorklet:2.67ms 间隔,雷打不动。
一个卡死了,一个没感觉。这不是玄学,是线程调度的物理事实。
为什么主线程会卡
V8 做垃圾回收时,特别是 Major GC(Mark-Compact),需要暂停主线程。这不是 Chrome 的 bug,是 GC 的设计——你不停下业务逻辑,就没法安全地移动和清理堆内存。
卡多久?取决于堆的大小和 GC 策略。几百毫秒是常态。
问题在于:你的性能监控代码也跑在主线程上。performance.now()、setInterval、requestAnimationFrame——全冻住了。你根本不知道卡了多久。
为什么 AudioWorklet 不受影响
JavaScript 是单线程的,这句话对主线程成立。但 AudioWorklet 不是主线程。
当你注册一个 AudioWorkletProcessor,浏览器会在独立的音频渲染线程上执行它的 process() 回调。这个线程由 Chromium 的音频子系统管理,向操作系统申请接近实时优先级的调度权限。
为什么?因为音频不能断。物理声学决定了:音频流哪怕中断几毫秒,人耳就能听到爆音。浏览器没有任何选择,必须保证这个线程不被打断。
所以当 V8 挥下 Stop-The-World 的刀,砍到的是主线程。AudioWorklet 在另一个线程,V8 管不到它。
2.67ms 是怎么来的
AudioWorklet 每次处理 128 个音频采样帧。标准采样率 48kHz:
128 / 48000 = 0.002666... 秒 ≈ 2.67ms
这就是它的心跳间隔。物理定律决定的,不是代码设定的。
为什么不用 Web Worker
Worker 也在独立线程,但 Worker 没有固定的执行间隔。你没法用 setInterval 在 Worker 里做精确计时——浏览器会让步给更高优先级的任务。AudioWorklet 有 128 帧的硬约束,每隔 2.67ms process() 必须被调用一次,否则音频就会断。这个约束反过来成了最精准的时钟。
怎么用这个时钟做监控
思路很简单:
- AudioWorklet 每 2.67ms 执行一次 process(),往 SharedArrayBuffer 写一个递增时间戳
- 主线程读这个时间戳,如果发现"Worklet 的时间戳在走,但主线程的帧间隔异常大"——说明主线程被冻住了
- 主线程帧间隔 > 50ms 的,记一次 STW 尖峰
5 行代码接入:
npm i stw-sentinel
import { STWSentinel } from 'stw-sentinel'
const s = new STWSentinel({
thresholdMs: 10,
onSpike: (d) => console.log('STW!', d.deltaUs)
})
s.init()你不需要优化 GC,你需要先看见它
前端性能优化的盲区不是不知道怎么优化,是不知道卡在哪里。
主线程卡 700ms,你的用户感觉到了,但你的监控代码也冻住了,报不上来。AudioWorklet 给了一个旁路:它不卡,它能帮你计时。
这就是全部。没有玄学,就是线程调度的物理事实。
LIVE LAB
主线程 684.5ms vs AudioWorklet 2.67ms
点一下「超新星核爆」,亲眼看黄线刺穿天际、绿线纹丝不动 →