竞态条件
竞态条件(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++实际是 读 → 改 → 写 三步,不是原子操作。
⚠️ 竞态条件的两个必要条件
- 多个执行单元并发访问同一资源
- 至少有一个是写操作
- 访问没有同步机制保护
如果只是读(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 写一个静态文件服务器:
- 磁盘 → 内核缓冲区(DMA copy)
- 内核缓冲区 → 用户空间缓冲区(CPU copy)
- 用户空间缓冲区 → 内核 socket 缓冲区(CPU copy)
- 内核 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 serve、python -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');
};
▶️ 运行效果
-
点击 "启动(不安全)":
- 4 个 Worker 各执行 1000 次
arr[0]++ - 预期结果:4000
- 实际结果:通常 < 4000(如 3800、3920...)→ 出现竞态条件!
- 4 个 Worker 各执行 1000 次
-
点击 "启动(安全)":
- 使用
Atomics.add(arr, 0, 1) - 结果总是 4000 → 线程安全!
- 使用
🔍 关键点解析
| 技术 | 作用 |
|---|---|
SharedArrayBuffer |
多线程共享同一块内存 |
postMessage(..., [sab]) |
转移所有权(零拷贝),避免复制 |
arr[0] = arr[0] + 1 |
非原子,三步操作(读/改/写) |
Atomics.add(arr, 0, 1) |
原子操作,CPU 级别保证不可分割 |
🛑 注意事项
-
浏览器安全策略 :现代浏览器默认禁用
SharedArrayBuffer,除非站点启用:Http
javascriptCross-Origin-Embedder-Policy: require-corp Cross-Origin-Opener-Policy: same-origin开发时建议用本地服务器(如
npx serve)并确保页面通过 HTTPS 或 localhost 访问。 -
性能权衡:原子操作比普通操作稍慢,但换来的是正确性。
✅ 总结
这个例子清晰展示了:
- 竞态条件悄无声息地破坏程序逻辑
Atomics成为多线程 JavaScript 的"安全锁"
如果你正在开发高性能 Web 应用(如游戏、音视频处理、WASM 多线程),理解并正确使用原子操作至关重要。