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() 必须被调用一次,否则音频就会断。这个约束反过来成了最精准的时钟。

怎么用这个时钟做监控

思路很简单:

  1. AudioWorklet 每 2.67ms 执行一次 process(),往 SharedArrayBuffer 写一个递增时间戳
  2. 主线程读这个时间戳,如果发现"Worklet 的时间戳在走,但主线程的帧间隔异常大"——说明主线程被冻住了
  3. 主线程帧间隔 > 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

点一下「超新星核爆」,亲眼看黄线刺穿天际、绿线纹丝不动 →

下一步:5 行代码接入 stw-sentinel — 把这个旁路心跳装进你的项目。

GitHub: hlng2002/stw-sentinel

NPM: stw-sentinel