名词解释:竞态条件、原子操作、零拷贝(附:用 Atomics 修复竞态条件的完整 Web Worker 示例)

竞态条件


竞态条件(Race Condition) 是并发编程中的一个经典问题,指的是:多个线程(或进程、协程等)同时访问共享资源,且最终结果依赖于它们执行的相对时序 。当这种时序不可控时,程序行为变得不可预测、不一致甚至错误

📌 简单说:谁先抢到资源,结果就不同 ------ 这就是"竞态"


🧨 举个生活化的例子

想象两个室友共用一个冰箱,里面只剩 1 盒牛奶

  • 室友 A 打开冰箱,看到有牛奶,决定拿走。
  • 室友 B 同时打开冰箱,也看到有牛奶,也决定拿走。
  • 结果:两人都以为自己拿到了牛奶,但实际上只有一个人能拿到,或者盒子被撕破......

这就是典型的竞态条件:"检查 + 使用"不是原子操作


💻 编程中的经典例子:counter++

javascript 复制代码
// 共享变量
let counter = 0;

// 线程 A 和 线程 B 同时执行:
counter = counter + 1;

可能的执行顺序(非原子):

步骤 线程 A 线程 B
1 读取 counter → 0
2 读取 counter → 0
3 计算 0+1=1
4 计算 0+1=1
5 写入 counter = 1
6 写入 counter = 1

预期结果counter = 2

实际结果counter = 1(数据丢失!)

原因:counter++ 实际是 读 → 改 → 写 三步,不是原子操作


⚠️ 竞态条件的两个必要条件

  1. 多个执行单元并发访问同一资源
  2. 至少有一个是写操作
  3. 访问没有同步机制保护

如果只是读(read-only),通常不会引发竞态。


🌐 在 Web 开发中何时会出现?

虽然 JavaScript 主线程是单线程的,但在以下场景仍可能出现竞态条件:

✅ 场景 1:多个 async/await 操作共享状态

javascript 复制代码
1let balance = 100;

async function withdraw(amount) {
  // 模拟网络延迟
  await delay(100);
  if (balance >= amount) {
    balance -= amount; // ❌ 非原子!
    console.log(`取款 ${amount},余额 ${balance}`);
  }
}

// 同时发起两次取款
withdraw(80); // 可能成功
withdraw(50); // 也可能成功!导致透支

即使没有多线程,异步回调的交错执行也会造成竞态。


✅ 场景 2:SharedArrayBuffer + 多 Worker(真正的多线程)

javascript 复制代码
// 主线程和 Worker 共享一个 Int32Array
const sab = new SharedArrayBuffer(4);
const arr = new Int32Array(sab);

// 主线程:
arr[0]++;

// Worker 中:
arr[0]++;

如果没有使用 Atomics,结果可能是 1 而不是 2!


🔒 如何解决竞态条件?

方法 1:同步机制(Synchronization)

  • 互斥锁(Mutex):确保同一时间只有一个线程能访问临界区
  • 信号量、读写锁:更复杂的控制

JavaScript 中可通过 Atomics.wait / Atomics.notify 模拟锁(但复杂,一般不推荐)。


方法 2:原子操作(Atomic Operations)

使用 Atomics 系列方法保证操作不可分割:

javascript 复制代码
// 安全地加 1
Atomics.add(arr, 0, 1);

方法 3:避免共享状态(推荐!)

  • 使用不可变数据(Immutable Data)
  • 每个任务携带完整上下文
  • 用消息传递代替共享内存(如 Actor 模型)

这正是 Web Worker 的设计哲学:通过 postMessage 通信,而非共享变量


方法 4:事务或队列化

将并发操作串行化:

javascript 复制代码
const queue = [];

function enqueueWithdraw(amount) {
  queue.push(() => {
    if (balance >= amount) {
      balance -= amount;
    }
  });
}

// 用一个定时器或微任务依次执行
setInterval(() => {
  const op = queue.shift();
  op?.();
}, 0);

✅ 总结

项目 说明
定义 多个并发单元对共享资源的访问顺序影响结果
根源 非原子操作 + 缺乏同步
后果 数据不一致、逻辑错误、安全漏洞
解决方案 原子操作、锁、避免共享、队列化
Web 特点 异步回调和 SharedArrayBuffer 是主要风险点

💡 黄金法则
"永远不要假设操作是原子的,除非文档明确说明。"

如果你在开发涉及并发、缓存、计数器、状态管理等功能,务必警惕竞态条件!


原子操作


原子操作(Atomic Operation) 是指在执行过程中 不可被中断 的操作:它要么 完全执行成功 ,要么 完全不执行,不会出现"执行到一半被其他线程打断"的中间状态。

