目录
[1.1 并发编程的定义](#1.1 并发编程的定义)
[1.2 并发编程里我们要解决什么](#1.2 并发编程里我们要解决什么)
[2.1 原子性的定义](#2.1 原子性的定义)
[2.2 a++ "非原子"的危害](#2.2 a++ “非原子”的危害)
[2.3 atomic 如何提供原子性](#2.3 atomic 如何提供原子性)
[3.1 数据竞争的定义](#3.1 数据竞争的定义)
[3.2 延用上一节的 counter++ 示例](#3.2 延用上一节的 counter++ 示例)
[3.3 如何从根上避免数据竞争](#3.3 如何从根上避免数据竞争)
[方式一:使用 std::atomic(硬件原子性)](#方式一:使用 std::atomic(硬件原子性))
[方式二:使用 std::mutex(软件互斥锁)](#方式二:使用 std::mutex(软件互斥锁))
[4.1 为什么需要"内存模型"?](#4.1 为什么需要“内存模型”?)
[4.2 C++ 内存模型提供了什么?](#4.2 C++ 内存模型提供了什么?)
[五、atomic vs mutex:什么时候用谁?](#五、atomic vs mutex:什么时候用谁?)
[5.1 性能与应用场景对比](#5.1 性能与应用场景对比)
[5.2 经验性总结](#5.2 经验性总结)
在上一篇学习中,我们已经详细展开了 C++ 中 a++ 的线程安全问题,并且通过 std::atomic 与 std::mutex 看到了两种典型的"解决方案"------硬件级原子操作 和软件级互斥锁。(链接如下)
【C++基础】Day 7:a++ 的线程安全问题 与 std::atomic 全解析-CSDN博客
https://blog.csdn.net/m0_58954356/article/details/155519779?spm=1001.2014.3001.5502【C++基础】Day 7:std::atomic vs mutex -CSDN博客
https://blog.csdn.net/m0_58954356/article/details/155523789?spm=1001.2014.3001.5502但是,要想真正把这块知识吃透,仅靠"记住 a++ 不安全、atomic 可以、mutex 也行"还远远不够。
这篇我准备进一步把底层概念系统梳理一遍,围绕下面四个关键词展开:
-
并发编程(Concurrency)
-
原子性(Atomicity)
-
数据竞争(Race Condition)
-
内存模型(Memory Model)
并结合上一节的 a++ / std::atomic / std::mutex 示例,从现象 → 概念 → 原理 → 工程实践,把 C++ 并发中的基础理论补齐。
一、并发编程:多个线程一起干活的世界
先回顾一下上一节:
我们写了两个线程一起对同一个
counter做counter++,结果居然小于预期的 200000,这就是典型的并发问题。
1.1 并发编程的定义
一句话:
并发编程(Concurrency)就是让多个任务在同一时间段内协同执行。
在 C++ 里,最常见的就是多线程程序:
-
主线程负责主业务
-
子线程负责计算 / IO / 网络 / 控制
-
多个线程之间共享部分数据(如计数器、日志缓冲区、任务队列等)
并发编程关注的问题:
-
多个线程如何访问同一块数据?
-
如何避免互相抢、互相覆盖?
-
如何保证某些操作"不能被打断"?
-
如何在保证正确性的前提下尽量提高性能?
典型现象就是你在上一节看到的:
cpp
// 期望:counter == 200000
// 实际:经常 < 200000,而且每次都不一样
counter++;
这就是并发编程的典型坑。
1.2 并发编程里我们要解决什么
从工程角度看,最重要的就是两个字:
"安全" + "顺序"
-
安全:不会因为多个线程一起读写导致崩溃或错误结果
-
顺序:某些操作之间的先后关系,是不是必须满足?谁先谁后能不能保证?
而想解决这两个问题,就需要下面三个概念:原子性、数据竞争、内存模型------这就是下面几节要讲的东西。
二、原子性:一个操作要么全做,要么不做
上一节我们说:
a++在多线程中不安全,因为它会被拆成 读 → 加一 → 写回 三步。
这其实就是在说:a++ 不是原子操作。
2.1 原子性的定义
原子性(Atomicity) 就是:
一个操作要么全部执行,要么完全不执行,中间不会被打断,也不会被其他线程"看见到一半的状态"。
在 C++ 里:
-
普通的
int自增:非原子 -
std::atomic<int>的自增:原子
1)非原子示例:
cpp
int a = 10;
a++; // 底层:load → add → store,各自可以被打断
2)原子示例:
cpp
std::atomic<int> a{10};
a++; // 底层使用 CPU 原子指令,一次性完成"读-改-写"
2.2 a++ "非原子"的危害
多线程场景下,两条线程执行 a++:
cpp
int a = 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(覆盖) |
本该是:
10 → 11 → 12
实际却变成:
10 → 11 → 11
原因:两条线程都读到了旧值 10,都算出 11,然后互相覆盖。
这就是"非原子操作在并发场景下的灾难"。
2.3 atomic 如何提供原子性
上一节我们已经写过:
cpp
std::atomic<int> counter{0};
void add() {
for (int i = 0; i < 100000; i++)
counter++; // 原子操作
}
这里的 counter++ 底层会变成类似:
-
x86:
lock xadd -
ARM:
ldrex/strex配对 -
RISC-V:
amoadd
这些都是 CPU 提供的硬件级原子操作,一次性完成"读-改-写 ",整个过程对其他线程来说是不可分割的。
atomic = 利用硬件原子指令来实现"真正的原子性"。
三、数据竞争:多线程抢同一块数据的灾难
有了"原子性"的概念,接下来就好理解 " 数据竞争(Race Condition)"了。
3.1 数据竞争的定义
当满足以下条件时,就发生了数据竞争:
-
至少有两个线程
-
访问同一个内存位置(比如同一个变量)
-
其中至少一个在写
-
没有使用任何同步手段(无锁、无 atomic)
一旦发生数据竞争,程序行为就是未定义(Undefined Behavior)。
a++ 的多线程示例,就是教科书级的 race condition。
3.2 延用上一节的 counter++ 示例
错误版本:
cpp
int counter = 0;
void add() {
for (int i = 0; i < 100000; i++)
counter++; // 非原子,存在数据竞争
}
两个线程同时执行:
-
同时读
-
同时加
-
同时写
结果会比 200000 小,而每次运行都可能不一样。
行为未定义 ≠ "有点不准",而是"没人能保证结果正确"。
3.3 如何从根上避免数据竞争
方式一:使用 std::atomic(硬件原子性)
cpp
std::atomic<int> counter{0};
void add() {
for (int i = 0; i < 100000; i++)
counter++; // 原子操作,无数据竞争
}
方式二:使用 std::mutex(软件互斥锁)
cpp
int counter = 0;
std::mutex m;
void add() {
for (int i = 0; i < 100000; i++) {
std::lock_guard<std::mutex> lock(m);
counter++; // 在锁保护下访问,共享变量的读写被串行化
}
}
-
lock_guard构造时m.lock() -
析构时
m.unlock() -
任意时刻只有一个线程能拿到锁
-
自增就从"并行 + 互相覆盖",变成了"串行 + 正确统计"
atomic 用硬件保证"每次 ++ 不互相盖";
mutex 用软件保证"同一时间只有一个人 ++"。
四、内存模型:多线程世界里的"读写规则手册"
前面我们解决了两个问题:
-
操作是否是原子的?
-
多线程会不会出现数据竞争?
但还有一个更隐蔽的问题:读写顺序。
4.1 为什么需要"内存模型"?
现代 CPU 和编译器为了优化性能,会做很多"骚操作":
-
指令重排:把一些语句顺序调换,只要单线程结果不变就行
-
**缓存:**线程 A 写的数据先在自己的 cache 里,线程 B 一段时间内看不到
-
**写缓冲:**写操作可能被延后刷新到内存
单线程下,这些优化是安全且透明的。
但多线程下就会变成灾难:
cpp
ready = true;
data = 42;
在另一个线程眼里,有可能看到的顺序完全不同,甚至永远看不到更新后的值。
为了让多线程程序有"可以推理的行为",C++11 定义了一套 内存模型(Memory Model)。
4.2 C++ 内存模型提供了什么?
简化理解,它主要规定了:
-
哪些操作可以被重排?
-
哪些操作之间必须保持顺序?
-
哪些写对其他线程是"可见的"?什么时候可见?
-
std::atomic的读写有哪些顺序保证?
我们平时最常用的就是:
cpp
std::atomic<int> x{0};
// 线程1
x.store(1, std::memory_order_release);
// 线程2
int v = x.load(std::memory_order_acquire);
release + acquire 这对组合,保证了部分"先后发生(happens-before)"关系,避免了乱序导致的怪异行为。
简单说:内存模型就是规定多线程读写时,"谁能看到谁、按什么顺序看到"的一套规则。
五、atomic vs mutex:什么时候用谁?
这一点其实是上一节的延续,借机在这里给个最终对比。
5.1 性能与应用场景对比
| 方案 | 是否安全 | 底层原理 | 性能 | 是否阻塞 | 适用场景 |
|---|---|---|---|---|---|
普通 int |
❌ | load + add + store 可被打断 | ⭐⭐⭐⭐⭐(快) | 否 | 纯单线程 |
std::atomic |
✅ | CPU 原子指令(lock xadd 等) | ⭐⭐⭐⭐ | 否 | 计数器、自增、标志位、轻量共享变量 |
mutex + int |
✅ | 互斥锁、可能进入内核、阻塞 | ⭐ | 是 | 保护复杂数据结构、多步骤操作 |
5.2 经验性总结
-
能用 atomic 就用 atomic :
简单读写 / 计数 / 标志位,自增自减,适合走硬件原子指令。
-
atomic 搞不定的,就上 mutex :
尤其是涉及多步逻辑 / 复杂数据结构的整体一致性。
例如**(整个区间)**:
cpp
if (vec.empty()) {
vec.push_back(x);
}
这里就必须用 mutex 来保护整个 if + push_back 区间,不可能用 atomic 替代。
六、总结
-
并发编程 :
研究多个线程如何协作、如何共享数据而不出错。
-
原子性 :
一个操作要么全部执行,要么不执行,中途不会被打断。
a++非原子,atomic++是原子。 -
数据竞争 :
多线程在没有同步的前提下同时读写同一内存,
结果不可预测,行为未定义(UB)。
-
内存模型 :
规定多线程读写的顺序、可见性和重排规则,
确保我们写的并发程序有可推理的行为。
在并发世界里,
a++看似简单,其实牵扯到并发编程、原子性、数据竞争和内存模型这四个核心概念。
std::atomic用硬件原子指令解决"操作不可分割"的问题;
std::mutex用互斥锁解决"代码区间只能一个线程进"的问题;二者配合内存模型,才构成了 C++11 之后那套"可依赖的并发语义"。