JavaScript 并发编程实战:用 Atomics 与 SharedArrayBuffer 玩转多线程与视频渲染

1)Atomics 与 SharedArrayBuffer

概念

  • SharedArrayBuffer(SAB) :可被多个执行体(主线程/Worker)同时访问的内存缓冲区。它不复制数据,而是多方共享同一块内存。
  • Atomics :在共享内存 上提供一组原子读写、算术、位运算、交换、以及"futex 风格"等待/唤醒的 API,确保并发下的内存可见性与操作不可分割性。

原理

  • JS 的普通操作并不保证对共享内存的原子性 (不可分割)与顺序可见性(别的线程何时能看到你的写入)。
  • Atomics.*SharedArrayBuffer 上的 Int32Array/BigInt64Array 视图上执行,提供内存序保障(类似 CPU 原子指令 + 内存栅栏),避免"读旧值""丢写""乱序可见"等并发问题。

对比

  • ArrayBuffer:只能单线程访问或拷贝给别人(会复制或转移所有权),无并行共享。
  • SharedArrayBuffer真正并发共享 ,因此需要 Atomics 来做同步互斥
  • 高层抽象(比如消息通道 postMessage)适合低频大块传输;SAB+Atomics 更适合高频、低延迟、细粒度的共享与同步(例如音视频帧环形缓冲区、计数器、锁/条件变量等)。

实践(小结)

  • 没有 Atomics 的 SAB 常导致资源争用 :多线程同时读/改/写同一位置,会出现竞态
  • 用 Atomics 把关键步骤"锁住"或"原子化",再配合 wait/notify阻塞/唤醒式同步,就能写出稳定的并发代码。

拓展

  • 可用 Atomics 自行实现 spinlockticket lock信号量环形队列有界缓冲读写锁 等原语。
  • WebCodecsOffscreenCanvasWebAssembly 配合,能搭建高性能音视频管线、图像处理流水线。

潜在问题

  • 只有 Worker 可以调用阻塞式 Atomics.wait(主线程禁止阻塞)。
  • 浏览器端使用 SAB 需要 crossOriginIsolated(COOP/COEP 头或等效策略)。
  • 原子操作频繁也有成本:要平衡锁粒度争用

2)SharedArrayBuffer:四个 Worker 访问同一 SAB 的"资源争用"演示

2.1 无原子操作:会丢写(错误示范)

ini 复制代码
<!-- demo-race.html:演示丢写,需在 crossOriginIsolated 环境中运行 -->
<script>
  // 1) 准备共享内存:仅 4 字节(一个 32 位整数)
  const sab = new SharedArrayBuffer(4);
  const view = new Int32Array(sab);
  view[0] = 0; // 共享计数器

  // 2) Worker 脚本(用 Blob URL 内联,便于单文件运行)
  const workerCode = `
    self.onmessage = async (e) => {
      const { sab, rounds } = e.data;
      const view = new Int32Array(sab);
      // 不使用 Atomics:典型的"读-改-写"竞态
      for (let i = 0; i < rounds; i++) {
        const v = view[0];    // A: 读
        // 模拟计算耗时,增大竞态概率
        for (let j = 0; j < 50; j++) {} 
        view[0] = v + 1;      // B: 写(可能覆盖他人写入)
      }
      postMessage('done');
    };
  `;
  const workerUrl = URL.createObjectURL(new Blob([workerCode], {type: 'text/javascript'}));

  // 3) 启动 4 个 worker,每个自增 N 次
  const N = 10000;
  const workers = Array.from({length: 4}, () => new Worker(workerUrl));
  let done = 0;
  workers.forEach(w => {
    w.onmessage = () => {
      if (++done === workers.length) {
        console.log('期望值 =', 4*N, '实际值 =', view[0]); // 实际值通常 < 期望值
      }
    };
    w.postMessage({ sab, rounds: N });
  });
</script>

