并发中的「原子操作」
1. 我们的直觉 vs. 更精确的定义
在并发编程中,我们常说**「原子操作(Atomic Operation)」**是不可分割的。
-
我们的直觉理解:
原子 = 不可拆分 = 必须完整执行 = 中间不能被任何东西打断。
这个直觉大体上是对的,它抓住了「不可再分」的核心。 -
我们需要厘清的误区:
「不可打断」并不等于「CPU 在执行这段逻辑时绝对不能切换线程或干别的」。
现代多核 CPU 的调度非常复杂,我们并不需要要求「整个世界在执行期间完全静止」。 -
最精确的理解:对其他线程「要么全看见,要么全看不见」
原子操作的核心保证是:
对同一个内存位置的操作,从其他线程的观察视角来看,它是瞬间完成的——世界上没有任何人能观察到它「做了一半」的中间状态。
换句话说:原子性保证的是「内存状态的可见性」,而不是「CPU 执行时间的绝对独占」。
2. 为什么需要原子性?看一个普通操作的反例
平时我们写一行简单的计数代码:
x = x + 1;
在底层,CPU 并不是瞬间完成它的,而是拆成了 读、改、写 三步:
1. 读:把 x 从内存读到 CPU 寄存器 a
2. 改:在寄存器里算 b = a + 1
3. 写:把结果 b 写回内存 x
如果我们有两个线程(线程 A 和线程 B)同时执行这段代码,问题就来了:
初始值:x = 0
时间线:
1. 线程 A:读内存 x → 拿到 0
2. 线程 A:计算 0 + 1 = 1 ←(注意:此时还没写回内存!)
3. 线程 B:读内存 x → 读到的依然是 0 (线程 B 撞见了中间状态!)
4. 线程 B:计算 0 + 1 = 1
5. 线程 A:把 1 写回内存 x → x 变成 1
6. 线程 B:把 1 写回内存 x → x 依然是 1
结果: 两个线程各自加了一次,最终 x 应该是 2,但实际变成了 1,丢失了一次更新。
根本原因: 普通的「读-改-写」暴露了中间状态,其他线程插到了「读」与「写」的缝隙之间。
3. 原子操作是如何解决这个问题的?
如果把上面的累加换成原子操作(例如 x.fetch_add(1)):
底层 CPU 会把「读、改、写」这三个动作焊死成一条逻辑整体。
- 对于其他线程来说:
- 要么在操作开始前去读,读到完整的 旧值
0; - 要么在操作结束后去读,读到完整的 新值
1; - 绝对不可能在半路插队,读到任何「中间态」。
- 要么在操作开始前去读,读到完整的 旧值
4. 硬件底层是怎么帮我们做到的?
硬件层面的实现非常巧妙,我们只需要了解核心机制:
- 原子指令: 现代 CPU(如 x86、ARM)提供了专门的原子汇编指令(例如 x86 的
LOCK XADD、LOCK CMPXCHG)。 - 总线锁 / 缓存锁(Cache Locking): 当 CPU 执行带
LOCK语义的指令时,硬件会在极短的时间内锁住这块内存所在的缓存行(Cache Line)。 - 效果: 在这一瞬间,其他 CPU 核心无法读写这同一块内存,只能排队等待。等当前核心完成「读-改-写」并释放锁后,其他核心才能看到最新的结果。
对我们写代码的人来说,这个硬件独占过程是瞬间且完全透明的。
5. 概念认知对照表
为了避免混淆,我们可以用下表来校准我们的概念:
| 我们的日常说法 | 精准的技术含义 | 是否准确 |
|---|---|---|
| 不可分割 | 操作是最小逻辑单位,不能拆开执行 | |
| 其他操作不可插入 | 对同一内存地址的读写,绝无法插入到此操作中间 | |
| 必须完整执行 | 要么执行成功,要么失败回滚,不会停在半路留下半成品 | |
| CPU 绝不切换上下文 | CPU 可能会调度,但其他线程绝不会看到半成品数据 | ⚠️ 需微调 |
6. 核心结论
原子操作 = 不可分割的整体
它的本质不是「禁止 CPU 去干别的」,而是**「禁止让任何其他线程看到未完成的中间状态」**。
只要记住:对同一个变量的原子操作,对外只有「做完了」和「还没做」,没有「正在做一半」。 这就是我们在并发编程中依赖它的最大底气。