本节摘要:当 Wasm 计算大到足以挤占渲染预算,互操作就要升维到线程级:Web Worker 提供后台工位,SharedArrayBuffer 提供跨线程共享内存,Atomics 提供同步原语;Service Worker 让字节缓存与页面生命周期解耦。本节组装出一套"主线程只管交互、后台承包计算"的标准架构,并交代每一层的部署前提。
上线首日的监控曲线让人坐不住了:页面在 Wasm 解码大文件时帧率跌到个位数,交互全面卡顿——计算明明跑在 Wasm 里,主线程照样喘不过气。原因不难猜:Wasm 与 JavaScript 共享同一个线程与事件循环,重计算不挪窝,主线程就永远挤不出帧预算。本节的全部内容,就是给计算挪窝的系统方法。
Worker 是浏览器提供的独立线程:模块在 Worker 里实例化、计算在 Worker 里跑,主线程只收发轻量消息。标准架构分三层——主线程管交互与调度,Worker 管模块生命周期与计算,两者之间用消息传指令、用共享内存传数据:
// main.js —— 主线程:只发指令,不算数 const worker = new Worker("engine.js"); const channel = new MessageChannel(); worker.postMessage( { cmd: "init", port: channel.port2 }, [channel.port2] ); // 提交任务:共享缓冲区的地址与长度,不搬数据 worker.postMessage({ cmd: "decode", ptr: 0, len: bufferLen }); worker.onmessage = (e) => { if (e.data.type === "progress") updateProgressBar(e.data.pct); if (e.data.type === "done") render(e.data.resultPtr, e.data.resultLen); };
// engine.js —— Worker:模块与计算的家 let instance; onmessage = async (e) => { if (e.data.cmd === "init") { const bytes = await (await fetch("engine.wasm")).arrayBuffer(); instance = (await WebAssembly.instantiate(bytes)).instance; postMessage({ type: "ready" }); } if (e.data.cmd === "decode") { instance.exports.decode(e.data.ptr, e.data.len); postMessage({ type: "done", resultPtr: e.data.ptr, resultLen: 4096 }); } };
这个骨架值得原样记住:指令与数据分流(消息传指令、内存传数据)、模块单例驻留 Worker(避免每次任务重新实例化)、进度经消息回传(主线程 UI 只消费轻量状态)。绝大多数"页面被 Wasm 拖卡"的问题,套这个骨架就能解决。

主线程与 Worker 各有各的线性内存,大块数据不想拷贝,就要用 SharedArrayBuffer——一块显式声明跨线程共享的字节区,递给模块当线性内存,两端读写同一片物理字节。它有两道门槛要过。
第一道是部署门槛:共享内存要求页面处于跨源隔离状态,响应头必须带上跨源开启策略的两个标记(COOP 与 COEP),否则 SharedArrayBuffer 直接不可用。这两个头可能影响页面嵌入第三方内容的方式,要在架构阶段决定而不是上线当天补救——第八章安全节还会从攻防视角回看这条约束。第二道是同步门槛:两个线程同时读写同一片内存,没有协调就是数据竞争。Atomics 提供原子读写与等待唤醒原语:生产方写完数据后置状态位并唤醒,消费方等待状态位就绪再读,构成最简生产者-消费者协议:
// 约定:缓冲区头部第一个 Int32 是状态字,0 空闲 1 就绪 const status = new Int32Array(sab, 0, 1); const payload = new Float32Array(sab, 16); // 主线程(生产方) function submitTask(data) { payload.set(data, 0); Atomics.store(status, 0, 1); // 置就绪 Atomics.notify(status, 0); // 唤醒等待方 } // Worker(消费方) function waitTask() { Atomics.wait(status, 0, 0); // 状态字不为 1 就睡 // ... 读 payload 处理 Atomics.store(status, 0, 0); // 用完复位 }
这套"状态字加通知"的模式是线程间协议的最小完整件;更复杂的任务队列(多槽环形缓冲、双缓冲交替)都是它的组合变形,第六章的多线程提案会在语言层再遇到同一套原语。
Service Worker 缓存解决的是"字节进港"的稳定性:Wasm 模块动辄数百 KB,依赖网络的加载路径在弱网下是首屏杀手。Service Worker 里对模块请求采用缓存优先策略,字节命中本地后编译照常进行;更进一步可以把编译产物(各引擎的模块缓存)也纳入缓存体系,实现"零编译启动"。要记得缓存的是模块字节本身,加载逻辑里的版本号与完整性校验照常保留——缓存救弱网,不替你做版本治理。
OffscreenCanvas解决的是渲染本身的重活:把画布的控制权移交 Worker,渲染循环与 Wasm 计算同线程闭环,主线程彻底卸下绘制负担。游戏与可视化场景把"计算加渲染"整体搬进后台,主线程只剩输入分发与界面元素,帧预算从此与计算量解耦。
泊位上的工程手法到此齐备。下一章离开固定码头,去看那些正在改写航线的扩展提案——线程、向量、垃圾回收与更远处的系统接口。