这个概念在 多线程编程并发控制 中至关重要,尤其是在多个线程(或进程)同时访问共享资源时,用来保证数据的一致性和正确性。


🧩 为什么需要原子操作?

举个反例:非原子操作的问题

假设两个线程同时对一个全局变量 counter 执行 counter++

javascript 复制代码
// 初始值:counter = 0

// 线程 A 读取 counter → 0
// 线程 B 读取 counter → 0
// 线程 A 计算 0 + 1 = 1,写回 → counter = 1
// 线程 B 计算 0 + 1 = 1,写回 → counter = 1

预期结果counter = 2
实际结果counter = 1

因为 counter++ 实际上包含 三个步骤 (读 → 改 → 写),不是原子的


✅ 原子操作的特点

  • 不可分割:执行期间不会被调度器切换或被其他线程干扰。
  • 线程安全:多个线程同时执行同一原子操作,结果依然正确。
  • 常用于同步原语:如锁、信号量、无锁数据结构(lock-free data structures)。

🔧 在 JavaScript 中:Atomics 对象

JavaScript 主线程是单线程的,但在使用 SharedArrayBuffer + Web Workers 时,多个线程可以共享同一块内存,这时就需要原子操作来避免竞态条件(race condition)。

⚠️ 注意:出于安全原因(如 Spectre 漏洞),SharedArrayBuffer 默认在很多浏览器中被禁用,除非站点启用 跨域隔离(Cross-Origin-Embedder-Policy + Cross-Origin-Opener-Policy)

示例:使用 Atomics.add() 实现线程安全的计数

javascript 复制代码
// 主线程
const sab = new SharedArrayBuffer(4); // 4 字节 = 1 个 Int32
const int32 = new Int32Array(sab);

// 初始化为 0
int32[0] = 0;

// 创建 Worker 并传入 SharedArrayBuffer
const worker = new Worker('worker.js');
worker.postMessage(sab);
javascript 复制代码
// worker.js
self.onmessage = (e) => {
  const sab = e.data;
  const int32 = new Int32Array(sab);

  // 原子地将 int32[0] 加 1
  Atomics.add(int32, 0, 1);
};
javascript 复制代码
// 主线程也执行一次加 1
Atomics.add(int32, 0, 1);

// 等待 Worker 完成后
setTimeout(() => {
  console.log(int32[0]); // 一定是 2!✅
}, 100);

常用的 Atomics 方法

方法 作用
Atomics.load(ta, index) 原子读取
Atomics.store(ta, index, value) 原子写入
Atomics.add(ta, index, delta) 原子加法
Atomics.sub(ta, index, delta) 原子减法
Atomics.compareExchange(ta, index, expected, replacement) CAS(比较并交换)
Atomics.wait(ta, index, value) / Atomics.wake(ta, index, count) 线程等待/唤醒(类似条件变量)

🌐 其他语言中的原子操作

语言 原子操作支持
C++ std::atomic<int>
Java AtomicInteger, AtomicReference
Rust AtomicUsize, AtomicBool
Go 通过 sync/atomic

它们的核心思想一致:在硬件或运行时层面保证操作的不可分割性(通常利用 CPU 的 CAS 指令)。


✅ 总结

  • 原子操作 = 不可中断的操作
  • 多线程共享内存 场景下防止数据竞争
  • JavaScript 中通过 Atomics + SharedArrayBuffer 实现
  • 常用于高性能并发场景(如游戏引擎、实时音视频、WASM 多线程)

💡 简单记忆:

"原子"就像一颗 indivisible(不可分)的粒子 ------ 要么整个发生,要么根本不发生。


零拷贝


零拷贝(Zero-Copy) 是一种优化数据传输性能的技术 ,其核心思想是:在数据从一个地方传送到另一个地方的过程中,尽可能避免或减少 CPU 对数据的复制操作,从而节省内存带宽、降低 CPU 开销、提升系统吞吐量。


🧠 为什么需要"零拷贝"?

在传统的 I/O 操作中(比如从磁盘读取文件并通过网络发送),数据往往要经过 多次复制多次上下文切换,例如:

❌ 传统方式(有拷贝)

假设你用 Node.js 或 Java 写一个静态文件服务器:

  1. 磁盘 → 内核缓冲区(DMA copy)
  2. 内核缓冲区 → 用户空间缓冲区(CPU copy)
  3. 用户空间缓冲区 → 内核 socket 缓冲区(CPU copy)
  4. 内核 socket 缓冲区 → 网卡(DMA copy)

🔁 共 4 次数据拷贝 + 4 次上下文切换(用户态 ↔ 内核态)

这不仅浪费 CPU 资源,还增加延迟。


✅ 零拷贝如何工作?