说明 :多个 Worker 在 view[0] 上做"读-改-写",没有互斥或原子化,发生丢写(比如两个线程都读取了旧值 5,然后分别写回 6 → 6,等价于只+1一次)。

2.2 使用 Atomics.add 修复丢写(正确示范)

ini 复制代码
<!-- demo-atomic-add.html:用 Atomics 消除丢写 -->
<script>
  const sab = new SharedArrayBuffer(4);
  const view = new Int32Array(sab);
  view[0] = 0;

  const workerCode = `
    self.onmessage = async (e) => {
      const { sab, rounds } = e.data;
      const view = new Int32Array(sab);
      for (let i = 0; i < rounds; i++) {
        // 原子加:硬件级不可分割 + 内存序保障
        Atomics.add(view, 0, 1);
      }
      postMessage('done');
    };
  `;
  const workerUrl = URL.createObjectURL(new Blob([workerCode], {type: 'text/javascript'}));

  const N = 10000;
  const workers = Array.from({length: 4}, () => new Worker(workerUrl));
  let done = 0;
  workers.forEach(w => {
    w.onmessage = () => {
      if (++done === workers.length) {
        console.log('期望值 =', 4*N, '实际值 =', view[0]); // === 期望值
      }
    };
    w.postMessage({ sab, rounds: N });
  });
</script>

3)原子操作基础

3.1 算术与位运算:add/sub/or/and/xor

使用场景

  • 计数/配额add / sub
  • 多标志位or 设置位,and 清位,xor 翻转位
  • 资源争用:用位标志表示"资源是否被占用/脏页/就绪"等

示例:用位运算管理 4 个"资源占用"位,并原子自增计数

scss 复制代码
<script>
  // 布局:index 0 = 计数器;index 1 = 标志位(低 4 位对应 4 个资源占用)
  const sab = new SharedArrayBuffer(8);
  const i32 = new Int32Array(sab);
  const COUNTER = 0, FLAGS = 1;

  // 设置第 k 位(占用资源 k)
  function occupy(k) {
    const mask = 1 << k;
    Atomics.or(i32, FLAGS, mask); // 原子置位
  }

  // 清除第 k 位(释放资源 k)
  function release(k) {
    const mask = ~(1 << k);
    Atomics.and(i32, FLAGS, mask); // 原子清位
  }

  // 查询第 k 位
  function isOccupied(k) {
    return (Atomics.load(i32, FLAGS) & (1 << k)) !== 0;
  }

  // 原子自增计数(比如处理任务数)
  function inc() {
    return Atomics.add(i32, COUNTER, 1) + 1; // 返回自增后的值
  }

  // ------ 演示 ------ 
  occupy(0); // 占用资源0
  console.log('flags after occupy(0)=', i32[FLAGS].toString(2));
  console.log('isOccupied(0)=', isOccupied(0));
  release(0);
  console.log('flags after release(0)=', i32[FLAGS].toString(2));
  console.log('inc() ->', inc()); // 1
</script>

3.2 原子的读/写:load / store

  • Atomics.load(view, idx):从共享内存原子读取,带必要内存序(保证之前的写入对当前可见)。
  • Atomics.store(view, idx, value)原子写入并带发布语义(之后的读取者按顺序可见)。

示例:标志位 + 数据写入的"先写数据、再置就绪标志"

xml 复制代码
<script>
  // index 0: READY 标志;index 1: 数据
  const sab = new SharedArrayBuffer(8);
  const i32 = new Int32Array(sab);
  const READY = 0, DATA = 1;

  // 生产者(Worker 内常见)
  function producerWrite(v) {
    Atomics.store(i32, DATA, v);     // ① 先写数据
    Atomics.store(i32, READY, 1);    // ② 再置就绪(发布)
    // 消费者用 load/或 wait 看到 READY==1 时,保证能看到数据 v
  }

  // 消费者
  function consumerRead() {
    if (Atomics.load(i32, READY) === 1) {
      const v = Atomics.load(i32, DATA);
      console.log('read =', v);
    }
  }

  producerWrite(42);
  consumerRead(); // 输出 42
