原子类型与内存模型基础
读完能解释原子操作、单个原子对象的修改顺序与数据竞争,区分原子语义和无锁实现,并看懂 std::atomic 家族与 CAS。
学习目标
- 能解释原子操作与修改顺序分别保证什么,并指出修改顺序只属于单个原子对象
- 能区分数据竞争的语言级未定义行为与硬件撕裂现象,并判断原子性、内存顺序和无锁实现为何不能混为一谈
- 能回答这个检验问题:两个线程同时、非原子地读写同一个
int,C++ 标准为什么直接判它是未定义行为,而不是「结果不确定但至少不崩」?
机制总览
原子对象的修改顺序与 CAS 重试
- 1
选择原子对象
并发读写同一内存位置时,用合适的 atomic 类型消除数据竞争。
- 2
执行读改写
fetch_add 等原子读改写在单一修改顺序中占一个位置。
- 3
处理 CAS
compare_exchange 失败会更新 expected,weak 允许伪失败并通常循环。
章级决策实验
原子对象的修改顺序与 CAS 重试
切换原子操作阶段,区分单对象原子性、修改顺序和无锁实现。
选择推理阶段
当前阶段 · 选择原子对象
并发读写同一内存位置时,用合适的 atomic 类型消除数据竞争。
可核验证据
TSan、类型声明与访问点清单。
std::atomic 保证操作语义,不自动保证整个算法正确或实现无锁;CAS 失败路径同样属于协议。
失效—证据矩阵
原子对象的修改顺序与 CAS 重试
选择原子对象
典型失效
普通变量并发读写触发未定义行为,而不只是偶尔撕裂。
核验证据
TSan、类型声明与访问点清单。
执行读改写
典型失效
load 后计算再 store 被误当成原子复合更新。
核验证据
丢失更新测试与 modification order 推导。
处理 CAS
典型失效
失败后仍用旧 expected,或假设 atomic 一定 lock-free。
核验证据
重试次数、is_lock_free 与状态不变量。
从一笔不能写一半的订单开始
后厨墙上挂着一块订单板,5 号桌的菜量写在上面。现在你要把它从「3 份」改成「12 份」。可这块板子有点怪:数字得分两笔写——先擦掉十位写上「1」,再擦掉个位写上「2」。就在你写完十位、还没写个位的那一瞬,另一个厨师扭头瞄了一眼板子,看到的是「13 份」——一个你压根没打算写、谁也没真正下过的数。他照着这个乱数去备料,全乱套了。
这一章要解决的,正是「改一个值的过程中,别让另一个人看到改了一半的样子」。我们想要的是这样一种改动:要么对方看到的是改之前的旧数(3),要么是改之后的新数(12),绝不会是中间那个谁都没写过的乱码。一笔落下、要么全有、要么全无。
没有这套保证会怎样?多个厨师同时对同一块板子又读又写,迟早有人撞见「写一半」的瞬间,照着乱码干活——轻则备错料,重则整个后厨的逻辑全崩。这一章就教你怎么让这种「关键的一笔」变得不可分割、谁也插不进缝。
对象、内存位置、修改顺序:账目的官方台账
要讲清楚原子,得先有一套描述「内存里发生了什么」的语言,这就是 C++ 的内存模型。它把一切归结到「对象」和它占的「内存位置」上。
在 C++ 里,↡标量对象,或相邻非零宽度位域组成的最大连续序列。不同内存位置可被不同线程并发访问;同一位置的冲突访问则受数据竞争规则约束。 是并发规则判断冲突的基本单位。一个标量对象占一个内存位置;相邻的非零宽度位域可能共享一个位置,零宽位域会切开序列。不同数组元素是不同内存位置,即使它们物理上紧挨着。
对每一个原子对象,所有原子写和原子读改写存在一个唯一全序,叫 ↡针对单个原子对象,其所有原子写与原子读改写形成的唯一全序。该顺序必须满足 happens-before 约束,但不等于跨多个对象的全局总序。。它像这一条账目的官方流水:所有线程都必须与同一条顺序相容。普通非原子对象若发生无同步冲突访问,程序已经有数据竞争,不能先假设它也有一条可供推演的合法修改顺序。
关键限定是单个原子对象。x 有 x 的修改顺序,y 有 y 的修改顺序;两条流水之间是否建立可见性,要看下一页的 memory order、synchronizes-with 和 happens-before。下面的动画只演示一个原子 x 的修改顺序:
猜一猜:三个线程各自往同一个变量 x 上写了好几笔,动手先后乱成一团。那么「x 被改成 5 这一笔,到底在 x 被改成 8 那一笔之前还是之后」——三个线程的看法会不会有分歧?先想一下,再单步看。
第 1 / 6 步 · 厨师1 写 x=1——这笔最先落账,排进全序第 1 位
三个线程乱序写同一个原子变量 x,但每写一笔就落到下方唯一一条全序线上——这条「修改顺序」所有线程看法一致。注意这是单变量全序,不是跨变量内存序。可暂停、单步、拖进度。
看清楚了:无论三个线程动手多乱,对 x 这一个变量的所有写入,最终都落到下方唯一一条全序线上;这条「流水台账」所有线程看法一致。修改顺序是内存模型给你的第一条地基。
为什么要原子:非原子并发读写 = 未定义行为
有了内存模型的语言,就能精确地说清开篇那个「写一半被看见」的灾难了。
两个不同线程的访问发生冲突、至少一个是写、至少一个是非原子访问,且两者之间没有 happens-before,就发生 ↡不同线程对同一内存位置的冲突访问中至少一个是写、至少一个是非原子操作,而且彼此没有 happens-before。C++ 规定存在数据竞争的程序行为未定义。。两个原子操作即使并发也不构成数据竞争;两次普通访问若由 mutex 等同步建立顺序,同样没有数据竞争。未定义行为不是一组“随机但有限”的结果,编译器可以据此重排、合并或删除代码。
↡一次底层读观察到某个多步写入的不同部分,拼出从未整体写入过的值。它是部分平台上非原子访问可能出现的硬件现象,但不是 C++ 将数据竞争定义为未定义行为的唯一原因。
是一种可能的底层现象,例如某平台分两步写宽对象,读者夹在中间取得“高位新、低位旧”。但即使目标机器保证对齐
int 的单次加载不会撕裂,无同步冲突访问在 C++
层仍是数据竞争;优化器行为才是不能靠硬件直觉推演的关键。
↡不可分割的操作:从任何其他线程的角度看,它要么完全没发生、要么已经全部完成,绝不会被观察到「做了一半」的中间状态。C++ 里用 std::atomic 类型提供原子操作。一个原子操作天然不会和别的原子操作构成数据竞争。 就是来提供不可分割访问的。原子 load、store 和读改写不会被观察成半个操作,同一原子对象上的原子访问不会彼此形成数据竞争。但把每一步都写成原子操作,不会自动让多步业务逻辑成为一个事务,也不会自动发布其他普通数据。下面的对照动画只展示“单次访问是否可能撕裂”这一维: Demo:
猜一猜:把一个普通的 64 位变量改成原子类型后,「线程 A 正写、线程 B 同时读」这种情况,线程 B 还可能读到「高位新、低位旧」的撕裂乱码吗?先想,再播放看答案。
第 1 / 7 步 · 非原子:线程 A 先把高 32 位写成新值(写一半,留了个缝)
上区:非原子变量写一半被读 → 撕裂乱码(UB);下区:原子变量一笔写完,读到的要么全旧、要么全新。可暂停、单步、拖进度。
上区是某些硬件上可能发生的撕裂示意;真实 C++ 数据竞争的行为范围更大。下区表达原子访问不会返回某次写入的半成品,但读到哪一笔合法修改仍受修改顺序和内存顺序约束。
原子类型家族:从最简的 atomic_flag 说起
C++ 标准库把原子能力都放在 <atomic> 头文件里。先认识其中最简单的一个。
↡C++ 里最基础、且标准保证一定无锁的原子类型。它只有两个状态(已置位 / 未置位),只支持两个操作:test_and_set(原子地置位并返回它之前是不是已置位)和 clear(清位)。功能极简,常用来搭一个最朴素的自旋锁。
是最朴素的原子类型:它只有「置位 /
未置位」两种状态,只支持两个动作——test_and_set(原子地把它置位,并返回它之前是不是已经置位)和
clear(清位)。别看它简陋,正因为它一定无锁,可以用它搭一个最简单的自旋锁(后面代码会演示)。
更常用的是 std::atomic<bool>:比 atomic_flag 好用得多,能直接读、直接写、还能做后面要讲的 CAS。它和 atomic_flag 是这个家族里专门处理「真假开关」的两员。
更宽的原子家族与 is_lock_free
家族远不止开关。std::atomic<T*> 是原子指针(原子地读写一个指针,还支持原子地让指针前后挪)。原子整型(如 std::atomic<int>、std::atomic<long>)除了普通读写,还提供 fetch_add、fetch_sub 这类「原子地加一个数、并返回加之前的旧值」的操作——这正是多线程安全计数器的标准做法。更一般地,std::atomic<T> 对任何↡一种类型,它的对象可以仅靠「逐字节拷贝内存」就完成复制(没有自定义拷贝逻辑、没有虚函数、没有需要特殊处理的成员),且没有非平凡的析构。简单的内置类型和只含这类成员的结构体都满足。只有可平凡复制的类型才能放进 std::atomic。的类型 T 都能用(比如一个只含几个 int 的小结构体)。
但 std::atomic<T> 不保证一定无锁。小整数和指针在常见目标上通常映射到硬件原子指令,却仍不能跨平台假定;较大或不受硬件直接支持的类型可以由库用内部锁实现。语义原子性不变,进展与性能特征不同。用 ↡std::atomic 的 is_lock_free 查询当前对象的原子操作是否无锁;C++17 的 is_always_lock_free 是该特化在所有对象上恒为无锁的编译期常量。 查询目标实现:
compare_exchange:看一眼是不是我以为的数,是才改
原子家族里还有一个核心操作,它是后续无锁编程的基石,这里先建立概念、ch6 / ch8 再深入。这就是 ↡compare-and-swap(比较并交换),C++ 里写作 compare_exchange_weak / compare_exchange_strong。它在一个原子操作里做:比较原子变量的当前值是否等于 expected——相等就把它换成 desired 并返回 true(成功),不等就把当前真实值写回 expected 并返回 false(失败,调用方据此重试)。是大多数无锁算法的核心原语。weak 版允许「伪失败」(明明相等也偶尔返回 false),所以通常配重试循环用;strong 版不会伪失败。。
它的两个成员函数是 compare_exchange_weak 和 compare_exchange_strong,签名形如 compare_exchange_weak(expected, desired)。一句话讲清它在干嘛:看一眼账上还是不是我以为的那个数(expected),是才把它改成新数(desired),不是就把账上真实的数记下来、回头重看一次。整个「比较 + 交换」在一个原子操作里一气呵成,别的线程绝不会看到中间态——又是「要么全做、要么全不做」。下面这张动画把它的成功支和失败支分开演:
猜一猜:
compare_exchange_weak(expected, desired)发现「当前值 ≠ expected」时(说明账被别人动过了),它会做什么——直接什么都不动、还是顺手帮你把expected更新成真实值好让你重试?先想,再单步看失败支。
第 1 / 6 步 · ① 读出原子变量当前值 cur——先看一眼账上现在到底是多少
读 cur → 比较 cur==expected:相等就换成 desired 返回 true(成功);不等就把真实 cur 回填进 expected 返回 false(失败→重试)。比较与交换在一个原子操作里完成。可暂停、单步、拖进度。
看明白两条路:相等就把值换成 desired、返回 true(成功);不等就把当前真实值回填进 expected、返回 false(失败)——这样调用方拿着更新后的 expected 再试一次,反复重试直到成功。这套「读—比较—(不行就)重来」正是无锁算法的核心套路,后面章节会反复用到。
上手玩一玩这三张图
这三张图都是可操作的教具——单步、暂停、拖进度,配合下一节代码一起玩,比读十遍文字管用:
- 撕裂读 vs 原子
<TornReadDiagram />(主图):重点单步盯上区第②③拍——线程 A 才写完高位、线程 B 就在缝里读,结果拿到「高新+低旧」的红色乱码(这就是未定义行为的真身)。再看下区:原子写一笔到底,读者要么全旧、要么全新。把这两段对照看透,「为什么非原子并发读写是 UB」就懂了。 - 修改顺序全序
<ModificationOrderDiagram />:盯下方那条全序线——三个线程怎么乱写,对 x 这一个变量的所有写入都排成唯一一串、所有线程看法一致。记住它是单变量全序,别和跨变量的内存序混。 - CAS 比较并交换
<CASConceptDiagram />:分别走一遍成功支(相等→换值→true)和失败支(不等→回填 expected→false→重试),体会「比较和交换在一个原子操作里完成」这件事。
玩熟这三张图,下一节每一句 test_and_set / fetch_add / is_lock_free / compare_exchange_weak 你都能对上图里某个动作。
代码:把原子操作一段段写出来
atomic_flag 搭一个最简自旋锁
std::atomic_flag 只有 test_and_set / clear 两招,却刚好够搭一个最朴素的锁。test_and_set 原子地置位并返回之前的状态——这正是「抢锁」的关键:
#include <atomic>
class SpinlockMutex {
std::atomic_flag flag = ATOMIC_FLAG_INIT; // 必须这样初始化为「未置位」
public:
void lock() {
// test_and_set 返回旧值:旧值为 true 说明锁已被别人占着,就原地空转重试
while (flag.test_and_set(std::memory_order_acquire)) {
// 自旋:什么都不做,反复试,直到抢到锁(旧值为 false)
}
}
void unlock() {
flag.clear(std::memory_order_release); // 清位 = 放锁
}
};test_and_set 把「读旧值 + 置位」打包成一个原子操作,所以两个线程同时抢锁时,只有一个会拿到「旧值是 false」(抢到),另一个拿到「旧值是 true」(继续自旋)——绝不会两个都以为自己抢到了。这就是原子操作做并发同步的最小例子。
这只是语义演示,不是生产锁模板:循环会持续占用 CPU,没有公平性保证,持锁线程被调度出去时其他线程仍会空转。除非临界区极短、目标平台与退避策略都经过测量,优先使用 std::mutex。
原子整型计数器:fetch_add
多线程安全地给一个计数器加一,靠的不是 ++counter,而是原子的 fetch_add:
#include <atomic>
std::atomic<int> counter{0}; // 原子整型,初值 0
int bump() {
// fetch_add 原子地「加 1 并返回加之前的旧值」——整个加法不可分割
int old = counter.fetch_add(1, std::memory_order_relaxed);
return old; // 返回这次加之前的值
}fetch_add(1) 把「读—加—写回」三步压成一个原子操作,无论多少线程同时调用,每次加 1 都不会丢。这正是原子整型相对普通 int 的价值——后面误区一节会讲,为什么 counter = counter + 1 即使 counter 是原子的也仍然不安全。
这里使用 memory_order_relaxed,因为计数值本身不负责发布其他数据;它仍保证读改写原子性和 counter 的修改顺序。若把这个计数器同时当作“其他对象已初始化”的信号,relaxed 就不够,必须在下一页证明相应的同步关系。
查一查到底是不是无锁:is_lock_free
把变量声明成 std::atomic 不代表它就无锁。运行期用 is_lock_free() 查清楚:
#include <atomic>
#include <iostream>
struct Big { // 一个较大的结构体
long a, b, c, d;
};
int main() {
std::atomic<int> ai{0};
std::atomic<Big> ab{};
// 两者是否无锁都由目标实现决定,必须查询
std::cout << "atomic<int> 无锁? " << ai.is_lock_free() << "\n";
std::cout << "atomic<Big> 无锁? " << ab.is_lock_free() << "\n";
return 0;
}is_lock_free() 返回 true 表示这个原子类型在当前平台真用无锁指令实现,false 表示库偷偷加了把内部锁。语义两种情况都正确,但性能差别很大——性能敏感处务必先查。
CAS 重试循环的骨架
CAS 几乎总是配一个重试循环用(尤其 weak 版可能伪失败)。这是它最典型的形态——把一个原子变量原子地翻倍:
#include <atomic>
void atomic_double(std::atomic<unsigned>& x) {
unsigned expected = x.load(); // 无符号溢出按模运算定义
// 相等就换成 expected*2 返回 true(退出循环);
// 不等(被别人改过)就把真实值回填进 expected、返回 false,循环再试一次
while (!x.compare_exchange_weak(expected, expected * 2)) {
// 失败时 expected 已被更新为真实的当前值,下一轮用它重新算 desired
}
}失败时 compare_exchange_weak 会把观察到的值写回 expected,下一轮据此重算 desired;weak 还允许伪失败,所以适合循环。这里用无符号类型,让翻倍溢出具有按模定义;若改回有符号整数,表达式溢出本身会产生未定义行为。
容易踩的坑
小结
- 原子操作不可分割:别的线程要么看到它完全没发生、要么已全部完成,绝不会撞见「写一半」的中间态——就像那笔「要么全写上、要么完全没动」的订单改动
- 每个原子对象有自己的修改顺序;普通对象一旦发生数据竞争,不能假设仍有合法执行可供推演
- 冲突访问至少一写、至少一项非原子且没有 happens-before 才构成数据竞争;撕裂读只是可能现象,不是未定义行为的全部解释
- 原子家族:
std::atomic_flag(最简、强制无锁)、std::atomic<bool>、原子指针、原子整型(fetch_add)、std::atomic<可平凡复制 T>;但不是都无锁,用is_lock_free()查 - CAS(
compare_exchange_weak/strong)在一个原子操作里「比较 + 交换」:相等才改、不等就回填真实值重试——无锁编程的基石
练习
问题 1(解释/判断型) 同事说:「两个线程非原子地读写同一个 int,硬件单次访问不会撕裂,所以最坏也就是旧值或新值。」这句话对吗?请区分硬件撕裂与 C++ 数据竞争。
问题 2(改代码型) 下面这个多线程计数器有 bug:即使把 counter 改成了原子类型,并发自增时计数仍然会丢。指出 bug,并改成正确的原子自增。
std::atomic<int> counter{0};
void worker() {
for (int i = 0; i < 1000; ++i) {
counter = counter + 1; // ❌ 看似用了 atomic,其实不安全
}
}问题 3(问答型) 用「订单板」的比喻,分别解释:(a) 为什么 std::atomic_flag 能搭一个锁,而普通 bool 不行?(b) 为什么 std::atomic<T> 不保证一定无锁,该怎么查?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 内存位置(memory location)
内存里一块被独立读写的存储单元。一个标量(
int、bool、指针等)占一个内存位置。C++ 的并发规则以它判断冲突;相邻非零宽度位域可能属于同一位置,不同数组元素则是不同位置。- 修改顺序(modification order)
针对单个原子对象,其所有原子写与原子读改写形成的唯一全序。它必须满足 happens-before 约束,但不是多个对象之间的全局总序。
- 数据竞争(data race)
不同线程对同一内存位置的冲突访问中至少一个是写、至少一个是非原子操作,而且彼此没有 happens-before。C++ 规定存在数据竞争的程序行为未定义。
- 撕裂读(torn read)
读取一个正在被写、且写入要分多步完成的变量时,读到「一部分是新值、一部分还是旧值」拼起来的结果——一个谁都没真正写过的乱码。比如 64 位对象在某平台分两步写,读者夹在中间可能拿到「高新 + 低旧」。它只是数据竞争可能呈现的硬件现象之一,并非 C++ 未定义行为的完整原因。
- 原子操作(atomic operation)
不可分割的操作:从任何其他线程看,它要么完全没发生、要么已经全部完成,绝不会被观察到「做了一半」的中间态。像那笔「要么全写上、要么完全没动」的订单改动。C++ 用
std::atomic类型提供原子操作,且原子操作之间天然不构成数据竞争。详见本章「为什么要原子」一节。- std::atomic_flag
C++ 里最基础、且标准强制保证一定无锁的原子类型。它只有「已置位 / 未置位」两个状态,只支持两个操作:
test_and_set(原子地置位并返回它之前是不是已置位)和clear(清位)。功能极简,常用来搭一个最朴素的自旋锁。详见本章「原子类型家族」一节。- 可平凡复制(trivially copyable)
一种类型,它的对象仅靠「逐字节拷贝内存」就能完成复制(没有自定义拷贝逻辑、没有虚函数、没有需要特殊处理的成员),且析构也平凡。简单内置类型和只含这类成员的结构体都满足。只有可平凡复制的类型才能放进
std::atomic<T>。详见本章「更宽的原子家族」一节。- is_lock_free
std::atomic的成员函数is_lock_free()(以及编译期常量is_always_lock_free),返回这个原子类型在当前平台上是不是真用无锁指令实现。true表示无锁;false表示库用了一把内部锁来模拟原子语义——语义仍正确,但不是无锁、性能不同。详见本章「更宽的原子家族与 is_lock_free」一节。- CAS(比较并交换)
compare-and-swap,C++ 里写作
compare_exchange_weak/compare_exchange_strong。它在一个原子操作里:比较原子变量的当前值是否等于expected——相等就换成desired并返回true(成功),不等就把当前真实值写回expected并返回false(失败,调用方据此重试)。像「看一眼账上还是不是我以为的那个数,是才改、不是就把真实值记下来重来」。是大多数无锁算法的核心原语;weak版允许偶尔「伪失败」,所以通常配重试循环用。详见本章「compare_exchange」一节。