零拷贝技术让数据直接从源头(如磁盘)流向目的地(如网卡),全程不经过用户空间,也不被 CPU 复制。

典型实现:sendfile() 系统调用(Linux)

C

cs 复制代码
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
  • 数据路径:
    磁盘 → 内核页缓存 → 网卡

    (全程在内核空间完成)

  • 结果:

    • 0 次 CPU 复制
    • 2 次上下文切换(而不是 4 次)
    • CPU 可以去做其他计算任务

💡 这就是为什么 Nginx、Kafka、Netty 等高性能系统都大量使用零拷贝。


🌐 在 Web/JavaScript 中的"零拷贝"

虽然浏览器 JavaScript 无法直接调用 sendfile(),但在 主线程与 Worker 线程通信 中,有一个非常重要的零拷贝机制:

ArrayBuffer转移(Transfer)

javascript 复制代码
// 主线程
const buffer = new ArrayBuffer(10 * 1024 * 1024); // 10MB
worker.postMessage(buffer, [buffer]); // ← 转移所有权,非复制!
  • 数据没有被复制,只是内存所有权从主线程"移交"给 Worker
  • 原线程中的 buffer.byteLength 变为 0(已失效)
  • 性能极高,适合传输图像、音频、WASM 内存等大块二进制数据

这就是 Web 平台上的"零拷贝"通信!


📊 零拷贝 vs 普通拷贝对比

场景 普通拷贝 零拷贝
数据复制次数 多次(CPU 参与) 0 次(或仅 DMA)
CPU 占用 极低
延迟 较高 更低
适用场景 小数据、简单逻辑 大文件、高频 I/O、实时系统

🛠 常见零拷贝技术汇总

技术 平台/语言 说明
sendfile() / splice() Linux 文件 → socket 直接传输
mmap() + write() Unix-like 内存映射 + 写,仍有一次拷贝
TransmitFile Windows Windows 版本的零拷贝
ArrayBuffer transfer 浏览器 / Node.js (Worker) 线程间零拷贝传递二进制数据
Netty 的 FileRegion Java 基于 sendfile 的零拷贝
Kafka 的日志传输 Java 利用零拷贝高效转发消息

✅ 总结

零拷贝 ≠ 完全没有数据移动 ,而是 避免不必要的 CPU 参与的数据复制

  • 目标:减少 CPU 开销、提升 I/O 吞吐
  • 关键:让硬件(如 DMA 控制器)或内核直接处理数据流转
  • Web 开发者能用的 :通过 postMessage(arrayBuffer, [arrayBuffer]) 实现线程间零拷贝

💡 一句话记住

零拷贝就是"让数据走捷径,别让 CPU 当搬运工"。


Atomics 修复竞态条件的完整 Web Worker 示例


下面是一个 完整的、可运行的 Web 示例,演示:

  • 如何在多个 Web Worker 并发访问共享内存时 产生竞态条件
  • 如何使用 Atomics 修复它,实现 线程安全的计数器

📁 文件结构

Text

复制代码
1race-condition-demo/
2├── index.html
3├── main.js          ← 主线程
4└── worker.js        ← 工作者线程

1️⃣ index.html

html 复制代码
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>竞态条件 vs 原子操作</title>
</head>
<body>
  <h2>竞态条件演示(Web Worker + SharedArrayBuffer)</h2>
  <button id="startUnsafe">启动(不安全:普通 ++)</button>
  <button id="startSafe">启动(安全:Atomics.add)</button>
  <p id="result"></p>

  <script src="main.js"></script>
</body>
</html>

⚠️ 注意:由于安全限制,SharedArrayBuffer 需要启用 跨域隔离(Cross-Origin Isolation)


