目录
学习日志 | Day 7
第七篇主要系统梳理了 C++ 中 a++ 的线程安全问题,以及 为什么 std::atomic 能彻底解决数据竞争。这篇内容属于线程与原子性这一块的高频八股,工程中也极易踩坑,因此非常值得单独拆开来讲。
主要包括:
-
为什么 a++ 不安全?它到底被编译成什么?
-
为什么 a++ 会在多线程中“只加一次”?
-
数据竞争(race condition)在底层如何发生?
-
std::atomic 为什么能保证线程安全?
-
atomic 内部的 CPU 原子指令到底解决了什么?
-
atomic vs mutex 的对比与使用场景
-
完整代码示例 + 精确解析
希望通过这篇,真正搞清 “读-改-写” 的底层细节、原子性与内存模型的关系、atomic 的优势等关键机制,彻底理解面试中关于 a++ 的所有高频问题。
一、 简要回答
a++ 不是线程安全的,因为它会被编译成 读 → 加一 → 写 三条独立指令,多个线程会互相覆盖写入,产生数据竞争。
要保证线程安全,需要使用 std::atomic<T>,其 fetch_add() 或 operator++ 都是 原子操作,不会被打断,没有数据竞争。
二、详细解释
2.1 为什么 a++ 不安全
a++ 并不是一个“原子操作”。
虽然你写了一句:
a++;
但 CPU 一定会拆成 三条指令:
-
load(读内存,把 a 放入寄存器)
-
add(寄存器值 +1)
-
store(写回内存)
也就是:
temp = a; // load
temp = temp+1; // add
a = temp; // store
这三步之间任何一步都可能被打断。
因此,a++ 在多线程环境中不是原子的,会发生数据竞争。
2.2 多线程为什么会“只加一次”
假设:
int a = 10;
两个线程 T1/T2 同时执行 a++,本来应该得到:
a = 12
但结果经常变成:
a = 11
2.3 根本原因(表格)
两个线程“都读到了旧值 10”,最终互相覆盖。
下面是最经典的交错执行流程
| 时间 | T1 操作 | T2 操作 | 内存 a |
|---|---|---|---|
| t0 | load → 10 | 10 | |
| t1 | load → 10 | 10 | |
| t2 | add → 11 | 10 | |
| t3 | add → 11 | 10 | |
| t4 | store 11 | 11 | |
| t5 | store 11 | 11(覆盖) |
2.4 解释
-
T1 读取 a = 10
-
T2 也读取 a = 10(因为 T1 还没写回去)
-
两个线程分别计算出 11
-
两个线程分别写回 11
-
T2 的写入覆盖了 T1 的写入
最终只得到 11。
这就是 a++ 在多线程中“不安全”的本质原因。
2.5 解决方法(使用 std::atomic)
C++11 引入了 std::atomic<T>:
std::atomic<int> a{0};
a++; // 原子自增
atomic 内部使用 CPU 指令级的锁 / 内存序保证,确保:
-
读-改-写是一个不可分割的动作(真正 atomic)
-
不会被打断
-
不会被其他线程覆盖
-
CPU 内使用特定“原子指令”,性能远高于 mutex
2.6 为什么 atomic 不需要加锁
因为它利用了 CPU 原生的原子加法指令,例如:
-
x86:
LOCK XADD -
ARM:
LDREX/STREX -
RISC-V:
AMOADD
这些底层指令一次性完成“读-改-写”,比 mutex 更轻量。
3. 图表总结
3.1 a++ 与 atomic++ 指令流程对比
| 操作 | 是否线程安全 | 底层指令 | 是否可能被打断 | 适用场景 |
|---|---|---|---|---|
a++(普通 int) | ❌ 不安全 | load → add → store | 会 | 多线程禁止 |
++a(普通 int) | ❌ 不安全 | load → add → store | 会 | 多线程禁止 |
std::atomic<int> a++ | ✅ 安全 | atomic_fetch_add | 不会 | 推荐 |
mutex + a++ | ✅ 安全 | lock → load → add → store → unlock | 不会 | 操作很重时 |
3.2 atomic 常用 API 对比
| 写法 | 等价底层 | 说明 |
|---|---|---|
a++ | fetch_add(1) | 返回旧值 |
++a | fetch_add(1)+1 | 返回新值 |
a.fetch_add(1, std::memory_order_relaxed) | 原子加一,宽松顺序 | 最高性能 |
4. 代码示例及解析
4.1 普通 int 自增会丢数据(❌)
#include <thread>
#include <iostream>
int counter = 0;
void add() {
for (int i = 0; i < 100000; i++)
counter++; // 竞态
}
int main() {
std::thread t1(add), t2(add);
t1.join(); t2.join();
std::cout << counter << std::endl; // 结果 < 200000
}
1)逐行解释
int counter = 0;
-
定义一个全局变量
counter,初始为 0。 -
全局是为了让两个线程都能访问到同一个变量。
void add() {
for (int i = 0; i < 100000; i++)
counter++; // 竞态
}
-
这是线程要执行的函数
add:-
循环 100000 次
-
每次执行
counter++
-
-
理论上:一个线程执行完 → counter 加 100000
两个线程一起执行 → 应该加 200000
int main() {
std::thread t1(add), t2(add);
t1.join(); t2.join();
std::cout << counter << std::endl;
}
-
std::thread t1(add), t2(add);-
创建两个线程
t1和t2 -
它们同时去执行
add()函数,也就是同时对counter做 100000 次++
-
-
t1.join(); t2.join();-
主线程在这里等待
t1和t2都执行完。 -
没有 join 的话,主线程可能先结束,程序直接退出,看不到结果。
-
-
std::cout << counter << std::endl;-
打印最终的
counter。
-
期望:
每个线程 +100000,两个线程 → 200000
现实:
很多时候会 < 200000,甚至每次运行都不一样。
原因就是:counter++ 在多线程中有 数据竞争(race condition)。
2)关键点
这一句代码其实是“3 条指令”,会互相覆盖
counter++;
在 CPU 看来是:
temp = counter; // 1. 读
temp = temp + 1; // 2. 算
counter = temp; // 3. 写
两个线程同时跑这个三步,就会出现前面说的场景:
-
两个线程都读到同一个旧值
-
都算出同一个结果
-
都写回去
-
后写的覆盖先写的
于是“加了两次”,却只生效一次,这就是为什么最终 counter 远小于 200000。
并且从标准角度讲:
多线程同时读写一个非原子变量(没有同步手段) → 未定义行为
不只是“值小一点”,而是整个程序行为都不受保证。
4.2 atomic 自增不会丢数据(✅ )
#include <atomic>
#include <thread>
#include <iostream>
std::atomic<int> counter{0};
void add() {
for (int i = 0; i < 100000; i++)
counter++; // 原子操作
}
int main() {
std::thread t1(add), t2(add);
t1.join(); t2.join();
std::cout << counter << std::endl; // 200000
}
1)逐行解释
#include <atomic>
-
引入 C++11 的原子库,里面有
std::atomic<T>。
std::atomic<int> counter{0};
-
定义一个原子整型
counter,初始为 0。 -
这行和
int counter = 0;看着差不多,但本质完全不同:-
普通
int:读/写都可能被多线程打断,存在数据竞争 -
std::atomic<int>:读/写/自增等操作都是原子的,不会产生数据竞争
-
void add() {
for (int i = 0; i < 100000; i++)
counter++; // 原子操作
}
-
这里的
counter++调用的是std::atomic<int>::operator++ -
标准保证:这个
++是一个完整的原子操作:-
内部会使用 CPU 的原子指令(比如
lock xadd) -
把“读-改-写”合成一个不可分割的步骤
-
-
多个线程同时做
counter++:-
线程 A 做一次:从 0 → 1
-
线程 B 做一次:从 1 → 2
-
线程 C 做一次:从 2 → 3
-
……无论怎么交错,每一次 ++ 都一定会对最终结果生效
-
int main() {
std::thread t1(add), t2(add);
t1.join(); t2.join();
std::cout << counter << std::endl; // 200000
}
-
整体结构和前面完全一样:
-
两个线程各自执行
add(),每个做 100000 次counter++ -
一共 200000 次原子自增
-
-
因为每一次自增都是原子的,所以不会丢,结果稳定为:
200000
4.3 两个版本的本质区别
| 点 | 普通 int 版本 | std::atomic<int> 版本 |
|---|---|---|
| 变量类型 | int | std::atomic<int> |
counter++ 的本质 | load → add → store 三步,可能被线程交叉打断 | 使用 CPU 原子指令,一步完成读-改-写 |
| 多线程行为 | 有数据竞争,未定义,结果 < 200000 且不稳定 | 无数据竞争,结果稳定为 200000 |
| 标准角度 | 未定义行为(UB) | 行为定义良好(well-defined) |
总结:
换成 atomic 并不是“语法糖”,而是用硬件原子指令把那三步“粘成了一步”。
5. 面试常问
Q1:为什么 a++ 不是原子操作?
因为 a++ 底层拆成 load/add/store 多条指令,线程间会互相覆盖(数据竞争)。
Q2:atomic++ 是如何做到原子的?
通过 CPU 的原子指令(如 lock xadd / ldrex/strex)实现一次性“读-改-写”。
Q3:atomic 比 mutex 更快吗?
通常快很多,因为 atomic 不会进入内核态,不会引起上下文切换。
Q4:atomic 能替代所有锁吗?
不能。atomic 只能保证读写原子性,不能保护复杂结构(如 vector 的 push_back)。
六、总结
a++ 在多线程中一定不安全;std::atomic 才能提供“读-改-写”不可分割的真正原子性。

418

被折叠的 条评论
为什么被折叠?