</script>

3.3 原子交换:exchange / compareExchange

  • exchange:把索引处的值替换为新值,并返回旧值(常用于交换标志获取旧状态)。
  • compareExchange(CAS):若当前值等于期望值,则写入新值并返回旧值;否则不写,返回实际旧值。用来实现无锁算法自旋锁once 初始化等。

示例 A:exchange 实现"获取并清空"通知标志

xml 复制代码
<script>
  const sab = new SharedArrayBuffer(4);
  const i32 = new Int32Array(sab);

  function notifyOnce() {
    Atomics.store(i32, 0, 1); // 置 1:有通知
  }
  function consumeNotify() {
    const prev = Atomics.exchange(i32, 0, 0); // 写回0,返回旧值
    if (prev === 1) console.log('收到一次通知并已清空');
  }

  notifyOnce();
  consumeNotify(); // 收到一次通知并已清空
</script>

示例 B:compareExchange 实现"最大值更新"(无锁)

xml 复制代码
<script>
  const sab = new SharedArrayBuffer(4);
  const i32 = new Int32Array(sab);
  i32[0] = 10; // 初始最大值

  function updateMax(candidate) {
    while (true) {
      const cur = Atomics.load(i32, 0);
      if (candidate <= cur) return cur; // 不需要更新
      // 仅当 cur 仍是当前值时,写入 candidate
      const old = Atomics.compareExchange(i32, 0, cur, candidate);
      if (old === cur) return candidate; // 成功
      // 否则被别人抢先更新,循环重试
    }
  }

  console.log(updateMax(15)); // 15
  console.log(updateMax(12)); // 15(不会倒退)
</script>

3.4 原子的 "futex" 操作与加锁:wait / notify

  • Atomics.wait(view, idx, expected[, timeout])阻塞 当前 Worker,直到 view[idx] 不再等于 expected 或超时被唤醒(仅 Worker 可用!)。
  • Atomics.notify(view, idx[, count]):唤醒在该地址上等待的 count 个线程(默认唤醒全部)。

示例:4 个 Worker 依次取得锁并按顺序执行

思路:用一个共享变量 turn 指示当前轮到的线程 ID(0..3)。每个 Worker:

1)在 turn 不等于自己 ID 时调用 wait 挂起;

2)轮到自己时执行任务;3)将 turn 改为下一个 ID,并 notify 唤醒其他等待者。