为简化测试,你可以:

  • 使用本地服务器(如 npx servepython -m http.server
  • 或在支持的环境下(如 Chrome)临时开启标志(不推荐长期)

2️⃣ main.js(主线程)

javascript 复制代码
// 检查 SharedArrayBuffer 是否可用
if (typeof SharedArrayBarray === 'undefined' && typeof SharedArrayBuffer === 'undefined') {
  document.getElementById('result').textContent = '⚠️ 当前环境不支持 SharedArrayBuffer';
}

let sab;
let int32;

function resetCounter() {
  sab = new SharedArrayBuffer(4); // 4 字节 = 1 个 Int32
  int32 = new Int32Array(sab);
  int32[0] = 0; // 初始化为 0
}

function createWorkers(useSafeMode) {
  const workers = [];
  const numWorkers = 4;
  const incrementsPerWorker = 1000;

  for (let i = 0; i < numWorkers; i++) {
    const worker = new Worker('worker.js');
    worker.postMessage({
      sab: sab,
      useSafeMode: useSafeMode,
      increments: incrementsPerWorker
    }, [sab]); // 转移所有权(零拷贝)
    workers.push(worker);
  }

  // 等待所有 Worker 完成
  let finished = 0;
  workers.forEach(w => {
    w.onmessage = () => {
      finished++;
      if (finished === numWorkers) {
        // 所有 Worker 完成后读取最终值
        const finalValue = int32[0];
        const expected = numWorkers * incrementsPerWorker;
        const resultEl = document.getElementById('result');
        if (finalValue === expected) {
          resultEl.innerHTML = `✅ 安全模式:结果正确!${finalValue} / ${expected}`;
        } else {
          resultEl.innerHTML = `❌ 竞态条件!结果错误:${finalValue} / ${expected}`;
        }
      }
    };
  });
}

document.getElementById('startUnsafe').onclick = () => {
  resetCounter();
  createWorkers(false); // 不使用原子操作 → 会出现竞态
};

document.getElementById('startSafe').onclick = () => {
  resetCounter();
  createWorkers(true);  // 使用 Atomics.add → 线程安全
};

3️⃣ worker.js(工作者线程)

javascript 复制代码
self.onmessage = (event) => {
  const { sab, useSafeMode, increments } = event.data;
  const arr = new Int32Array(sab);

  for (let i = 0; i < increments; i++) {
    if (useSafeMode) {
      // ✅ 安全:原子加法
      Atomics.add(arr, 0, 1);
    } else {
      // ❌ 不安全:普通读-改-写(非原子)
      arr[0] = arr[0] + 1;
    }
  }

  // 通知主线程完成
  self.postMessage('done');
};

▶️ 运行效果

  1. 点击 "启动(不安全)"

    • 4 个 Worker 各执行 1000 次 arr[0]++
    • 预期结果:4000
    • 实际结果:通常 < 4000(如 3800、3920...)→ 出现竞态条件!
  2. 点击 "启动(安全)"

    • 使用 Atomics.add(arr, 0, 1)
    • 结果总是 4000 → 线程安全!

🔍 关键点解析

技术 作用
SharedArrayBuffer 多线程共享同一块内存
postMessage(..., [sab]) 转移所有权(零拷贝),避免复制
arr[0] = arr[0] + 1 非原子,三步操作(读/改/写)
Atomics.add(arr, 0, 1) 原子操作,CPU 级别保证不可分割

🛑 注意事项

  • 浏览器安全策略 :现代浏览器默认禁用 SharedArrayBuffer,除非站点启用:

    Http

    javascript 复制代码
    Cross-Origin-Embedder-Policy: require-corp
    Cross-Origin-Opener-Policy: same-origin

    开发时建议用本地服务器(如 npx serve)并确保页面通过 HTTPS 或 localhost 访问。

  • 性能权衡:原子操作比普通操作稍慢,但换来的是正确性。


✅ 总结

这个例子清晰展示了:

  • 竞态条件悄无声息地破坏程序逻辑
  • Atomics 成为多线程 JavaScript 的"安全锁"

如果你正在开发高性能 Web 应用(如游戏、音视频处理、WASM 多线程),理解并正确使用原子操作至关重要。

相关推荐
BestOrNothing_201521 小时前
C++ 并发四件套:并发编程 / 原子性 / 数据竞争 / 内存模型 (全解析)
c++·多线程·并发编程·线程安全·内存模型·原子操作·数据竞争
poemyang4 天前
从硬盘I/O到网络传输:Kafka与RocketMQ读写模型及零拷贝技术深度对比
kafka·零拷贝·消息中间件
无心水18 天前
【分布式利器:Kafka】Kafka基本原理详解:架构、流转机制与高吞吐核心(附实战配置)
分布式·架构·kafka·partition·零拷贝·broker·分布式流处理平台
赖small强1 个月前
Linux 内核 8 类同步机制详解(原理、场景与示例)
linux·信号量·原子操作·自旋锁·内核同步方法·读-写自旋锁·读-写信号量
Qt程序员1 个月前
基于原子操作的 C++ 高并发跳表实现
c++·线程·c/c++·原子操作·无锁编程
fat house cat_2 个月前
为什么RocketMQ选择mmap+write?RocketMQ零拷贝技术深度解析
java·rocketmq·零拷贝
失散132 个月前
分布式专题——26 BIO、NIO编程与直接内存、零拷贝深入辨析
java·分布式·rpc·架构·nio·零拷贝
hour_go2 个月前
C++多线程编程入门实战
c++·并发编程·互斥锁·线程同步·原子操作
奔跑吧邓邓子2 个月前
【C++实战(54)】C++11新特性实战:解锁原子操作与异步编程的奥秘
c++·实战·c++11新特性·原子操作·异步编程