原子类型与内存模型基础

读完能解释原子操作、单个原子对象的修改顺序与数据竞争,区分原子语义和无锁实现,并看懂 std::atomic 家族与 CAS。

学习目标

  • 能解释原子操作修改顺序分别保证什么,并指出修改顺序只属于单个原子对象
  • 能区分数据竞争的语言级未定义行为与硬件撕裂现象,并判断原子性、内存顺序和无锁实现为何不能混为一谈
  • 能回答这个检验问题:两个线程同时、非原子地读写同一个 int,C++ 标准为什么直接判它是未定义行为,而不是「结果不确定但至少不崩」?

机制总览

原子对象的修改顺序与 CAS 重试

  1. 1

    选择原子对象

    并发读写同一内存位置时,用合适的 atomic 类型消除数据竞争。

  2. 2

    执行读改写

    fetch_add 等原子读改写在单一修改顺序中占一个位置。

  3. 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++ 里, 是并发规则判断冲突的基本单位。一个标量对象占一个内存位置;相邻的非零宽度位域可能共享一个位置,零宽位域会切开序列。不同数组元素是不同内存位置,即使它们物理上紧挨着。

对每一个原子对象,所有原子写和原子读改写存在一个唯一全序,叫 。它像这一条账目的官方流水:所有线程都必须与同一条顺序相容。普通非原子对象若发生无同步冲突访问,程序已经有数据竞争,不能先假设它也有一条可供推演的合法修改顺序。

关键限定是单个原子对象。x 有 x 的修改顺序,y 有 y 的修改顺序;两条流水之间是否建立可见性,要看下一页的 memory order、synchronizes-with 和 happens-before。下面的动画只演示一个原子 x 的修改顺序:

猜一猜:三个线程各自往同一个变量 x 上写了好几笔,动手先后乱成一团。那么「x 被改成 5 这一笔,到底在 x 被改成 8 那一笔之前还是之后」——三个线程的看法会不会有分歧?先想一下,再单步看。

可交互
修改顺序:一条账目的官方流水台账三个线程乱序写同一个原子变量 x,但对 x 的所有写入有唯一全序线程1线程2线程3x = 1x = 2x = 3x = 4x = 5x = 6↓ 所有写入按真实发生先后,落到同一条全序线 ↓x 的修改顺序(全序,所有线程一致)第 1 笔x=1第 2 笔x=2第 3 笔x=3第 4 笔x=4第 5 笔x=5第 6 笔x=6先 → 后

第 1 / 6 步 · 厨师1 写 x=1——这笔最先落账,排进全序第 1 位

三个线程乱序写同一个原子变量 x,但每写一笔就落到下方唯一一条全序线上——这条「修改顺序」所有线程看法一致。注意这是单变量全序,不是跨变量内存序。可暂停、单步、拖进度。

修改顺序(modification order):对任意一个对象(这里是原子变量 x),它的所有写入存在唯一一个全序,所有线程对这条「流水台账」的先后看法完全一致。注意这是单个变量的全序,不是跨多个变量的内存序——后者下一章再讲。

看清楚了:无论三个线程动手多乱,对 x 这一个变量的所有写入,最终都落到下方唯一一条全序线上;这条「流水台账」所有线程看法一致。修改顺序是内存模型给你的第一条地基。

为什么要原子:非原子并发读写 = 未定义行为

有了内存模型的语言,就能精确地说清开篇那个「写一半被看见」的灾难了。

两个不同线程的访问发生冲突、至少一个是写、至少一个是非原子访问,且两者之间没有 happens-before,就发生 。两个原子操作即使并发也不构成数据竞争;两次普通访问若由 mutex 等同步建立顺序,同样没有数据竞争。未定义行为不是一组“随机但有限”的结果,编译器可以据此重排、合并或删除代码。

是一种可能的底层现象,例如某平台分两步写宽对象,读者夹在中间取得“高位新、低位旧”。但即使目标机器保证对齐 int 的单次加载不会撕裂,无同步冲突访问在 C++ 层仍是数据竞争;优化器行为才是不能靠硬件直觉推演的关键。

就是来提供不可分割访问的。原子 load、store 和读改写不会被观察成半个操作,同一原子对象上的原子访问不会彼此形成数据竞争。但把每一步都写成原子操作,不会自动让多步业务逻辑成为一个事务,也不会自动发布其他普通数据。下面的对照动画只展示“单次访问是否可能撕裂”这一维: Demo:

猜一猜:把一个普通的 64 位变量改成原子类型后,「线程 A 正写、线程 B 同时读」这种情况,线程 B 还可能读到「高位新、低位旧」的撕裂乱码吗?先想,再播放看答案。

可交互
非原子:写一半被看见 → 撕裂读64 位变量分两步写(高位 + 低位),B 在缝里读到乱码线程 A 写变量值线程 B 读高32=新低32=旧①写高32位 = 新②趁缝里读整个 64 位③写低32位 = 新B 读到:高=新|低=旧 → 撕裂乱码 💥原子:一笔写完,没有中间态线程 A 写变量值线程 B 读高32=新低32=新①一笔写完整个 64 位(不可分割)②此后才读B 读到:高=新|低=新 → 完整新值 ✓时间 →

第 1 / 7 步 · 非原子:线程 A 先把高 32 位写成新值(写一半,留了个缝)

上区:非原子变量写一半被读 → 撕裂乱码(UB);下区:原子变量一笔写完,读到的要么全旧、要么全新。可暂停、单步、拖进度。

撕裂读 vs 原子:非原子的 64 位变量分两步写,另一线程趁缝里读会拿到「高新+低旧」的乱码(未定义行为);原子操作不可分割——写入要么全做、要么全不做,读到的永远是一个完整的值。

上区是某些硬件上可能发生的撕裂示意;真实 C++ 数据竞争的行为范围更大。下区表达原子访问不会返回某次写入的半成品,但读到哪一笔合法修改仍受修改顺序和内存顺序约束。

原子类型家族:从最简的 atomic_flag 说起

C++ 标准库把原子能力都放在 <atomic> 头文件里。先认识其中最简单的一个。

是最朴素的原子类型:它只有「置位 / 未置位」两种状态,只支持两个动作——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_addfetch_sub 这类「原子地加一个数、并返回加之前的旧值」的操作——这正是多线程安全计数器的标准做法。更一般地,std::atomic<T> 对任何的类型 T 都能用(比如一个只含几个 int 的小结构体)。

std::atomic<T> 不保证一定无锁。小整数和指针在常见目标上通常映射到硬件原子指令,却仍不能跨平台假定;较大或不受硬件直接支持的类型可以由库用内部锁实现。语义原子性不变,进展与性能特征不同。用 查询目标实现:

compare_exchange:看一眼是不是我以为的数,是才改

原子家族里还有一个核心操作,它是后续无锁编程的基石,这里先建立概念、ch6 / ch8 再深入。这就是

它的两个成员函数是 compare_exchange_weakcompare_exchange_strong,签名形如 compare_exchange_weak(expected, desired)。一句话讲清它在干嘛:看一眼账上还是不是我以为的那个数(expected),是才把它改成新数(desired),不是就把账上真实的数记下来、回头重看一次。整个「比较 + 交换」在一个原子操作里一气呵成,别的线程绝不会看到中间态——又是「要么全做、要么全不做」。下面这张动画把它的成功支和失败支分开演:

猜一猜:compare_exchange_weak(expected, desired) 发现「当前值 ≠ expected」时(说明账被别人动过了),它会做什么——直接什么都不动、还是顺手帮你把 expected 更新成真实值好让你重试?先想,再单步看失败支。

可交互
CAS:看一眼是不是我以为的数,是才改compare_exchange_weak(expected, desired):比较 + 交换,一个原子操作里完成相等 ✓不等 ✗① 读当前值 cur看一眼账上现在是多少② cur ==expected ?③ 把值换成 desired账上确实是我以为的数 → 改返回 true(成功)④ 把 cur 写进 expected账上不是我以为的 → 记下真实值返回 false(失败 → 重试)

第 1 / 6 步 · ① 读出原子变量当前值 cur——先看一眼账上现在到底是多少

读 cur → 比较 cur==expected:相等就换成 desired 返回 true(成功);不等就把真实 cur 回填进 expected 返回 false(失败→重试)。比较与交换在一个原子操作里完成。可暂停、单步、拖进度。

CAS(比较并交换):compare_exchange_weak 把「比较 cur 是否等于 expected」和「相等才换成 desired」打包成一个不可分割的原子操作。相等返回 true(改成功);不等返回 false,并把真实值回填进 expected——调用方据此重试。这是后续无锁编程的基石。

看明白两条路:相等就把值换成 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()
  • CAScompare_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)

内存里一块被独立读写的存储单元。一个标量(intbool、指针等)占一个内存位置。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」一节。

讨论

评论区加载中…