ini 复制代码
<!-- demo-turn.html:依次执行(0 → 1 → 2 → 3 → 循环) -->
<script>
  const sab = new SharedArrayBuffer(4);
  const i32 = new Int32Array(sab);
  const TURN = 0;
  Atomics.store(i32, TURN, 0); // 从 0 号开始

  const workerCode = `
    self.onmessage = async (e) => {
      const { sab, id, rounds } = e.data;
      const i32 = new Int32Array(sab);
      const TURN = 0;

      for (let r = 0; r < rounds; r++) {
        // 1) 等待轮到自己
        while (Atomics.load(i32, TURN) !== id) {
          // 等待值在当前值上发生变化
          Atomics.wait(i32, TURN, Atomics.load(i32, TURN), 100); // 可设超时避免永久睡眠
        }
        // 2) 执行临界区(此处仅打印模拟)
        // (在真实任务中,这里可访问共享数据结构)
        postMessage(`Worker ${id}: run #${r}`);

        // 3) 交接"令牌"给下一个
        const next = (id + 1) % 4;
        Atomics.store(i32, TURN, next); // 更新 turn
        Atomics.notify(i32, TURN, 1);   // 唤醒一个等待者
      }
      postMessage(`Worker ${id}: done`);
    };
  `;

  const url = URL.createObjectURL(new Blob([workerCode], {type:'text/javascript'}));
  const workers = Array.from({length:4}, (_,id)=>new Worker(url));
  workers.forEach((w,id)=>{
    w.onmessage = (e)=> console.log(e.data);
    w.postMessage({ sab, id, rounds: 3 });
  });
</script>

4)"视频播放"示例(OffscreenCanvas + SAB 环形缓冲 + Atomics.wait/notify

目标:搭一个"解码/生产者渲染/消费者"的双 Worker 管线:

  • 解码 Worker 把帧像素(这里用程序生成假帧 代替真实解码)写入 SAB 环形缓冲
  • 渲染 Worker 拿到 OffscreenCanvas,在 Worker 内 Atomics.wait 阻塞等待有帧可用,唤醒后从 SAB 读帧并绘制。
  • 主线程只负责创建 canvas、传参、显示 UI,不做阻塞等待。
    ✅ 重点:演示 有界缓冲区 (避免无限增长)、等待/唤醒 (低 CPU 占用)、无复制共享(SAB)。
    ⚠️ 要求:在 crossOriginIsolated 环境运行(见文末部署说明)。
ini 复制代码
<!-- video-sab.html:双 Worker 视频模拟播放(单文件版) -->
<canvas id="screen" width="320" height="180" style="border:1px solid #ccc"></canvas>
<button id="start">Start</button>
<button id="stop">Stop</button>
<script>
(() => {
  // ========================
  // 1) 配置环形缓冲区参数
  // ========================
  const WIDTH = 320, HEIGHT = 180, BYTES_PER_PIXEL = 4;
  const FRAME_BYTES = WIDTH * HEIGHT * BYTES_PER_PIXEL;
  const RING_CAP = 8; // 帧缓冲容量(越大越抗抖;越小越省内存)

  // 控制区布局(Int32)
  // [0] writeIndex  生产者写入位置(0..RING_CAP-1)
  // [1] readIndex   消费者读取位置
  // [2] available   当前可用帧数量(0..RING_CAP)
  // [3] running     1=运行,0=停止(供协程优雅退出)
  const CTRL_INTS = 4;
  const controlSAB = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * CTRL_INTS);
  const ctrl = new Int32Array(controlSAB);
  ctrl[0] = 0; ctrl[1] = 0; ctrl[2] = 0; ctrl[3] = 0;

  // 帧区(共享像素),总大小 = CAP * FRAME_BYTES
  const framesSAB = new SharedArrayBuffer(RING_CAP * FRAME_BYTES);

  // ========================
  // 2) Worker 代码(内联 Blob)
  // ========================
  const decoderCode = `
    // 生产者:把"合成帧"写入环形缓冲,并 Atomics.notify 唤醒渲染线程
    self.onmessage = (e) => {
      const { controlSAB, framesSAB, width, height, cap } = e.data;
      const ctrl = new Int32Array(controlSAB);
      const bytes = new Uint8ClampedArray(framesSAB);
      const FRAME_BYTES = width * height * 4;

      function produceOne(t) {
        // 1) 如果环形缓冲已满,等待消费者消耗
        while (Atomics.load(ctrl, 2) === cap) {
          // 在 available 索引(2)上等待值变化
          Atomics.wait(ctrl, 2, cap, 50); // 50ms 超时避免死等
          if (Atomics.load(ctrl, 3) === 0) return false; // 被要求停止
        }

        // 2) 写入一帧到 writeIndex
        const wi = Atomics.load(ctrl, 0);
        const base = wi * FRAME_BYTES;

        // ------ 生成一个简单动态图案(渐变+移动方块)------
        for (let y = 0; y < height; y++) {
          for (let x = 0; x < width; x++) {
            const i = base + (y * width + x) * 4;
            // 背景:横向渐变
            bytes[i+0] = (x + t) % 256;     // R
            bytes[i+1] = (y*2) % 256;       // G
            bytes[i+2] = (255 - x) % 256;   // B
            bytes[i+3] = 255;               // A

            // 叠加一个随时间移动的白色小方块
            const s = 32;
            const cx = (t*2) % (width - s);
            const cy = (t) % (height - s);
            if (x >= cx && x < cx+s && y >= cy && y < cy+s) {
              bytes[i+0] = 255; bytes[i+1] = 255; bytes[i+2] = 255;
            }
          }
        }

        // 3) 提交一帧:writeIndex 向前,available++
        Atomics.store(ctrl, 0, (wi + 1) % cap);
        Atomics.add(ctrl, 2, 1);
        // 唤醒在 available 上等待的消费者
        Atomics.notify(ctrl, 2, 1);
        return true;
      }

      // 主循环:~60 FPS 生产
      let t = 0;
      const produceLoop = () => {
        if (Atomics.load(ctrl, 3) === 0) return; // 停止
        produceOne(t++);
        setTimeout(produceLoop, 16);
      };
      produceLoop();
    };
  `;

  const rendererCode = `
    // 消费者:在 OffscreenCanvas 中阻塞等待有帧可用,再绘制
    self.onmessage = (e) => {
      const { canvas, controlSAB, framesSAB, width, height, cap } = e.data;
      const off = canvas;
      const ctx = off.getContext('2d');
      const ctrl = new Int32Array(controlSAB);
      const bytes = new Uint8ClampedArray(framesSAB);
      const FRAME_BYTES = width * height * 4;

      function drawOne() {
        // 1) 若无可用帧,则阻塞等待 available 变化
        while (Atomics.load(ctrl, 2) === 0) {
          Atomics.wait(ctrl, 2, 0, 100); // 100ms 超时防止永久睡眠
          if (Atomics.load(ctrl, 3) === 0) return false; // 停止
        }

        // 2) 取出一帧
        const ri = Atomics.load(ctrl, 1);
        const base = ri * FRAME_BYTES;

        // 为了兼容性,复制到非共享缓冲再创建 ImageData(一些浏览器对 SAB-backed ImageData 有限制)
        const local = new Uint8ClampedArray(FRAME_BYTES);
        local.set(bytes.subarray(base, base + FRAME_BYTES));

        const img = new ImageData(local, width, height);
        ctx.putImageData(img, 0, 0);

        // 3) 消费完成:readIndex 前移,available--
        Atomics.store(ctrl, 1, (ri + 1) % cap);
        Atomics.sub(ctrl, 2, 1);
        // 唤醒可能在等待"缓冲不满"的生产者
        Atomics.notify(ctrl, 2, 1);
        return true;
      }

      // 渲染循环:尽量快地消费(可加节流/时间戳对齐)
      const loop = () => {
        if (Atomics.load(ctrl, 3) === 0) return;
        drawOne();
        // 这里选择 requestAnimationFrame 风格节奏:~60fps
        setTimeout(loop, 16);
      };
      loop();
    };
  `;

  const decoderURL  = URL.createObjectURL(new Blob([decoderCode],  {type:'text/javascript'}));
  const rendererURL = URL.createObjectURL(new Blob([rendererCode], {type:'text/javascript'}));

  // ========================
  // 3) 主线程:创建 Worker 与 OffscreenCanvas
  // ========================
  const canvas = document.getElementById('screen');
  const startBtn = document.getElementById('start');
  const stopBtn  = document.getElementById('stop');
  let decoder, renderer;

  startBtn.onclick = () => {
    if (Atomics.load(ctrl, 3) === 1) return; // 已运行
    Atomics.store(ctrl, 3, 1);               // running = 1

    decoder  = new Worker(decoderURL);
    renderer = new Worker(rendererURL);

    // 把 canvas 交给渲染 Worker
    const offscreen = canvas.transferControlToOffscreen();

    // 传入共享内存与参数(注意把 OffscreenCanvas 置于 transferable 中)
    renderer.postMessage({
      canvas: offscreen, controlSAB, framesSAB,
      width: WIDTH, height: HEIGHT, cap: RING_CAP
    }, [offscreen]);

    decoder.postMessage({
      controlSAB, framesSAB, width: WIDTH, height: HEIGHT, cap: RING_CAP
    });
  };

  stopBtn.onclick = () => {
    if (Atomics.load(ctrl, 3) === 0) return;
    Atomics.store(ctrl, 3, 0);      // 请求停止
    Atomics.notify(ctrl, 2, 10);    // 唤醒阻塞的 wait
    decoder && decoder.terminate();
    renderer && renderer.terminate();
    decoder = renderer = null;
    // 重置索引
    Atomics.store(ctrl, 0, 0);
    Atomics.store(ctrl, 1, 0);
    Atomics.store(ctrl, 2, 0);
  };
})();
</script>

你会看到:点击 Start 后,canvas 中出现平滑变化的"视频"画面(程序合成帧)。这证明了:

  • 不复制像素(共享 SAB)→ 低延迟;
  • Atomics.wait/notify 在 Worker 内阻塞/唤醒 → 低 CPU 占用;
  • 环形缓冲(availablereadIndexwriteIndex)→ 平衡生产/消费速率。

5)拓展与实践建议

  • 接入真实视频 :用 WebCodecs 在解码 Worker 中解码 EncodedVideoChunk,把 VideoFrame 映射/拷到 SAB(或转成 RGBA),其余流程相同。
  • 音视频同步 :在控制区携带 时间戳时钟偏移 ,渲染端按 wall-clock(performance.now())或 AudioContext 时钟对齐。
  • 多生产者/多消费者 :把 available 拆为空槽信号量已填充信号量,或用 ticket lock 控制写入/读取原子性。
  • 帧丢弃策略 :渲染端若落后,可原子地把 readIndex 前跳至最新,丢弃陈旧帧,保持实时性。

6)潜在问题与排错清单

  1. Atomics.wait 只能在 Worker 调用 :主线程会抛异常。主线程宜用 postMessage/事件/RAF 协调。

  2. crossOriginIsolated 要求(浏览器中使用 SAB 必备):

    • 响应头需包含:

      • Cross-Origin-Opener-Policy: same-origin
      • Cross-Origin-Embedder-Policy: require-corp
    • 或其它等效策略确保 window.crossOriginIsolated === true

    • 你可在本地用任意静态服务器加这两行头部(如 Nginx/Express/Cloudflare Pages 等)再访问 video-sab.html

  3. 忙等 vs 阻塞 :若发现 CPU 飙高,检查是否错误地用 while 自旋;应使用 Atomics.wait/notify

  4. 数据对齐Atomics 只能作用于 Int32Array / BigInt64Array 视图的元素(4/8 字节对齐)。

  5. 内存越界 :环形缓冲的 base + FRAME_BYTES 计算要严谨,最好封装成函数并加断言。

  6. 兼容性OffscreenCanvasWebCodecs 在部分环境可能需要开关或降级方案(可退化为主线程绘制 + 非阻塞轮询)。

相关推荐
折果32 分钟前
如何在vue项目中封装自己的全局message组件?一步教会你!
前端·面试
不死鸟.亚历山大.狼崽子36 分钟前
Syntax Error: Error: PostCSS received undefined instead of CSS string
前端·css·postcss
汪子熙36 分钟前
Vite 极速时代的构建范式
前端·javascript
跟橙姐学代码36 分钟前
一文读懂 Python 的 JSON 模块:从零到高手的进阶之路
前端·python
前端小巷子1 小时前
Vue3的渲染秘密:从同步批处理到异步微任务
前端·vue.js·面试
nightunderblackcat2 小时前
新手向:用FastAPI快速构建高性能Web服务
前端·fastapi
小码编匠2 小时前
物联网数据大屏开发效率翻倍:Vue + DataV + ECharts 的标准化模板库
前端·vue.js·echarts
欧阳天风2 小时前
分段渲染加载页面
前端·fcp
艾小码3 小时前
TypeScript在前端的实践:类型系统助力大型应用开发
前端·typescript
今禾3 小时前
前端工程化的范式革命:从 Webpack 的“全量打包”到 Vite 的“按需编译”
前端·webpack·vite