竞态条件


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

简单说:谁先抢到资源,结果就不同 —— 这就是“竞态”


举个生活化的例子

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

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

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


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

// 共享变量
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 操作共享状态

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(真正的多线程)

// 主线程和 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 系列方法保证操作不可分割:

// 安全地加 1
Atomics.add(arr, 0, 1);

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

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

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


方法 4:事务或队列化

将并发操作串行化

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++

// 初始值: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() 实现线程安全的计数

// 主线程
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);
// worker.js
self.onmessage = (e) => {
  const sab = e.data;
  const int32 = new Int32Array(sab);
  // 原子地将 int32[0] 加 1
  Atomics.add(int32, 0, 1);
};
// 主线程也执行一次加 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>
JavaAtomicIntegerAtomicReference
RustAtomicUsizeAtomicBool 等
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

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)

// 主线程
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内存映射 + 写,仍有一次拷贝
TransmitFileWindowsWindows 版本的零拷贝
ArrayBuffer transfer浏览器 / Node.js (Worker)线程间零拷贝传递二进制数据
Netty 的 FileRegionJava基于 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




  
  竞态条件 vs 原子操作


  

竞态条件演示(Web Worker + SharedArrayBuffer)

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

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


为简化测试,你可以:

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

2️⃣ main.js(主线程)

// 检查 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(工作者线程)

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
    Cross-Origin-Embedder-Policy: require-corp
    Cross-Origin-Opener-Policy: same-origin

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

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


✅ 总结

这个例子清晰展示了:

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

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