如何与 Web Workers 共享内存?
解读
在国内前端/全栈面试中,Rust 与 WebAssembly(WASM)的组合常被用来解决“主线程卡死”或“密集计算”场景。面试官问“如何与 Web Workers 共享内存”,并不是让你背诵 JS API,而是考察三点:
- 你是否理解 Rust 在浏览器中的线性内存模型(WASM 只有一块线性内存);
- 你是否能把 Rust 的所有权语义映射到SharedArrayBuffer + Atomics的 JS 生态;
- 你是否能在零拷贝、零竞争的前提下,给出可落地的工程方案(含 Cargo 工具链、wasm-bindgen、wasm-opt 与浏览器的跨域隔离头)。
一句话:让 Rust 编译出的 .wasm 与 Web Worker 共享同一块 SharedArrayBuffer,且编译期就能保证无数据竞争。
知识点
- wasm-bindgen 0.2.90+ 已官方支持
js_sys::SharedArrayBuffer与wasm_bindgen::convert::RefFromSharedArrayBuffer。 - Rust 侧使用
#[wasm_bindgen]导出unsafe impl Sync的结构体时,必须手动保证内部无悬垂指针;否则借用检查器无法跨线程帮你。 - 浏览器启用
cross-origin-isolated需要同时返回两个响应头:Cross-Origin-Embedder-Policy: require-corp与Cross-Origin-Opener-Policy: same-origin,国内 CDN 如阿里云 OSS 可在“HTTP 头设置”里一键配置。 - 共享内存的最小对齐单位是 8 字节(Atomics.wait 要求),Rust 侧用
#[repr(C, align(8))]结构体可避免 JS 侧出现RangeError。 - 若需动态扩容,必须在主线程与 Worker 之间约定“停写窗口”,再用
Atomics.notify唤醒;否则 Rust 侧&mut [u8]会违反别名规则,属于未定义行为。
答案
步骤一:在 Cargo.toml 中启用特性
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["SharedArrayBuffer", "Atomics"] }
步骤二:Rust 侧封装无竞争缓冲区
use wasm_bindgen::prelude::*;
use std::sync::atomic::{AtomicU32, Ordering};
#[repr(C, align(8))]
pub struct RingBuf {
head: AtomicU32,
tail: AtomicU32,
data: [u8; 65536],
}
#[wasm_bindgen]
pub struct Channel {
ptr: *mut RingBuf,
}
#[wasm_bindgen]
impl Channel {
/// 把 JS 传来的 SharedArrayBuffer 映射成 Rust 引用
pub fn new(sab: js_sys::SharedArrayBuffer) -> Result<Channel, JsValue> {
let ptr = js_sys::Reflect::get(&sab, &JsValue::from_str("byteOffset"))?;
let ptr = ptr.as_f64().ok_or("missing byteOffset")? as *mut u8;
let ptr = ptr as *mut RingBuf;
// SAFETY: 调用方保证同一块 SAB 只实例化一次,且 8 字节对齐
Ok(Channel { ptr })
}
/// 非阻塞写,返回实际写入字节数
pub fn push(&self, buf: &[u8]) -> u32 {
unsafe {
let rb = &*self.ptr;
let tail = rb.tail.load(Ordering::Relaxed);
let head = rb.head.load(Ordering::Acquire);
let avail = head.wrapping_sub(tail).wrapping_sub(1) % 65536;
let len = buf.len().min(avail as usize);
rb.data[tail as usize..tail as usize + len]
.copy_from_slice(&buf[..len]);
rb.tail.store(tail.wrapping_add(len as u32), Ordering::Release);
len as u32
}
}
}
步骤三:JS 侧初始化并传给 Worker
// main.js
if (crossOriginIsolated) {
const sab = new SharedArrayBuffer(65552); // 头 8 字节留给 head/tail
const channel = Channel.new(sab);
const worker = new Worker("worker.js", { type: "module" });
worker.postMessage({ sab });
// 主线程写数据
const n = channel.push(new TextEncoder().encode("hello 中国"));
} else {
console.error("请先配置 COOP/COEP 响应头");
}
步骤四:Worker 侧反向映射
// worker.js
import init, { Channel } from "./pkg/rust_wasm.js";
await init();
self.onmessage = async ({ data: { sab } }) => {
const channel = Channel.new(sab);
// 循环读数据,配合 Atomics.wait 节能
};
要点回顾:
- SharedArrayBuffer 仅做内存载体,真正的并发安全由 Rust 的 Atomic* 与 Ordering 保证;
- 整个流程零拷贝,Rust 直接操作 JS 的共享内存,无 serde 序列化开销;
- 编译期通过 wasm-bindgen-cli 生成 TypeScript 声明,国内团队可直接接入 Vite/Rspack 流水线,符合大厂工程规范。
拓展思考
- 若要把共享内存扩展到多 Worker 一写多读,可把
RingBuf拆成head: AtomicU32+tail: [AtomicU32; N],用位域标记读者 ID,Rust 侧用fetch_or做无锁位图分配;此时需小心伪共享(false sharing),在结构体之间插入 64 字节 padding,适配 x86_64 与 Apple M 系列缓存行。 - 当业务需要大于 2 GB 的共享内存时,浏览器会抛出
RangeError,可借助wasm-bindgen-rayon的“内存池 + 分段映射”思路:Rust 侧预先申请多块 64 KiB 的SharedArrayBuffer,用Vec<JsValue>保存,通过索引表做逻辑连续、物理分段的超大缓冲区,兼顾 Chrome 与国产双核浏览器。 - 在嵌入式浏览器场景(如国产 RTOS 的 WebView),若不支持
cross-origin-isolated,可回退到postMessage传输ArrayBuffer+ Rust 侧wee_alloc手动拷贝;此时用criterion做基准测试,确保拷贝耗时 < 16 ms,避免掉帧,满足车载中控的硬实时要求。