【C++基础】Day 7:a++ 的线程安全问题 与 std::atomic 全解析

目录

学习日志 | Day 7

一、 简要回答

二、详细解释

2.1 为什么 a++ 不安全

2.2 多线程为什么会“只加一次”

2.3 根本原因(表格)

2.4 解释

2.5 解决方法(使用 std::atomic)

2.6 为什么 atomic 不需要加锁

3. 图表总结

3.1 a++ 与 atomic++ 指令流程对比

3.2 atomic 常用 API 对比

4. 代码示例及解析

4.1 普通 int 自增会丢数据(❌)

1)逐行解释

2)关键点

4.2 atomic 自增不会丢数据(✅ )

1)逐行解释

4.3 两个版本的本质区别

5. 面试常问

Q1:为什么 a++ 不是原子操作?

Q2:atomic++ 是如何做到原子的?

Q3:atomic 比 mutex 更快吗?

Q4:atomic 能替代所有锁吗?

六、总结


学习日志 | 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 一定会拆成 三条指令

  1. load(读内存,把 a 放入寄存器)

  2. add(寄存器值 +1)

  3. 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
t0load → 1010
t1load → 1010
t2add → 1110
t3add → 1110
t4store 1111
t5store 1111(覆盖)

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)返回旧值
++afetch_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);

    • 创建两个线程 t1t2

    • 它们同时去执行 add() 函数,也就是同时对 counter 做 100000 次 ++

  • t1.join(); t2.join();

    • 主线程在这里等待 t1t2 都执行完

    • 没有 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. 写

两个线程同时跑这个三步,就会出现前面说的场景:

  1. 两个线程都读到同一个旧值

  2. 都算出同一个结果

  3. 都写回去

  4. 后写的覆盖先写的

于是“加了两次”,却只生效一次,这就是为什么最终 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> 版本
变量类型intstd::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 才能提供“读-改-写”不可分割的真正原子性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值