如何与 Web Workers 共享内存?

解读

在国内前端/全栈面试中,Rust 与 WebAssembly(WASM)的组合常被用来解决“主线程卡死”或“密集计算”场景。面试官问“如何与 Web Workers 共享内存”,并不是让你背诵 JS API,而是考察三点:

  1. 你是否理解 Rust 在浏览器中的线性内存模型(WASM 只有一块线性内存);
  2. 你是否能把 Rust 的所有权语义映射到SharedArrayBuffer + Atomics的 JS 生态;
  3. 你是否能在零拷贝、零竞争的前提下,给出可落地的工程方案(含 Cargo 工具链、wasm-bindgen、wasm-opt 与浏览器的跨域隔离头)。

一句话:让 Rust 编译出的 .wasm 与 Web Worker 共享同一块 SharedArrayBuffer,且编译期就能保证无数据竞争。

知识点

  1. wasm-bindgen 0.2.90+ 已官方支持 js_sys::SharedArrayBufferwasm_bindgen::convert::RefFromSharedArrayBuffer
  2. Rust 侧使用 #[wasm_bindgen] 导出 unsafe impl Sync 的结构体时,必须手动保证内部无悬垂指针;否则借用检查器无法跨线程帮你。
  3. 浏览器启用 cross-origin-isolated 需要同时返回两个响应头:Cross-Origin-Embedder-Policy: require-corpCross-Origin-Opener-Policy: same-origin国内 CDN 如阿里云 OSS 可在“HTTP 头设置”里一键配置
  4. 共享内存的最小对齐单位是 8 字节(Atomics.wait 要求),Rust 侧用 #[repr(C, align(8))] 结构体可避免 JS 侧出现 RangeError
  5. 若需动态扩容,必须在主线程与 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 流水线,符合大厂工程规范。

拓展思考

  1. 若要把共享内存扩展到多 Worker 一写多读,可把 RingBuf 拆成 head: AtomicU32 + tail: [AtomicU32; N],用位域标记读者 ID,Rust 侧用 fetch_or 做无锁位图分配;此时需小心伪共享(false sharing),在结构体之间插入 64 字节 padding,适配 x86_64 与 Apple M 系列缓存行。
  2. 当业务需要大于 2 GB 的共享内存时,浏览器会抛出 RangeError,可借助 wasm-bindgen-rayon 的“内存池 + 分段映射”思路:Rust 侧预先申请多块 64 KiB 的 SharedArrayBuffer,用 Vec<JsValue> 保存,通过索引表做逻辑连续、物理分段的超大缓冲区,兼顾 Chrome 与国产双核浏览器。
  3. 嵌入式浏览器场景(如国产 RTOS 的 WebView),若不支持 cross-origin-isolated,可回退到 postMessage 传输 ArrayBuffer + Rust 侧 wee_alloc 手动拷贝;此时用 criterion 做基准测试,确保拷贝耗时 < 16 ms,避免掉帧,满足车载中控的硬实时要求。