4. 内存模型和原子操作
内存模型基础
C++是一个系统级别的编程语言,标准委员会的目标之一就是不需要比C++还要底层的高级语言。C++应该向程序员提供足够的灵活性,无障碍的去做他们想要做的事情;当需要的时候,可以让他们“接触硬件”。原子类型和原子操作就允许他们“接触硬件”,并提供底层级别的同步操作,通常会将常规指令数缩减到1~2个CPU指令。
对象和内存位置
在一个C++程序中的所有数据都是由对象(objects)构成。这里的对象不是指面向对象编程中的对象, 而是指一块有类型的内存区域。C++标准定义类对象为“存储区域”, 每个对象都存储在一个或多个“内存位置”上(memory location),这里的内存位置是由对象的起始地址和对象的大小决定的。两个对象如果它们的内存位置重叠(overlap),那么它们就不是独立的对象。
例如, 一个 int
类型的对象通常占用4个字节的内存。如果有两个 int
对象分别位于内存地址0x1000和0x1004,那么它们的内存位置不重叠,是独立的对象;但如果一个
int
对象位于0x1000,另一个位于0x1002,那么它们的内存位置就重叠了,不是独立的对象。
此外, 位域(bit-field)成员变量也是对象,但多个相邻的位域可能会共享同一个内存位置(如果没有被强制分隔的话),这会导致数据竞争的问题。
位域指的是在结构体(struct)或联合体(union)中定义的特殊成员变量,它们允许程序员指定成员变量占用的位数,而不是默认的字节数。位域通常用于节省内存空间,特别是在需要存储大量布尔值或小整数时。
如图的这个struct的结构如下
1 | struct S { |
- struct S 本身:是一个大对象,它包含了很多“子对象”(即它的成员)。
- c (char):一个对象,它自己独占一个内存位置。
- i (int):一个对象,它自己独占一个内存位置。
- bf1 和 bf2
(相邻位域):它们是两个不同的对象。但是,因为它们是相邻的位域,C++标准规定它们共享同一个内存位置(这个位置是它们所属的
int)。
- 这会造成并发后果:如果线程A写入 bf1,同时线程B写入 bf2,它们是在访问同一个内存位置,这构成数据竞争 (Data Race)!
- bf3(匿名的
int : 0;)是一个特殊的布局说明符,它强制 bf4 与 bf1/bf2 分开。 - 由于 bf3 的分隔, bf4 是一个对象,它自己独占一个新的内存位置。
- s (std::string):s 本身是一个对象, 但 std::string 内部实现很复杂(可能包含指针、大小、容量等)。因此,s 对象是由内部多个内存位置组成的。
总之, 这一章要我们记住的四个原则:
- 每一个变量都是一个对象(包括成员变量)。
- 每个对象至少占有一个内存位置。
- 基本类型(如 int, char, float, T*)的对象都有自己确定的内存位置(即使它们在数组中相邻, 这是因为它们的类型和大小是固定的4个字节倍数, 天然对齐)。
- (关键)相邻的位域共享同一个内存位置。
并发下的内存位置
在上一节中,我们精确定义了“内存位置”(特别是位域的陷阱)。本节将阐明为什么这个定义对并发编程是致命重要的。它影响了C++并发中最危险的情况:数据竞争 (Data Race) 和 未定义行为 (Undefined Behavior)。
首先, 我们来看一下并发访问的三种场景
场景 1:访问“不同”的内存位置 - 线程A 写入 my_struct.c (一个 char),同时线程B 写入 my_struct.i (一个 int)。 - 结果:完全安全。 - 原因:c 和 i 是不同的对象,它们位于不同的内存位置。因此,线程A和B在各自的“领地”上操作,互不相干。
场景 2:访问“相同”的内存位置(全部只读) - 线程A 读取 my_struct.i,同时线程B 也读取 my_struct.i。 - 结果:完全安全。 - 原因:只读数据不需要保护或同步。
场景 3:访问“相同”的内存位置(至少一个写) - 线程A 写入 my_struct.i,同时线程B 读取 my_struct.i。 - 线程A 写入 my_struct.i,同时线程B 写入 my_struct.i。 - 线程A 写入 my_struct.bf1 (位域),同时线程B 写入 my_struct.bf2 (相邻位域)。 - 上述三种情况都是极度危险的, 是“条件竞争”的根源。
让我们再回忆一下数据竞争 (Data Race) 的定义: - 有两个或以上的线程并发(即没有同步)地执行。 - 它们访问相同的内存位置。 - 这些访问中至少有一个是写操作。 - (并且)它们不是 std::atomic 类型的操作。
而数据竞争的直接后果就是未定义行为。
如何避免数据竞争? 尤其是在需要访问相同内存位置的情况下?答案是在不同的访问之间建立一个强制的执行顺序 (Execution Order)。
方法一就是我们之前提到的使用互斥量 (Mutex) - 当线程A lock() 互斥量时,它就建立了一个“屏障”。 - 当线程B 尝试 lock() 同一个互斥量时,它被迫等待。 - lock() 和 unlock() 操作在线程A和线程B之间强制建立了一个“先行发生” (Happens-before) 关系,从而避免了数据竞争。
方法二则是这一讲要介绍的原子操作 (Atomic Operations) - std::atomic 类型的操作(如 load()、store())本身就提供了同步机制,它们也可以(在特定条件下)在线程间建立“先行发生”关系。
修改顺序 (Modification Order)
对于C++程序中的任意一个独立的对象(例如,一个 int 变量 x),C++内存模型规定,在一次程序的完整执行中,所有线程对这一个 x 的所有写入操作(从初始化开始),都必须存在一个单一的、全局的总顺序。
换句话说:即使有10个线程在并发地写入 x,在程序结束时,我们必须能(在理论上)将这些写入操作排成一个唯一的队伍,比如:“初始化” -> “线程A的写入” -> “线程C的写入” -> “线程F的写入”…
并且, 在某一次特定的程序运行中,所有线程都必须“同意”这个唯一的顺序。它也不是在每次运行时都相同的。在大多数情况下,这个顺序不同于执行中的顺序(因为线程调度的不可预测性)。但是, 所有线程必须对这个顺序达成一致。 > 注意: 这里的“达成一致”并不是指线程间需要通信或协商,而是指在程序执行结束后,所有线程都能观察到同一个修改顺序。这在某种程度上是一种”马后炮”的全局共识。
例如线程A执行x = 10; 线程B执行x = 20;
- 运行 1:可能的“修改顺序”是 init -> (x=10) -> (x=20)。
- 运行 2:可能的“修改顺序”是 init -> (x=20) -> (x=10)。
两种都是合法的。但关键是,在“运行 1”中,所有在之后读取 x 的线程都必须认同 (x=10) 发生在 (x=20) 之前。
从上面的例子中也可以看到, 一般变量的修改顺序是不可预测的, 而 C++ 标准通过原子操作强制实现了这个“修改顺序”, 这正是原子 (Atomic) 类型和非原子 (Non-Atomic) 类型的根本区别。
对于非原子类型(例如,一个普通的 int x):
- 编译器和CPU不提供任何保证。
- 你(程序员)的责任:你必须使用互斥量 (Mutex) 等同步工具来手动强制建立这个“修改顺序”。
- 后果:如果你不使用同步(即在不同线程中对 int x 进行读/写),线程之间就无法“同意”一个单一的修改顺序。这就造成了数据竞争 (Data Race),它会导致未定义行为 (Undefined Behavior)。
对于原子类型(例如,
std::atomic<int> x):- 编译器和CPU的责任:当你使用原子操作时,编译器和CPU必须保证(通常是通过特殊的CPU指令和内存屏障)所有线程都能“同意”一个对 x 的单一修改顺序。
- 这是原子类型提供的核心保证:它使你摆脱了“数据竞争”和“未定义行为”。
这个“所有线程必须同意一个顺序”的规则,反过来限制了编译器和CPU能做什么:
- 禁止读取“过去”:如果一个线程已经读取了 x 的某个值(例如,x=20),那么它在之后的任何读取操作,必须返回 x=20 或者是“修改顺序”中排在 x=20 之后的值(例如 x=30)。它绝不能返回一个排在 x=20 之前的值(例如 x=10)。
- 写操作的可见性:任何写操作必须(在修改顺序上)出现在它自己线程的前一个写操作之后。
- 读操作的可见性:任何读操作必须返回修改顺序中某个(或某个读-改-写链上的)值。
不过 ,十分重要的一点是: “修改顺序”是针对“每一个独立对象”的, 它不提供“跨对象”的顺序。
例如, 有两个独立的原子对象 x 和 y, 初始值都是0: -
线程 A 执行 x.store(1); y.store(1); - 线程 B
执行 y_val = y.load(); x_val = x.load(); -
线程 C 执行 x_val = x.load();
y_val = y.load();
毫无疑问, 所有线程都会同意 x 的修改顺序是 0 -> 1, y 的修改顺序也是 0 -> 1, 这是原子操作的保证。
但是, 线程B 可能会看到 y_val=1 和 x_val=0。(它看到 y 的修改在 x 之前). 同理线程C 可能会看到 x_val=1 和 y_val=0。(它看到 x 的修改在 y 之前)
这是因为处理器和编译器为了优化性能,可能会重排(Reorder)指令。当操作是针对两个不相关的内存位置(即 x 和 y)时,这种重排是允许的。
线程 A
内部的程序顺序(x.store(1); y.store(1);)只是一个“在本线程内”的顺序。这种顺序并不会自动同步给观察者线程
B 和 C,让它们看到 x
的写入严格发生在 y
的写入之前。
要实现跨对象的顺序同步,需要使用更强的同步机制,例如内存栅栏 (Memory Fences) , 即指定更强的内存顺序语义 (Memory Order Semantics)(例如
std::memory_order_seq_cst)的原子操作,或者使用互斥量等同步工具。
任何线程都会承认 x 的所有写操作是按 Wx1, Wx2, … 的顺序发生的,也会承认 y 的所有写操作是按 Wy1, Wy2, … 的顺序发生的。但是,线程 A 可能会看到 Wx1 发生在 Wy1 之前,而线程 B 则可能会看到 Wy1 发生在 Wx1 之前,这两种视角都是合法且可能发生的。
总之, 本节定义了并发程序的一个基本“健康”标准——所有线程必须能对任一单个对象的修改历史达成一致。原子类型会自动帮你做到这一点,从而避免数据竞争。但它不会自动帮你处理多个对象之间的相对顺序问题
C++中的原子操作和原子类型
标准原子类型
首先, 原子类型是并发编程中用于在多线程环境中安全地访问和修改共享数据的类型, 它们提供了一种机制,确保对这些数据的操作是原子的,即不可分割的, 从而避免使用传统的锁机制(如互斥量)来进行同步。
然而, 原子操作真的是”无锁编程”吗? 也就是is_lock_free()这个概念
一般来说, 原子性 (Atomicity)这个概念保证操作是不可分割的。其实现存在两种方式:
真·无锁 (Lock-Free):
- 编译器使用特殊的CPU指令(例如 x86 上的 LOCK CMPXCHG)来保证操作的原子性。
- 优点:效率极高,没有互斥量的阻塞、上下文切换等开销。
假·无锁” (Lock-Based):
- 编译器内部使用一个隐藏的互斥量 (Mutex) 来“模拟”原子性。
- 当你调用 my_atomic.store(x)
时,编译器(在幕后)执行的可能是:
internal_mutex.lock(); my_value = x; internal_mutex.unlock(); - 优点:它依然是线程安全的,能避免数据竞争。
- 缺点:它有锁的全部性能开销,会阻塞其他线程。
is_lock_free()
成员函数:bool my_atomic.is_lock_free() const;
这是一个运行时检查,它告诉你:“在这个特定的CPU架构和编译器上,对这个对象的操作是‘真·无锁’(返回 true)还是‘假·无锁’(返回 false)?”
在x86/x86-64等主流平台上,你可以期望
std::atomic<int>、std::atomic<void*>等基础类型是无锁的,但C++标准并不对此做保证。
std::atomic_flag:最基础的原子类型
这是标准库中最简单的原子类型。它就是一个布尔标志。
std::atomic_flag 是唯一被C++标准保证在所有平台上都必须是无锁的类型。
因为它必须是无锁的,所以它的功能被极度阉割,只被用作最底层的“构建块”(例如自旋锁)。
它没有 is_lock_free()(因为永远是 true),没有 load 或 store。它只有 clear() 和 test_and_set() 等几个非常受限的操作。
std::atomic<>:主要的类模板
这是我们实际使用中最主要的原子类型, 使用一个非原子类型 T 来特化它,以得到对应的原子版本
std::atomic<bool>std::atomic<int>std::atomic<unsigned long>std::atomic<MyClass*>, 指针类型的原子版本std::atomic<MyUserDefinedType>, 用户自定义类型的原子版本(前提是该类型满足某些要求,例如可拷贝、大小适中等)。
这些类型提供了全套的操作(load, store, exchange 等),但不保证是无锁的(你需要用 is_lock_free() 来检查)
核心属性
除了类型 T 的要求外,std::atomic<T>
还有以下几个核心属性( atomic_flag 除外): -
不可拷贝构造 / 不可拷贝赋值
(Non-Copyable /
Non-Assignable):std::atomic<int> x = y; // 编译错误!
-
原因:“拷贝”是一个包含“读(y)”和“写(x)”的两个操作。C++无法将这两个独立的操作合并成一个原子操作。
- 但是可以从非原子类型来构造和赋值:
std::atomic<int> x(5); // 正确
x = 10; // 正确 (这调用了 x.store(10))
- 核心操作:
- load() (加载), store() (存储)
- exchange() (交换)
- compare_exchange_weak/strong() (CAS)
- 整数/指针的特殊操作:
- fetch_add(), fetch_sub()
- +=, -=, ++, –(重载了上述操作符)
- 内存顺序:这是本章的重点。load, store
等所有操作都有一个可选的内存顺序参数。
- 默认值:如果你不指定,所有操作默认都是 std::memory_order_seq_cst(最强、最安全、但也最慢的顺序)。
std::atomic_flag
这是C++标准库中最基础、最简单的原子类型。它就是一个布尔标志。
- 它只有两种状态:“设置” (set, true) 和 “清除” (clear, false)。
- 唯一保证:它是C++标准中唯一保证始终无锁 (lock-free)
的类型。
std::atomic<bool>都不一定能做到这一点。 - 角色:它不是为通用布尔逻辑设计的,而是作为最底层的构建块存在的(例如,用来实现其他锁)。
初始化:ATOMIC_FLAG_INIT
std::atomic_flag 的初始化非常特殊,它不能像其他原子类型那样在构造函数中传入 true 或 false。你必须使用宏 ATOMIC_FLAG_INIT。
并且 ATOMIC_FLAG_INIT 只能将 atomic_flag 初始化为“清除” (clear) 状态(即 false)。你没有别的选择。
1 | std::atomic_flag f = ATOMIC_FLAG_INIT; |
静态初始化保证:当 std::atomic_flag 被声明为 static 或全局变量时,使用 ATOMIC_FLAG_INIT 可以保证它是“静态初始化”的,这意味着它在程序开始(甚至在多线程启动)之前就已经被初始化了,不会有初始化顺序问题。
与之类似的还有静态局部变量的初始化保证:C++11及以后的标准保证,函数内的静态局部变量在多线程环境下只会被初始化一次,且初始化过程是线程安全的。
核心操作:test_and_set() 和 clear()
std::atomic_flag 的功能被极度限制。一旦初始化,你只能对它做两件事:
clear() (清除): 原子性地将标志的状态设置为“清除” (false)。
1 | void clear(std::memory_order = std::memory_order_seq_cst); // 默认为 seq_cst |
这是一个“存储 (Store)”操作, 它可以接受 memory_order_relaxed, memory_order_release, 或 memory_order_seq_cst。
test_and_set() (测试并设置):这是 atomic_flag 最关键的操作,它是一个原子的“读-改-写” (Read-Modify-Write, RMW) 操作。
1 | bool test_and_set(std::memory_order = std::memory_order_seq_cst); // 默认为 seq_cst |
并且作为RMW操作,它可以接受所有类型的内存顺序。
返回值分析:
- 如果标志之前是 false (clear):test_and_set() 返回 false,然后将标志设为 true。
- 如果标志之前是 true (set):test_and_set() 返回 true,标志保持为 true。
关键限制
- 不可拷贝 / 不可赋值:
- 这是所有原子类型的共性。“拷贝”/“赋值”涉及两个对象(源和目标),需要“从A读”和“向B写”两个步骤。这“两个”步骤无法合并为一个原子操作,因此被禁止。
1
2std::atomic_flag f2 = f; // 编译错误!
f2 = f; // 编译错误!
- 这是所有原子类型的共性。“拷贝”/“赋值”涉及两个对象(源和目标),需要“从A读”和“向B写”两个步骤。这“两个”步骤无法合并为一个原子操作,因此被禁止。
- 没有“只读”操作:
- 这是 atomic_flag 最致命的局限性:你无法在不修改它的情况下检查它的当前值。
- 它没有 load() 或 test() 这样的函数。你唯一的“读”操作就是 test_and_set(),而它会强制将标志设为 true。
示例:实现自旋锁 (Spinlock)
atomic_flag 的局限性使它成为实现自旋互斥锁的完美(虽然非常基础)工具。
- “清除” (false) 状态代表锁是“可用” (unlocked)。
- “设置” (true) 状态代表锁是“被持有” (locked)。
1 | class spinlock_mutex |
行为分析: - unlock() (解锁): - 非常简单。调用 clear() 将标志位原子性地设为 false (unlocked)。 - memory_order_release (释放) 语义确保了:在此 unlock 之前的所有内存写入(即在锁保护下的代码),对于下一个 lock 它的线程都是可见的。
- lock() (上锁):
- 这是最精妙的部分。线程会执行 while(flag.test_and_set(…))。
- 情况 1:锁是可用的 (flag == false)
- 线程调用 test_and_set()。
- test_and_set() 原子地返回 false(之前的状态),并将 flag 设为 true。
- while(false):循环条件为 false,循环立即终止。
- 结果:线程成功获取了锁(只用了一次 test_and_set),并继续执行。
- 情况 2:锁已被持有 (flag == true)
- 线程调用 test_and_set()。
- test_and_set() 原子地返回 true(之前的状态),flag 保持为 true。
- while(true):循环条件为 true,线程继续循环(这就是“自旋”)。
- 线程会一遍又一遍地调用 test_and_set(),每次都返回 true,直到…
- 情况 3:锁被释放
- 某个持有锁的线程调用了 unlock(),将 flag clear() 为 false。
- 自旋中的线程在其下一次 test_and_set() 调用中,命中了情况1。
- test_and_set() 返回 false,循环终止,该线程获取了锁。
std::atomic<bool>
的相关操作
std::atomic<bool> 可以看作是普通 bool
类型的线程安全、原子版本。与极其基础的 std::atomic_flag
相比,它提供了更完整、更易于使用的布尔操作集。然而,代价是它不像
atomic_flag 那样被标准保证一定是无锁的。
你可以直接用普通的 bool 值来初始化
std::atomic<bool>,也可以将一个 bool 值赋给它。
1 | std::atomic<bool> b(true); // 用 true 初始化 |
假设赋值操作符返回的是引用, 当我们执行这样一行代码:
bool result = (atomic_b = false);,此时如果另一个线程在我们读取 result 之前又将 atomic_b 修改为 true,那么我们得到的 result 就是一个不一致的值。因为对于这样的链式调用, atomic_b在赋值时和被读取时是原子的, 但是中间存在非原子的间隙。 而返回被赋的值本身(不是引用)可以避免这种问题,因为我们直接得到的是赋值时的那个值, 即使 atomic_b 在之后被其他线程修改了, 也不会影响我们已经得到的 result。 同理还有if (atomic_b = true) { ... }这种用法, 因为赋值返回的是值本身, 所以不会因为后续的修改而影响条件判断。
核心操作
std::atomic<bool> 支持几个基本的原子操作:
load():原子性地读取当前值。
bool x = b.load(std::memory_order_acquire);- 这是一个读 (Read) 操作。它仅仅返回 b 中当前存储的布尔值(true 或 false)。你也可以通过隐式转换来读取值(bool x = b;),这同样执行原子加载。
store(bool):原子性地写入一个新值。
b.store(true);- 这是一个写 (Write) 操作。它用提供的值(这里是 true)替换 b 中的当前值。赋值操作符(b = false;)是 b.store(false); 的简写形式。
exchange(bool):原子性地用一个新值替换当前值,并返回旧的值。
bool old_value = b.exchange(false, std::memory_order_acq_rel);- 这是一个原子的读-改-写 (Read-Modify-Write, RMW)
操作。它不可分割地执行两个动作:
- 将 b 的值设置为新值(这里是 false)。
- 返回 b 在此操作之前所持有的值。
比较并交换 (Compare-and-Swap, CAS) 操作
这是最强大的 RMW 操作,是许多无锁算法的基础。它们尝试更改值,但仅当当前值与预期值匹配时才进行。
- 逻辑:“如果当前值是 expected,那么就把它设置为 desired。并通过返回值告诉我是否成功了。”
- 形式: 目前 C++ 标准提供了两个版本的 CAS 函数
bool compare_exchange_weak(T& expected, T desired, ...);bool compare_exchange_strong(T& expected, T desired, ...);
- 参数:两个 CAS 函数都至少接受两个参数
- expected:一个引用,指向一个 bool 变量,该变量持有你期望原子变量当前应该具有的值, 且是引用类型。
- desired:你希望在期望匹配时设置的 bool 值。
- 可选的第三个参数是内存顺序标签(将在后面解释)。
- 返回值:一个 bool,表示操作是否成功。
- 失败时的行为:如果原子变量的当前值与 expected 不匹配,CAS 操作失败。关键在于,此时它会用原子变量实际的当前值去更新 expected 变量。这会告诉你它失败的原因,并为可能的重试准备好 expected。
compare_exchange_weak() (CAS Weak)
1 | bool expected = false; |
可能伪失败 (Spurious Failure):即使值确实匹配 expected,这个版本也可能失败(返回 false)。这可能发生在某些 CPU 架构上,由于时间问题(例如上下文切换)。但是在某些平台上,它在循环内部可能生成比 strong 更高效的代码。
用法:由于可能伪失败,compare_exchange_weak 几乎总是用在一个循环中。循环会持续,只要操作失败 并且 失败的原因不是因为值与我们最初(或上一次尝试时)期望的不同。
1
2
3
4
5
6bool expected = false;
// 持续尝试,只要 CAS 失败了 并且 失败的原因不是因为实际值不是我们期望的'false'。
while (!b.compare_exchange_weak(expected, true) && !expected) {
// 如果我们只想在它变为 'false' 时将其设置为 'true',循环体可以为空。
// 如果 CAS 调用内部将 'expected' 更新为 'true',那么 && 条件的后半部分将失败,退出循环。
}
compare_exchange_strong() (CAS Strong)
1 | bool expected = false; |
无伪失败:这个版本保证只有在原子变量的值确实与 expected 不匹配时才会返回 false。
当你需要明确知道更改是成功还是因为值不匹配而失败,并且不想处理伪失败时使用。你可能仍然需要一个循环(如果你的逻辑需要在值被其他线程更改后重试),但循环逻辑通常比 weak 更简单。
内存顺序:所有这些操作 (load, store, exchange, CAS) 都接受可选的 std::memory_order 参数(将在后面详细解释),用于指定内存同步行为。默认值是 std::memory_order_seq_cst,这是最强、最安全的选择。
std::atomic<T*>:
指针运算
std::atomic<T*> 除了支持
std::atomic<bool> 所具有的基本原子操作(load, store,
exchange,
compare_exchange)之外,它还额外支持原子化的指针算术运算。
它是针对指针类型 T(可以是内置类型指针如 int,也可以是用户定义类型指针如 MyClass*)的 std::atomic 特化版本。
基本操作:与 std::atomic<bool> 类似,它支持: -
is_lock_free() 检查。 - 从 T* 构造和赋值。 -
std::atomic<int*> p(nullptr); -
p = some_int_pointer; // 这调用了 p.store(some_int_pointer);
- load(), store(), exchange(), compare_exchange_weak(),
compare_exchange_strong() 成员函数,只不过操作的对象和返回的值现在是 T*
类型。 - int* old_ptr = p.exchange(new_ptr); -
int* expected = nullptr; -
bool success = p.compare_exchange_strong(expected, new_ptr);
- 增加了对指针加减运算的原子支持。
原子指针算术运算
std::atomic<T*>
提供了两组成员函数(和对应的操作符)来实现原子化的指针加减:
fetch_add() 和 fetch_sub(): 这是底层的读-改-写 (RMW) 操作。
T* fetch_add(ptrdiff_t n, std::memory_order = std::memory_order_seq_cst);
- 功能:原子性地给存储的指针加上 n 个元素的偏移量(注意:是元素数量,不是字节数)。
- 返回值:返回指针在执行加法之前的旧值 (T*)。
- 类比:类似于 p += n,但返回的是 p 加之前的值。
T* fetch_sub(ptrdiff_t n, std::memory_order = std::memory_order_seq_cst);
- 功能:原子性地从存储的指针减去 n 个元素的偏移量。
- 返回值:返回指针在执行减法之前的旧值 (T*)。
- 类比:类似于 p -= n,但返回的是 p 减之前的值。
特性:作为 RMW 操作,它们可以接受任何内存顺序标签。返回的是普通的 T* 值,而不是原子对象的引用。
操作符重载 (+=, -=, ++, –):
为了方便使用,std::atomic<T*>
还重载了常见的指针算术操作符。这些操作符提供了更方便、更熟悉的语法,它们内部会调用相应的
fetch_xxx 操作。
p += n / p -= n: - 功能:原子性地加/减 n 个元素。 -
返回值:返回指针加/减之后的新值 (T*)。
++p / --p (前缀): - 功能:原子性地自增/自减 1 个元素。
- 返回值:返回指针自增/自减之后的新值 (T*)。
p++ / p-- (后缀): - 功能:原子性地自增/自减 1 个元素。
- 返回值:返回指针自增/自减之前的旧值 (T*)。
重要限制:这些重载的操作符不能指定内存顺序。它们总是使用默认的、最强的内存顺序 std::memory_order_seq_cst。如果你需要更弱的内存顺序(为了性能),你必须使用 fetch_add 或 fetch_sub 成员函数。 - 何时使用 fetch_xxx:当你需要返回旧值,或者需要指定非默认的内存顺序时。 - 何时使用操作符:当你只需要执行操作,并且对返回值(新值或旧值)符合要求,且满足于默认的 seq_cst 内存顺序时,操作符提供了更简洁的语法。
示例
1 | class Foo{}; |
标准的原子整型的相关操作
这一节介绍了除了 std::atomic<bool> 和
std::atomic<T*>
之外的其他标准原子类型,它们都是原子整数类型(例如
std::atomic<int>,
std::atomic<unsigned long long> 等)。
继承通用操作:它们都支持 std::atomic<bool>
所拥有的基本操作集: - load(), store() - exchange() -
compare_exchange_weak(), compare_exchange_strong() -
不可拷贝/赋值:像所有原子类型一样,它们不能被拷贝构造或拷贝赋值,但可以从对应的非原子整数类型构造或赋值。
- is_lock_free():同样提供此函数来检查是否无锁。
原子整数类型的专属操作
原子整数类型(包括所有 std::atomic
算术运算 (继承自指针类型并扩展)
fetch_add(integral_value, memory_order = seq_cst)
fetch_sub(integral_value, memory_order = seq_cst)
- 功能:原子性地给当前值加上/减去 integral_value。
- 返回值:返回执行加/减之前的旧值。
- 内存顺序:作为 RMW 操作,可以指定任何内存顺序。
+=, -= 操作符
- 功能:原子性地加/减 integral_value。
- 返回值:返回执行加/减之后的新值。
- 内存顺序:总是 memory_order_seq_cst。
++, – 操作符 (前缀和后缀)
- 功能:原子性地自增/自减 1。
- 返回值:
- ++x / –x (前缀):返回新值。
- x++ / x– (后缀):返回旧值。
- 内存顺序:总是 memory_order_seq_cst。
位运算 (原子整数类型专属): 这是原子整数类型区别于原子指针的关键增强。
fetch_and(integral_value, memory_order = seq_cst)
功能:原子性地对当前值执行按位与 (&=) 操作:current_value &= integral_value。
返回值:返回执行按位与之前的旧值。
内存顺序:作为 RMW 操作,可以指定任何内存顺序。
fetch_or(integral_value, memory_order = seq_cst)
功能:原子性地对当前值执行按位或 (|=) 操作:current_value |= integral_value。
返回值:返回执行按位或之前的旧值。
内存顺序:可以指定任何内存顺序。
fetch_xor(integral_value, memory_order = seq_cst)
功能:原子性地对当前值执行按位异或 (^=) 操作:current_value ^= integral_value。
返回值:返回执行按位异或之前的旧值。
内存顺序:可以指定任何内存顺序。
&=, |=, ^= 操作符
功能:原子性地执行相应的按位与/或/异或操作。
返回值:返回执行操作之后的新值。
内存顺序:总是 memory_order_seq_cst。
一般重载运算符都返回新值, 非重载的 fetch_xxx 函数返回旧值, 且返回的都是普通类型, 而不是原子对象的引用。
不过, 原子整数类型没有提供原子化的乘法、除法和移位操作。因为这些操作在并发场景(如原子计数器、标志位掩码)中不如加减和位运算常用。
替代方案:如果确实需要这些复杂操作的原子版本,可以通过在一个循环中使用 compare_exchange_weak() 或 compare_exchange_strong() 来实现。例如,原子乘法可以这样实现(伪代码):
1 | std::atomic<int> atomic_val; |
std::atomic<> 主要类的模板
前面几节讨论的都是 std::atomic<> 针对内置类型(如 bool, int, T*)的特化版本 (Specializations)。这些特化版本拥有丰富的操作集(如 fetch_add, fetch_or 等)。
本节讨论的是 std::atomic<> 的主要类模板 (Primary Class
Template) 本身。这个模板允许你尝试为用户定义类型 (User-Defined Type,
UDT) 创建原子版本,例如
std::atomic<MyStruct>。
UDT 的限制条件
首先, std::atomic<UDT> 对 UDT
有着非常严格的限制。并不是任何类或结构体都可以直接放入
std::atomic<> 中。为了使 std::atomic<UDT>
能够被实例化和使用,UDT 类型必须满足以下条件:
- 必须具有平凡的拷贝赋值运算符
(Trivial Copy Assignment Operator):
- 含义:编译器必须能够自动生成 UDT 的拷贝赋值运算符,而不需要调用任何用户自定义的赋值代码。
- 推论:
- UDT 不能有任何虚函数 (virtual functions) 或虚基类 (virtual base classes)。(因为这些会影响对象的内存布局和赋值方式)。
- UDT 的所有基类和所有非静态数据成员也必须具有平凡的拷贝赋值运算符。
- 本质:这个限制(基本上)允许编译器使用 memcpy() 或等价的按位拷贝操作来实现 UDT 对象的赋值。
- 必须是位可比的 (Bitwise Equality Comparable):
- 含义:UDT 类型的两个对象,如果它们的内存表示是一致的(按位相等,memcmp 结果为0),那么它们就必须逻辑相等 (operator== 结果为 true);反之亦然。
- 本质:这确保了 compare_exchange 操作可以在底层按位比较对象,而不会产生逻辑错误。
为什么有这些严格的限制?这些限制不是随意的,它们背后有深刻的原因,主要与安全和性能有关:
- 防止在原子操作内部调用用户代码:
- 风险:如果
std::atomic<UDT>允许用户自定义的赋值或比较运算符,那么原子操作(如 store 或 compare_exchange)在执行时就必须调用这些用户代码。 - 问题:
std::atomic<UDT>的实现(尤其是在非无锁的情况下)通常会使用一个内部锁来保证原子性(因为没有单一的 CPU 指令能原子地读取、修改和写入整个结构体。)。如果在持有这个内部锁的同时调用了用户代码,就违反了第3章的指导原则(“不要在持有锁时调用用户提供的代码”),这可能导致死锁或性能问题(用户代码阻塞了所有其他访问该原子变量的线程)。 - 解决方案:限制 UDT 只能进行按位拷贝/比较,这样编译器就知道原子操作内部不需要执行任何可能阻塞或产生副作用的用户代码。
- 风险:如果
- 为无锁实现提供可能性:
- 通过将 UDT 限制为可以按位操作的“类 POD (Plain Old
Data)”类型,编译器就有可能为
std::atomic<UDT>生成无锁 (lock-free) 的代码,特别是当 sizeof(UDT) 等于平台原生支持的原子类型大小(如 int, void*)时。 - 对于支持 DWCAS (Double-Word-Compare-and-Swap) 指令的平台,甚至可能对两倍指针大小的 UDT 实现无锁操作。
- 通过将 UDT 限制为可以按位操作的“类 POD (Plain Old
Data)”类型,编译器就有可能为
- 保证 compare_exchange 的行为符合预期:
- 浮点数陷阱:即使 float 或 double 满足按位拷贝/比较的标准,compare_exchange 也可能失败,即使两个浮点数在逻辑上相等(例如,+0.0 和 -0.0 的位模式不同)。
- UDT 陷阱:如果 UDT 定义了自己的 operator==,但其逻辑与 memcmp 不完全一致,compare_exchange(它依赖于位比较)的行为可能会让用户感到意外。
- 解决方案:限制 UDT 必须是“位可比的”,确保 compare_exchange
的行为是基于内存表示的精确匹配。 ####
std::atomic<UDT>的可用操作 (受限接口)
由于 UDT 的通用性以及上述限制,std::atomic
load():原子读取。
store():原子写入 (也支持从 UDT 赋值)。
exchange():原子交换。
compare_exchange_weak():原子比较并交换 (弱)。
compare_exchange_strong():原子比较并交换 (强)。
operator UDT():原子转换为 UDT 类型 (相当于 load())。
operator=(UDT):原子赋值 (相当于 store())。
is_lock_free():检查是否无锁。
注意:std::atomic
关于自定义类型的原子操作, 下面是一些建议:
非法示例:你不能创建
std::atomic<std::vector<int>>或std::atomic<std::string>,因为 std::vector 和 std::string 有非平凡的拷贝构造/赋值(需要管理动态内存)。何时使用 std::mutex:如果你的 UDT 很复杂,或者你需要对其执行比简单替换/比较更复杂的操作,那么
std::atomic<UDT>可能不适合。你应该回到第3章的方法:使用 std::mutex 来保护这个 UDT 对象,并在锁内执行所需的操作。
总结:std::atomic<UDT>
提供了一种将原子性扩展到简单用户定义类型的机制,但前提是这些类型必须像
POD
类型一样,可以安全地进行按位拷贝和比较。对于更复杂的类型或操作,互斥锁仍然是必要的工具。
原子自由函数
除了原子类型的成员函数外,C++标准库还提供了一些原子自由函数(Atomic Free Functions),这些函数可以在不需要锁的情况下安全地操作原子变量。常见的原子自由函数包括:
- std::atomic_load:原子读取。
- std::atomic_store:原子写入。
- std::atomic_exchange:原子交换。
- std::atomic_compare_exchange_weak:原子比较并交换(弱)。
- std::atomic_compare_exchange_strong:原子比较并交换(强)。
这些函数的使用方式与对应的成员函数类似,但它们是以自由函数的形式提供的。它们的第一个参数是一个指向原子变量的指针,在这个原子变量的基础上执行相应的原子操作。例如:
1 | std::atomic<int> a(0); |
为shared_ptr等智能指针提供原子操作
C++11引入了对智能指针(如 std::shared_ptr 和
std::weak_ptr)的原子操作支持。这些操作允许你在多线程环境中安全地共享和管理智能指针,而无需显式使用互斥锁。
- std::atomic_load(std::shared_ptr
为 std::shared_ptr 提供特殊的原子自由函数,是 C++ 标准委员会基于实际编程需求和易用性考虑做出的一个务实的决定。shared_ptr 在并发编程中太重要了,更新它的操作又涉及到复杂的引用计数原子性,因此提供一套标准化的、封装好的原子操作接口,可以显著提高开发效率和程序的健壮性,即使这意味着在类型系统上做出一点“让步”。
同步操作和强制排序
这是C++内存模型中用于建立线程间顺序关系的核心机制。理解它对于理解原子操作如何实现同步至关重要。
同步发生 (Synchronizes-with)
“同步发生” (Synchronizes-with) 是一种只发生在原子类型操作之间的关系。
基本思想:当一个线程对一个原子变量执行写操作,而另一个线程对同一个原子变量执行读操作,并且这个读操作确实读取到了那个写操作写入的值(或者之后的值),那么这两个操作之间就可能建立“同步发生”关系。
更精确的定义是, 一个原子写操作 W(例如 store 或 exchange)在一个原子变量 x 上, 同步于 (Synchronizes-with) 一个原子读操作 R(例如 load 或 exchange)在同一个原子变量 x 上,当且仅当:
- W 和 R 都被“适当标记”(使用了能够建立同步的内存顺序标签);
- 并且,操作 R 读取的值是由 W
直接写入的值;
- 或者,由 W 之后的同一个线程对 x 的后续原子写操作写入的值;
- 或者,由一系列原子“读-改-写” (RMW) 操作(例如 fetch_add, compare_exchange)构成的一个链条,这个链条的起始值来源于 W,而 R 读取的是这个链条中某个操作写入的值。
“同步发生”的意义:建立“线程间先行”关系
“同步发生”本身只是一个技术术语。它真正的威力在于,它是建立“线程间先行发生” (Inter-thread Happens-before) 关系的桥梁。
规则:如果操作 A(在一个线程上)同步于操作 B(在另一个线程上),那么 A 就线程间先行于 B。 > “同步于”之前的是先执行的, “先行于”之前的也是先执行的.
“先行发生”关系保证了操作 A 的所有内存效果(比如写入非原子变量 data)对于操作** B 之后的代码(比如读取 data)是可见且有序的**。
1 | // 线程 A (Writer) |
这里store(true) 同步于 load()
(因为满足上述条件:store 写入 true,load 读取到直接写入的
true,且两者都使用了 seq_cst 内存顺序)。
因此,store(true) 线程间先行于 load()。这就保证了在 load() 返回 true 之后,线程B一定能看到 data.push_back(42) 的效果。
总结:
“同步发生”是 C++ 内存模型定义的一种成对关系,发生在对同一个原子变量的写操作和读操作之间。
它的发生需要满足两个条件:操作被“适当标记”(例如,使用默认的 seq_cst),并且读操作看到了写操作(或其后续)的结果。
“同步发生”是建立跨线程的“先行发生”关系的基础,从而保证内存操作的可见性和顺序性。
它是理解原子操作如何实现线程同步(例如 mutex 的底层原理)的关键。
先行发生 (Happens-before)
这是C++内存模型中最基本、最核心的顺序关系概念。它定义了在一个并发程序中,一个操作的内存效果(例如写入)何时能保证对另一个操作(例如读取)可见。
如果操作 A 先行于 (Happens-before) 操作 B,那么就意味着:
- A 的执行结果(特别是对内存的写入)必须对 B 的执行是可见的。
- 在抽象的执行模型中,A 必须在 B 之前完成。
它是建立程序中基本操作顺序的构建块,尤其是在多线程环境下,用于避免数据竞争并保证程序的逻辑正确性。
如果两个操作(至少一个是写)访问同一个非原子内存位置,并且它们之间没有“先行发生”关系,那么就构成了数据竞争,导致未定义行为。
相比上面的“同步发生”关系,“先行发生”关系更为广泛。它不仅涵盖了线程间的操作,还包括同一线程内的操作顺序。因此,“先行发生”关系主要由两种更基础的关系组合而成:“序前”和“线程间先行”。
“序前” (Sequenced-before):单线程内的顺序
这是我们最熟悉的顺序,它描述了同一个线程内部操作之间的顺序。
基本上,如果源代码中操作 A 出现在操作 B 之前(例如,在不同的语句中,或者由分号、逗号操作符、函数调用顺序等分隔),那么 A 就序前于 (Sequenced-before) B。
1 | // writer_thread() |
因为语句③在语句④之前,所以操作③ 序前于 操作④。
1 | foo(get_num(), get_num()); // 两个 get_num() 调用的顺序是“未指定顺序的” |
“线程间先行” (Inter-thread Happens-before):跨线程的顺序
这是专门用来描述不同线程之间操作顺序的关系。
如何建立? 根据上一节所说, “线程间先行”关系必须通过“同步发生” (Synchronizes-with) 关系来建立。
- 如果操作 A(在线程T1上)同步于 (Synchronizes-with) 操作 B(在线程T2上),那么操作 A 就 线程间先行于 (Inter-thread Happens-before) 操作 B。
建立线程先行的主要机制有两点:
原子操作:一个(适当标记的)原子写操作 W 同步于一个(适当标记的)原子读操作 R(如果 R 读到了 W 或其后续写入的值)。
互斥量:mutex.unlock() 操作(内部是一个原子 release 操作)同步于后续在同一个 mutex 上的 lock() 操作(内部是一个原子 acquire 操作)。
并且, “先行发生”关系是可传递的,这一点极其重要。
- 如果 A 先行于 B,并且 B 先行于 C,那么 A 就先行于 C。
这允许同步效果“穿透”多个操作和线程, 例如:
1 | // 线程 A (Writer 1) |
这也是”序前”和”线程间先行”关系结合使用的一个典型例子, 它展示了如何通过多个线程和操作建立起内存可见性。
原子操作的内存顺序(Memory Order)
这是这一整章最核心、最复杂的部分。它解释了你在执行原子操作时,可以附加的不同“内存顺序标签” (Memory Ordering Tags),以及这些标签如何精确地控制线程间的同步行为和内存可见性。
为什么需要内存顺序?因为如果你不指定,所有原子操作都使用 std::memory_order_seq_cst。这提供了最强、最直观的保证(排序一致性),但可能是最慢的。
不同的CPU架构对内存操作的排序有不同的硬件支持。强制执行强顺序(如 seq_cst)在某些CPU(特别是多核、弱排序架构如 ARM)上可能需要昂贵的CPU指令(内存屏障/栅栏)来确保全局同步。
因此, C++提供更弱的内存顺序选项,允许专家级程序员在保证程序正确性的前提下,放松排序约束,从而减少同步开销,提升性能。
六种内存顺序标签与三种模型
C++11 定义了六种内存顺序标签,它们可以归纳为三种不同的内存模型:
排序一致 (Sequentially Consistent) 模型
- 标签:std::memory_order_seq_cst
获取-释放 (Acquire-Release) 模型
标签:std::memory_order_acquire (用于加载或 RMW)
标签:std::memory_order_release (用于存储或 RMW)
标签:std::memory_order_acq_rel (用于 RMW)
自由 (Relaxed) 模型
- 标签:std::memory_order_relaxed
排序一致序列 (Sequentially Consistent - seq_cst)
标签:std::memory_order_seq_cst
保证:最强保证。所有被标记为 seq_cst 的原子操作,在所有线程看来,都存在一个单一的、全局的总执行顺序。
行为:程序的行为就像所有线程的操作被简单地交错 (interleaved) 在一个单一的时间线上执行一样。这符合我们对并发程序的直观想象。
易于推理:这是最容易理解的模型。你可以通过列出所有可能的交错执行顺序来分析程序的行为。
禁止重排:编译器和CPU不能对 seq_cst 操作相对于其他 seq_cst 操作进行重排。
同步关系:一个 seq_cst 的存储操作 W 同步于一个 seq_cst 的加载操作 R(如果 R 读取了 W 或其后续写入的值)。这会建立线程间先行发生 (Inter-thread Happens-before) 关系。
全局顺序:seq_cst 提供的保证超越了简单的“同步发生”。它强制所有 seq_cst 操作都必须纳入那个单一的全局顺序中。
下面是一个示例,展示了 seq_cst 的行为:
1 | std::atomic<bool> x,y; std::atomic<int> z; |
在上述的例子中, 断言 5 永远不会失败。为什么?因为 seq_cst 保证了一个单一全局顺序。 - 首先, 线程 A 和线程 B 的 store 操作 (1 和 2) 会以某个顺序出现在全局序列中。可能是 1 在 2 之前,或者 2 在 1 之前。 - 假设 1 在 2 之前,那么线程 C 的 x.load 操作 (3) 会看到 x 为 true,从而跳出循环, 然后它会检查 y。如果 2 已经执行了,那么 y 也为 true,z 会被递增。 - 当然也有可能检查 y 还没执行, 从而线程 C 不会递增 z. 但是此时线程 D 会一直阻塞在 y.load 处, 直到线程 B 执行完毕后 y 为 true, 然后线程 D 会检查 x, 由于线程 A 已经执行完毕 x 为 true, z 也会被递增. - 反之亦然,如果 2 在 1 之前,线程 D 先跳出循环并检查 x,假设 x 也为 true,z 也会被递增。 - 假设 x 还没执行完毕, 线程 C 会阻塞在 x.load 处, 直到线程 A 执行完毕后 x 为 true, 然后线程 C 会检查 y, 由于线程 B 已经执行完毕 y 为 true, z 也会被递增. - 因此, 无论哪种情况, z 最终都会被递增至少一次, 断言 5 永远不会失败. - 根本原因就在于 seq_cst 保证了一个单一的全局顺序, 使得至少有一个线程能够看到两个 store 操作的结果. - 代价就是, 在多核弱排序 CPU 上,seq_cst 可能需要昂贵的全局同步指令。
自由序列 (Relaxed - relaxed)
以下都属于非排序一致模型(踏出 seq_cst 的世界). 一旦你放弃 seq_cst,以下直觉不再成立:
没有单一全局顺序:不同线程可以对其他线程的操作看到不同的相对顺序。
重排是可能的:编译器和CPU(缓存、存储缓冲区)可以更自由地重排指令,只要遵守每个变量自身的修改顺序和明确的同步关系。
线程不必“同意”:不同线程可以对同一组操作看到不同的顺序。
对于自由序列 (relaxed) , 编译器带来的同步保证是最少的:
标签:std::memory_order_relaxed
保证:最弱保证。只保证操作本身的原子性(不会发生数据撕裂)。
行为:
- 没有同步关系 (synchronizes-with)。
- 没有强制的跨线程顺序。
- 唯一的顺序是:同一个线程对同一个原子变量的操作不能被重排(符合该变量的修改顺序)。
重排:不同变量上的 relaxed 操作可以被编译器或CPU自由地重排。
1 | std::atomic<bool> x,y; std::atomic<int> z; |
对于上述例子, 断言 5 可能会失败。为什么?因为 relaxed 不保证任何跨线程的顺序。 - 在线程 A 中,x.store ① 序前于 y.store ②。 - 但是,因为使用了 relaxed,这两个操作之间没有同步关系传递给线程 B。 - 线程 B 的 y.load ③ 可能先于 x.load ④ 看到 true(例如,y 的更新先到达 B 的缓存)。 - 此时,x.load ④ 可能仍然看到 false(因为 x 的更新还没到达 B 的缓存,或者指令被重排了)。 - 因此,z 可能保持为 0。 - 上面是线程 A 的写-写(Store-Store)重排(更常见)。或者还有一种可能是线程 B 的读-读(Load-Load)重排,线程 B 的指令被重排,使得 x.load ④ 先于 y.load ③ 执行,此时 x.load ④ 看到 false,导致 z 保持为 0。 - 逻辑上, 线程 B 的 if 语句依赖于 while 循环的结果, 但由于使用了 relaxed, 编译器/CPU 可以在取值的时候忽略这种依赖关系, 导致重排发生(当然执行时仍然是按逻辑顺序执行的). - 程序执行顺序(控制依赖):线程B不会在 while 循环 (3) 退出之前去执行 if 语句 (4) 的逻辑判断。这是由控制依赖保证的,这个逻辑顺序是可靠的。 - 内存访问顺序(relaxed 导致的问题):现代CPU是“乱序执行”的。当CPU在 while 循环 (3) 处等待 y 变为 true 时,它会“空闲”。它会“向前看”,发现哦,我等下(if 里)需要 x 的值。由于 (4) 的 x.load 也是 relaxed 的,CPU认为这个读取操作没有限制,它就可以决定“提前”去内存中把 x 的值取回来(发起 Load 指令),尽管此时 while 循环还没退出。
用途:relaxed 通常用于不需要同步的场景,例如简单的原子计数器(只关心最终结果,不关心中间状态的可见性),或者需要配合栅栏 (fences) 或其他更强顺序的操作一起使用。
获取-释放序列 (Acquire-Release)
这是在性能和易用性之间取得平衡的主要模型。它不提供全局顺序,但允许你在特定操作之间建立成对的同步关系。
标签:
- std::memory_order_release (释放):用于存储操作 (store) 或读-改-写操作 (exchange, fetch_add 等)。
- std::memory_order_acquire (获取):用于加载操作 (load) 或读-改-写操作。
- std::memory_order_acq_rel (获取并释放):仅用于读-改-写操作。
核心规则 (配对): 一个 release 存储操作 W 同步于一个 acquire 加载操作 R(在同一个原子变量上),如果 R 读取了由 W 或 W 之后的 RMW 链写入的值。
效果(先行发生):这种同步会建立线程间先行发生关系 (W happens-before R)。
这保证了:在 W (release store) 之前的所有内存写入(包括非原子写入),对于在 R (acquire load) 之后的所有内存读取(包括非原子读取)都是可见且有序的。
- 可以理解为:release 操作“发布”了它之前的内存效果,而 acquire 操作“获取”了这些效果。
- release 操作在它之后建起了一个“屏障”,防止它之前的操作被重排到它之后。而 acquire 操作在它之前建起了一个“屏障”,防止它之后的操作被重排到它之前。
1 | std::atomic<bool> x,y; std::atomic<int> z; |
在上述例子中, 断言 5 永远不会失败。为什么?因为 release-acquire 建立了同步关系。 - 在线程 A 中,x.store ① 序前于 y.store ②, 并且 y.store ② 是一个 release 操作。 - 在线程 B 中,y.load ③ 是一个 acquire 操作, 它读取到了 y.store ② 写入的 true。 - 因此,y.store ② 同步于 y.load ③,建立了线程间先行关系 (② happens-before ③)。 - 由于 ① 序前于 ②,并且 ② 先行于 ③,根据传递性,① 也先行于 ③。 - 这保证了 x.store ① 的效果对于 x.load ④ 是可见的。因此,x.load ④ 一定会看到 true,z 会被递增。
还有下面这个例子:
1 | std::atomic<int> data[5]; |
同时我们也可以看到, 在 thread_1 中对 data 数组的写入使用的是 relaxed 内存顺序,thread_3 中对 data 数组的读取也使用的是 relaxed 内存顺序。这是安全的,因为获取-释放模型只要求在同步点(sync1 和 sync2)使用 acquire 和 release 建立屏障,而在其他地方可以使用 relaxed,从而提高性能。
而读-改-写 (RMW) 的 acq_rel,
例如my_atomic.fetch_add(1, std::memory_order_acq_rel);,
这个操作同时扮演 acquire 和 release
的角色:
- 它与之前对 my_atomic 的 release 存储同步(获取语义)。
- 它与之后对 my_atomic 的 acquire 加载同步(释放语义)。
这对于实现锁或在 RMW 链中传递同步非常有用。
总结
内存顺序是 C++ 原子编程的核心,它允许你在程序的正确性和性能之间进行权衡:
- seq_cst:最简单,最安全,但可能最慢。提供全局顺序。
- acquire-release:性能更好,需要仔细配对 release 和 acquire。提供成对同步和先行发生。
- relaxed:最快,但最危险。只保证原子性,没有同步。通常需要配合其他更强顺序或栅栏使用。
释放队列与同步 (Release Sequences)
这一节是 “同步发生” (Synchronizes-with) 定义的一个重要扩展和深化。基本的“同步发生”主要描述了单个“写”操作与单个“读”操作之间的直接关系。而“释放序列”则解释了同步关系如何通过一系列的“读-改-写” (Read-Modify-Write, RMW) 操作传递下去。
考虑以下场景:
- 线程 A 执行
store(value_A, release)。(W) - 线程 B 执行
old_B = fetch_add(1, acquire)。假设它读取了value_A,写入了value_B。(RMW1) - 线程 C 执行
old_C = fetch_add(1, acquire)。假设它读取了value_B,写入了value_C。(RMW2) - 线程 D 执行
value_D = load(acquire)。假设它读取了value_C。(R)
没有释放序列规则会怎样?
- W 同步于 RMW1 (因为 RMW1 读取了 W 的值)。
- 但是,RMW1 本身是 acquire 操作,它没有 release 语义。所以,W 的同步效果似乎在 RMW1 这里就中断了。
- RMW2 读取的是 RMW1 的值,而 RMW1 没有 release 语义,所以 RMW2 与 W 似乎没有同步关系。
- 同理,R 读取的是 RMW2 的值,似乎也与 W 没有同步关系。
这意味着只有线程 B 能保证看到线程 A 在 W 之前的写入,而线程 C 和 D 则没有保证!这显然不符合我们对原子计数器等模式的期望。
然而, “释放序列”规则解决了上述问题。一个以写操作 W 开始的释放序列包含:
起始点:写操作 W 本身,它必须被标记为 memory_order_release, memory_order_acq_rel, 或 memory_order_seq_cst (即具有释放语义的操作)。
链条:一系列(零个或多个)在同一个原子变量上执行的原子 RMW 操作(例如 fetch_add, compare_exchange, exchange 等)。
链接条件:链条中的每一个 RMW 操作,都必须读取由序列中前一个操作(起始的 W 或前一个 RMW)写入的值。
RMW 的内存顺序:链条中的 RMW 操作可以使用任何内存顺序(甚至是 memory_order_relaxed!)。
- 起始的 store(release) 已经付出了建立 Release 屏障的代价, 因此它们不需要再建立屏障。
- 它们只需要保证逻辑连接性(即读取了前一个操作写入的值),从而证明自己是释放序列的一部分。
释放序列这一规则的关键点在于:释放序列中的所有 RMW 操作都被视为具有“释放”语义,即使它们本身并没有被标记为 release。这是 C++ 内存模型赋予原子 RMW 操作的一个强大“特性”
换句话说:只要 load(acquire) 读取到的值是那个 release 链条上的任何一环,它就与链条的起始点 (store(release)) 建立了同步关系!
下面是一个示例,展示了释放序列的作用:
场景:一个生产者线程 populate_queue 准备数据,然后用 count.store(release) (①) 告知消费者数据量。多个消费者线程 consume_queue_items 通过 count.fetch_sub(acquire) (②) 来原子性地获取一个项目索引。
1 | std::vector<int> queue_data; |
- 生产者调用 store(release) ①。这是释放序列的起始点 W。
- 消费者 C1 调用 fetch_sub(acquire) ②。假设它读取了 W 写入的值 N,并将
count 更新为 N-1。
- 根据释放序列规则(链条长度为0),W 同步于 C1 的 fetch_sub。
- 因此,W 先行于 C1 的 fetch_sub。C1 可以安全地访问 queue_data[N-1] (④)。
- C1 的 fetch_sub 操作本身(即使是 acquire)现在成为释放序列 W 的一部分。
- 消费者 C2 调用 fetch_sub(acquire) ②。假设它读取了由 C1 的 fetch_sub
写入的值 N-1,并将 count 更新为 N-2。
- C2 的 load 读取了由 C1 的 RMW 操作写入的值,而 C1 的 RMW 操作是释放序列 W 的一部分。
- 根据释放序列规则,起始的 store W (①) 同步于 C2 的 fetch_sub!
- 因此,W 先行于 C2 的 fetch_sub。C2 也可以安全地访问 queue_data[N-2] (④)。
尽管 C1 和 C2 之间没有直接的同步关系(因为 C1 的 fetch_sub 是 acquire,不是 release), 但它们都与最初生产者的 store(release) 建立了同步关系。
这保证了所有成功获取到项目索引(item_index > 0)的消费者,都能看到生产者在 store(release) 之前对 queue_data 的写入。这正是我们期望的行为!
总结:
“释放序列”是 C++ 内存模型的一个重要规则,它允许同步关系通过一系列原子 RMW 操作(即使它们本身没有 release 语义)进行传递。
它确保了在一个由 release 写操作启动、后续由 RMW 操作(如原子计数器增减)延续的链条中,任何读取该链条上任何值的 acquire 读操作,都能与最初的 release 写操作建立同步。
这对于实现高效的多生产者/多消费者模式(例如使用原子计数器管理共享资源)至关重要。
栅栏 (Fences - std::atomic_thread_fence)
在之前,我们学习了如何将内存顺序标签(如 acquire, release, relaxed)附加到具体的原子操作(如 load, store)上。
本节介绍了一种与之类似但不同的强制排序机制:原子栅栏 (Atomic Fences),也常被称为内存屏障 (Memory Barriers)。
独立于操作:栅栏不是作用于某一个特定的原子变量或操作,而是作为一个独立的指令存在于程序的执行序列中。
“画一条线”:你可以把它想象成在代码中“画了一条线”。这条线具有特定的内存顺序属性(例如 release 或 acquire)。
- 栅栏之前的所有操作都必须在栅栏之前完成,而栅栏之后的所有操作都必须在栅栏之后开始。
- 之前的内存标签也是同样的理解,但栅栏的作用范围更广,因为它不依赖于具体的原子变量。
强制排序:栅栏强制了它之前的操作和它之后的操作之间的相对顺序,防止编译器或CPU将它们重排越过这条“线”。
主要用途:通常与 memory_order_relaxed 的原子操作配合使用,在需要同步的关键点插入栅栏来强制建立顺序,而在其他地方则允许最大的执行自由度(和性能)。
C++ 中的栅栏:std::atomic_thread_fence
函数原型:
1 | void std::atomic_thread_fence( std::memory_order order ); |
参数 order:栅栏可以接受所有六种内存顺序标签:
memory_order_relaxed:这个栅栏什么也不做 (no-op)。
memory_order_release:释放栅栏。它确保在此栅栏之前的所有内存写入(原子和非原子)都不能被重排到栅栏之后,并且这些写入对于后续看到相关 release 效果的 acquire 操作/栅栏是可见的。
memory_order_acquire:获取栅栏。它确保在此栅栏之后的所有内存读取(原子和非原子)都不能被重排到栅栏之前,并且能看到由与之同步的 release 操作/栅栏所“释放”的写入。
memory_order_acq_rel:获取-释放栅栏。同时具有 acquire 和 release 的效果。
memory_order_seq_cst:排序一致栅栏。具有 acq_rel 的效果,并且还参与到所有 seq_cst 操作的单一全局总顺序中。
基本和之前介绍的内存顺序标签的语义是一样的,只不过栅栏是独立于具体操作的。
栅栏如何建立同步
栅栏本身不会“凭空”建立同步。它们需要依赖原子变量的读写作为“信使”来传递同步信号。
核心规则: 一个 release 栅栏 F1(在线程 T1 中) 同步于 (Synchronizes-with) 一个 acquire 栅栏 F2(在线程 T2 中), 当且仅当:
- 存在一个原子变量 A;
- T1 在栅栏 F1 之后执行了一个对 A 的原子写操作 W(可以是 relaxed);
- T2 在栅栏 F2 之前执行了一个对 A 的原子读操作 R(可以是 relaxed);
- 并且,R 读取了由 W(或由 W 开始的释放序列中的某个操作)写入的值。
简化理解:如果线程 T2(通过 acquire 栅栏)能够“看到”(通过 relaxed load)一个由线程 T1 在 release 栅栏之后写入的值(通过 relaxed store),那么这两个栅栏就同步了。
类似于原子变量带着 F1 边界内的信息出发, 然后被 F2 边界内的读取操作接收,从而建立起同步关系。
这个例子展示了如何用栅栏和 relaxed 操作模拟使用 acquire-release 操作达到的效果。
1 | std::atomic<bool> x,y; |
根据栅栏同步规则,release 栅栏 (②) 同步于 acquire 栅栏 (⑤)。这建立了栅栏之间的线程间先行发生关系:F1 happens-before F2。建立先行链如下:
- x.store (①) 序前于 F1 (②) [在 T1 内]
- F1 (②) 先行于 F2 (⑤) [跨线程,通过同步]
- F2 (⑤) 序前于 x.load (⑥) [在 T2 内]
- 传递性:因此,x.store (①) 先行于 x.load (⑥)。x.load (⑥) 必须看到 true,断言 ⑦ 不会触发。
如果没有栅栏 ② 和 ⑤ ,x.store ① 和 x.load ⑥ 之间没有先行关系,x.load ⑥ 可能看到 false,断言可能触发。
注意事项: - 栅栏的位置至关重要:操作必须位于栅栏的“正确一侧”才能参与排序。如果将 x.store 移动到 release 栅栏之后,那么栅栏就不再保证 x.store 对 x.load 的可见性了。
需要成对出现:通常需要一个 release 栅栏和一个 acquire 栅栏(或 acquire 操作)配对才能建立同步。
栅栏与其他操作的交互:栅栏不仅与 relaxed 操作交互,也与更强顺序的操作交互。例如,一个 acquire 栅栏也能与之前的 release store 同步。
栅栏是全局的(相对线程内):一个栅栏会影响该线程中所有位于其前/后的原子(甚至非原子)操作的排序,而不仅仅是某一个变量。
总之, 原子栅栏 (std::atomic_thread_fence) 提供了一种独立于具体原子操作的内存排序机制。它们像代码中的“屏障”,限制编译器和 CPU 的重排。
release 栅栏与 acquire 栅栏可以通过跨越栅栏的原子读写(通常是 relaxed)建立同步和先行发生关系。
它们常用于需要对多个 relaxed 操作进行分组排序,或者需要同步原子操作与非原子操作的场景。
栅栏提供了非常细粒度的控制,但也需要非常仔细的推理来确保正确性。
原子操作对非原子的操作排序
这是理解 C++ 内存模型和原子操作的关键所在。它揭示了一个极其重要的原理:原子操作(特别是带有 acquire 和 release 语义的)不仅能同步原子变量本身,还能强制对其周围的非原子操作建立跨线程的顺序关系。这一原理正是 std::mutex、std::condition_variable 等高级同步原语能够保护非原子共享数据的根本原因。
我们知道,对非原子变量(如普通的 bool x)进行无同步的并发读写是数据竞争,导致未定义行为。
然而, 我们可以利用原子操作作为“同步点” (Synchronization Points)。只要确保对非原子变量的访问被这些“同步点”恰当地“夹在中间”,就可以为这些非原子访问建立先行发生 (Happens-before)** 关系,从而避免数据竞争。原理在于:
- 如果非原子操作 A 序前于 (Sequenced-before) 原子同步操作 S1 (在同一线程)。
- 并且 S1 同步于 (Synchronizes-with) / 先行于 (Happens-before) 另一个原子同步操作 S2 (在另一线程)。
- 并且 S2 序前于 非原子操作 B (在 S2 所在的线程)。
- 那么,通过传递性,非原子操作 A 就先行于非原子操作 B。 如上面代码所示, 尽管 x 本身是非原子的,但由于原子栅栏强制了顺序,对 x 的访问没有数据竞争。读取操作 (⑦) 保证能看到写入操作 (②) 的结果 true,因此断言 (⑧) 不会触发。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
bool x = false; // 1. x 现在是一个非原子变量
std::atomic<bool> y; // y 仍然是原子变量,用作“信使”
std::atomic<int> z;
void write_x_then_y()
{
x = true; // 2. 非原子写 (A)
std::atomic_thread_fence(std::memory_order_release); // 3. 释放栅栏 (S1)
y.store(true, std::memory_order_relaxed); // 4. 原子写 y (W - 写信)
}
void read_y_then_x()
{
while (!y.load(std::memory_order_relaxed)); // 5. 原子读 y (R - 收信)
std::atomic_thread_fence(std::memory_order_acquire); // 6. 获取栅栏 (S2)
if (x) // 7. 非原子读 (B)
++z;
}
int main()
{
// ... setup and join ...
assert(z.load() != 0); // 8. 断言不会触发
}
std::mutex 的工作原理
这正是 std::mutex 能够保护非原子数据的核心机制.
一个 std::mutex
内部(至少在概念上)包含一个带有内存标签的原子标志(类似于
std::atomic_flag 或
std::atomic
- mutex.unlock() (释放操作):当你调用 unlock()
时,它内部会执行一个原子写操作(例如 flag.clear() 或
flag.store(false))来标记锁为“可用”。
- 这个原子写操作带有 std::memory_order_release 语义。
- 效果:release 语义保证,在 unlock() 调用之前的所有内存写入(包括对互斥量保护的非原子数据的写入)都不能被重排到 unlock() 之后,并且这些写入的效果对于下一个成功 lock() 该互斥量的线程是可见的, 像是在 unlock() 之后插入了一个“屏障”,防止重排。
- mutex.lock() (获取操作):当你调用 lock()
时,它内部会执行一个原子读-改-写操作(例如循环 flag.test_and_set() 或
compare_exchange_weak())来尝试将锁标记为“被持有”。
- 这个(或这些)原子操作带有 std::memory_order_acquire 语义。
- 效果:acquire 语义保证,在 lock() 调用成功之后的所有内存读取(包括对互斥量保护的非原子数据的读取)都不能被重排到 lock() 之前,并且能够看到上一个调用 unlock() 的线程所“释放”的内存写入, 像是在 lock() 之前插入了一个“屏障”,防止重排。
串联起来: - 线程 T1 调用 lock() (Acquire S1)。 - T1 修改非原子数据 D。 - T1 调用 unlock() (Release S2)。 - 线程 T2 调用 lock() (Acquire S3)。由于 S2 释放了锁,S3 现在可以成功获取锁。 - T2 读取非原子数据 D。 - T2 调用 unlock() (Release S4)。
同步链: - T1 对 D 的修改序前于 S2 (Release Unlock)。 - S2 (Release Unlock) 同步于 S3 (Acquire Lock) —— 因为 S3 观察到了 S2 释放锁的效果。 - S3 (Acquire Lock) 序前于 T2 对 D 的读取。 - 结论:T1 对 D 的修改 先行于 T2 对 D 的读取!没有数据竞争发生。
例如, 一个可能的实现如下:
1 | // 共享的锁状态:0=未锁定, 1=已锁定 |
- simple_lock() (Acquire):自旋等待,直到它能将 lock_status 从 0 变为 1。成功时,它使用的是 Acquire 标签,这保证了它能看到配对的 Release 操作之前的所有内存写入, 从而类似于在 lock() 之前插入了一个“屏障”,防止重排, 将临界区的修改挡在 lock() 之后, 只有在 lock() 之前的代码才能看到这些修改。
这就是为什么互斥量能保证锁内代码的内存可见性。