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 在部分环境可能需要开关或降级方案(可退化为主线程绘制 + 非阻塞轮询)。

相关推荐
网络点点滴1 分钟前
前端与后端的区别与联系
前端
EnCi Zheng26 分钟前
M5-markconv自定义CSS样式指南 [特殊字符]
前端·css·python
kyriewen30 分钟前
你的网页慢,用户不说直接走——前端性能监控教你“读心术”
前端·性能优化·监控
广州华水科技30 分钟前
北斗GNSS变形监测在大坝安全监测中的应用与优势分析
前端
前端老石人41 分钟前
前端开发中的 URL 完全指南
开发语言·前端·javascript·css·html
CAE虚拟与现实42 分钟前
五一假期闲来无事,来个前段、后端的说明吧
前端·后端·vtk·three.js·前后端
Sarvartha1 小时前
三目运算符
linux·服务器·前端
晓晨的博客1 小时前
ROS1录制的bag包转换为ROS2格式
前端·chrome
Wect1 小时前
LeetCode 72. 编辑距离:动态规划经典题解
前端·算法·typescript
donecoding1 小时前
别再让 pnpm 跟着 nvm 跑了!独立安装终极指南
前端·node.js·前端工程化