在WebGL中实现线程模拟的SharedArrayBuffer

解读

Unity WebGL 导出后,JavaScript 运行在主线程,且WebGL 线程模型与原生线程完全不同
国内面试问“线程模拟的 SharedArrayBuffer”,并不是让你真的开线程,而是考察:

  1. 是否知道 WebGL 导出禁用 pthread(Unity 官方文档明确提示);
  2. 能否用 SharedArrayBuffer + Atomics主线程与 WebWorker 之间 做“伪并发”数据同步;
  3. 是否能把 Unity C# 端对多线程的访问模式(如双缓冲、环形队列)无损迁移到 JavaScript 层,并保证 Unity 主循环不掉帧
  4. 是否理解国内 跨域隔离(COOP/COEP) 政策,SharedArrayBuffer 必须 HTTPS + 响应头 才能启用,否则线上会直接抛异常。

一句话:在 无真线程、无共享内存 的 WebGL 沙箱里,用 官方允许的 SharedArrayBuffer 把“后台计算”搬到 WebWorker,并 对上层 C# 提供线程安全的假象

知识点

  1. Unity WebGL 导出限制

    • 编译选项 -pthread 被 Emscripten 强制关闭;
    • System.Threading.Thread 在构建期直接报错;
    • UnityJobSystemIJobParallelFor 会被降级成主循环顺序执行。
  2. SharedArrayBuffer 复活史

    • 2018 年因 Spectre 被禁用;
    • 2021 年国内主流浏览器(Chrome 92、Edge 92、Firefox 79)重新放开,前提是响应头
      Cross-Origin-Opener-Policy: same-origin  
      Cross-Origin-Embedder-Policy: require-corp
      
    • 字节、腾讯、网易的 WebGL 小游戏上线流程里,运维必须手动加头,否则 SAB 实例化失败。
  3. Atomics 四字诀

    • Atomics.load/store 保证 单操作原子
    • Atomics.wait/wake 实现 阻塞同步原语(模拟 mutex、sem);
    • Atomics.compareExchange无锁环形队列 的 head/tail 竞争;
    • 禁止在 主线程 调用 Atomics.wait,会抛 TypeError,只能在 Worker 里阻塞。
  4. Unity 与 JavaScript 双向通道

    • jslib 插件里把 SAB 地址通过 _malloc 映射到 C# IntPtr
    • C# 端用 Marshal.Copy 读写,避免每次跨语言拷贝
    • Worker 计算完后通过 postMessage0 拷贝信号,Unity 在下一帧 Update 里消费。
  5. 性能红线

    • 主线程 每帧访问 SAB 次数 < 2000 次(Chrome 实测 4 ms 以上就会掉 60 FPS);
    • Worker 计算任务粒度 > 5 ms 才能抵消通信开销;
    • 移动端微信、抖音内置浏览器 iOS 15 以下不支持 SAB,必须 优雅降级 到单线程。

答案

  1. 开启跨域隔离
    在 CDN 或 Nginx 层加响应头,否则后续代码都跑不通

  2. C# 端定义共享结构

// ThreadSafeData.cs
[StructLayout(LayoutKind.Sequential, Size = 64)]
public struct ThreadSafeData {
    public int head;
    public int tail;
    public unsafe fixed float matrix[12]; // 48 B
}
  1. jslib 插件导出 SAB
// WebGLWorker.jslib
mergeInto(LibraryManager.library, {
  WebGLWorker_Init: function (byteLength) {
    const sab = new SharedArrayBuffer(byteLength);
    // 存到全局供 Worker 使用
    window.webGLSab = sab;
    // 返回指针给 C#
    return _malloc(byteLength);
  },
  WebGLWorker_GetSABPtr: function () {
    return window.webGLSab ? window.webGLSab : 0;
  }
});
  1. 实例化 Worker 并建立协议
// worker.js
let sab, view;
self.onmessage = e => {
  if (e.data.sab) {
    sab = e.data.sab;
    view = new Int32Array(sab);
  }
  // 后台计算,例如蒙皮矩阵
  while (Atomics.load(view, 0) !== 1) {
    // 用 compareExchange 抢锁
    if (Atomics.compareExchange(view, 0, 0, 2) === 0) {
      for (let i = 0; i < 12; ++i) {
        view[i + 2] = Math.sin(performance.now() * 0.001 + i);
      }
      Atomics.store(view, 0, 0);        // 释放锁
      Atomics.notify(view, 0, 1);       // 唤醒主线程
    }
  }
};
  1. Unity 主循环消费
void Update() {
    unsafe {
        int* ptr = (int*)sabPtr;
        // 非阻塞检测
        if (Interlocked.CompareExchange(ref ptr[0], 1, 0) == 0) {
            float* mat = (float*)(ptr + 2);
            // 直接拷到 MaterialPropertyBlock,0 拷贝
            mpb.SetMatrix("unity_MatrixVP", new Matrix4x4(
                new Vector4(mat[0], mat[1], mat[2], mat[3]),
                ...
            ));
            Graphics.DrawMeshInstanced(mesh, 0, mat, matrices, 1023, mpb);
            // 归还锁
            Interlocked.Exchange(ref ptr[0], 0);
        }
    }
}
  1. 国内上线 checklist
    • 运维确认 HTTPS + COOP/COEP
    • 代码层检测 'SharedArrayBuffer' in window不支持就回退到协程版
    • 性能监控埋点:每帧 performance.now() 差值 > 16 ms 自动上报
    • 微信小游戏需 转码为 wasm2js,SAB 仍可用,但包体增大 30%,必须做分包加载

拓展思考

  1. 无 SAB 场景
    政务内网、教育平板常禁用 COOP,此时可用 Transferable ArrayBuffer 每帧 postMessage,代价是 一次内存拷贝,实测 1 万个 float 耗时 0.8 ms,可接受

  2. Unity 2023 的 WebGL Threads
    官方实验包已把 pthread 映射到 Web Worker + SAB,但 国内 CDN 缓存命中率低,首包 15 MB 以上,不适合微信小游戏,建议 继续用自研方案

  3. GPU 端并行替代
    把计算搬到 ComputeShader,通过 WebGL2.0EXT_disjoint_timer_query 测耗时,绕过 CPU 线程限制;但 WebGL1.0 机型占比 18%(国内安卓 7),需 回退到 SAB 方案

  4. 安全合规
    工信部 2024 年新规要求 WebGL 应用上报“共享内存使用声明”,否则应用商店下架;SharedArrayBuffer 必须在用户协议里显式告知,面试时可主动提到,体现合规意识