很多前端同学觉得性能优化就是防抖节流、配配 Webpack。但当你涉足音视频、WebAssembly 这些硬核领域时,你会发现 V8 的垃圾回收(GC)才是你最大的敌人。GC 一卡 500ms,你的音频线程就跟着死。锁?不存在的。AudioWorklet 里只有一条活路——无锁。
为什么不能锁
后端同学转过来,第一反应是 Atomics.wait() + Atomics.notify() 实现 Mutex。逻辑上没问题,但 AudioWorklet 不讲逻辑。
AudioWorklet 跑在浏览器音频线程上,这个线程的优先级是 OS 级实时优先级。它每 2.67ms 必须吐出 128 帧,一帧都不能少。如果你用 Atomics.wait() 把它挂起,等待主线程释放锁,而主线程正在 GC 卡 500ms——音频线程就跟着假死 500ms,用户听到的是爆音。
// ❌ 错误示范:在 AudioWorklet 里用锁
Atomics.wait(sab, 0, 0); // 挂起等待主线程释放锁
// 如果主线程被 GC 卡住 500ms,
// 音频线程跟着假死 500ms → 爆音
所以 AudioWorklet 里的操作必须是无等待(Wait-free) 的:读到就处理,读不到就跳过,永远不能挂起。
SAB 布局:Header + 环形数据区
SharedArrayBuffer 是两个线程共享的原始内存。我们把它分成 Header 和 Data 两段:
// SAB 布局
// HEADER: [0]writePtr [1]readPtr [2]dropCount [3]reserved
// DATA: 4096 Int32 = 2048 pairs (timestamp_µs, delta_µs)
const HEADER_SIZE = 4; // 4 Int32 元素
const DATA_CAPACITY = 4096; // 数据区 4096 Int32
const IDX_WRITE_PTR = 0;
const IDX_READ_PTR = 1;
const IDX_DROP_COUNT = 2;
Header 里放三个游标:Write Pointer(Worklet 写到哪了)、Read Pointer(主线程读到哪了)、Drop Count(溢出计数)。Data 区是 4096 个 Int32,每两个一组存一对 (timestamp, delta)。
写入端:Worklet(生产者)
AudioWorklet 每帧调用 process(),计算帧间隔 delta,然后往 SAB 里写一对数据。关键操作:
Atomics.load读 Write Pointer 和 Read Pointer- 环形包裹:
(writePtr + 2) % DATA_CAPACITY - 如果写指针追上读指针 → overflow,记录 drop
Atomics.store更新 Write Pointer
// AudioWorklet 端 — 写入(生产者)
process(inputs, outputs, parameters) {
const writePtr = Atomics.load(sab, IDX_WRITE_PTR);
const readPtr = Atomics.load(sab, IDX_READ_PTR);
// 环形包裹:写指针前进 2 格(timestamp + delta 一对)
const nextWritePtr = (writePtr + 2) % DATA_CAPACITY;
if (nextWritePtr === readPtr) {
// Buffer 满了 → overflow,记录 drop
Atomics.add(sab, IDX_DROP_COUNT, 1);
} else {
const dataIdx = HEADER_SIZE + writePtr;
sab[dataIdx] = Math.floor(currentTime * 1e6); // 时间戳 µs
sab[dataIdx + 1] = Math.floor(delta * 1000); // 帧间隔 µs
Atomics.store(sab, IDX_WRITE_PTR, nextWritePtr);
}
}
读取端:主线程(消费者)
主线程用 drain() 批量读取:从 Read Pointer 一路追到 Write Pointer,读完更新 Read Pointer。同样用 Atomics.load/store,零等待。
// 主线程端 — 读取(消费者)
drain() {
const writePtr = Atomics.load(sab, IDX_WRITE_PTR);
const readPtr = Atomics.load(sab, IDX_READ_PTR);
const results = [];
let ptr = readPtr;
while (ptr !== writePtr) {
const dataIdx = HEADER_SIZE / 4 + ptr;
results.push({
timestampUs: this.sharedBuffer[dataIdx],
deltaUs: this.sharedBuffer[dataIdx + 1],
});
ptr = (ptr + 2) % DATA_CAPACITY; // 环形包裹
}
Atomics.store(sab, IDX_READ_PTR, ptr);
return results;
}
为什么必须用 Atomics.load
直接 sab[0] 读不行吗?不行。V8 的 JIT 优化会把频繁访问的 SAB 位置缓存进寄存器,导致你读到的是旧值——在无锁循环里,这意味着死循环或脏数据。
// ❌ 直接读 — V8 JIT 会缓存进寄存器,死循环读到脏数据
const ptr = sab[0]; // 可能是旧值!
// ✅ Atomics.load — 强制内存屏障,保证可见性
const ptr = Atomics.load(sab, 0); // 永远是最新的
Atomics.load 插入内存屏障,强制从主内存读最新值。代价是几个纳秒的延迟,但保证了正确性。在 2.67ms 的帧预算里,几个纳秒根本不算什么。
环形包裹:一维数组首尾相连
环形队列的核心是取模:(ptr + chunkSize) % DATA_CAPACITY。写指针前进到末尾时,自动绕回数组开头。不需要移动数据,不需要分配新内存,O(1) 入队出队。
更极致的做法是用位运算代替取模——把 DATA_CAPACITY 设为 2 的幂,用 & (CAPACITY - 1) 代替 % CAPACITY。但对 2.67ms 的帧预算来说,取模和位运算的差距可以忽略。
溢出与断流
无锁队列有两个灾难模式:
- Overflow:写指针追上读指针一圈 → 数据覆盖。我们用
dropCount计数,不阻塞。 - Underrun:读指针追上写指针 → 没有新数据。主线程
drain返回空数组,音频继续用上一帧插值。
两种情况都不挂起,不等待,不死锁。这就是无锁的本质:允许丢数据,不允许卡线程。
无锁队列 + Atomics 原子操作 = 实时线程的生死线。
打开 Live Lab → diffserv.xyz
2026-04-15 · 无锁队列实战 · diffserv.xyz