06 原子操作和内存模型
接下来该进入本专题最难的部分了,也就是 原子操作和内存模型,本节我们会介绍理论部分,比较抽象,务必理解透彻再进入后面的实践部分。
原子类型
我在 同步原语与死锁的最开始举了一个例子,里面我们分析了 i++
这么一个简单的表达式本质上是有三步操作的,即 从内存中读取,在寄存器中加一,再写回内存,这些操作需要时间,因此可能被其他线程打断,这也是共享区的普通变量必须加锁来维持同步的原因。
但我们也多次强调加锁会造成一部分性能开销,那我们就假设了,如果有一种类型,它能够保证在多线程环境下,对它的操作即不会被拆分,也不会被打断,那不就不需要锁了,标准库为我们提供了这种类型,也就是 原子类型。
原子类型是我们进行无锁编程的基础,它可以把一个普通类型的操作封装为一个原子操作,这种操作是指一个不可被中断的操作序列,从其他线程的角度看,这个操作 要么已经完全发生,要么还没有发生,不存在任何中间状态。
此时可以分析一下,如果 i 是一个原子变量,那它执行自增的操作在其他线程看来是只有一步操作的,因此自然不会被打断,这就是原子性:原子类型的操作都是一个最小单位,无法被打断。
构造
首先先看一下主模板的标准库定义:
template< class T >
struct atomic;
也就是说我们只需要按照 std::atomic<T>
的格式即可指定一个原子类型,此处的 T 具有一些约束,它必须保证如下表达式都为 true:
std::is_trivially_copyable<T>::value
std::is_copy_constructible<T>::value
std::is_move_constructible<T>::value
std::is_copy_assignable<T>::value
std::is_move_assignable<T>::value
std::is_same<T, typename std::remove_cv<T>::type>::value
原子类型既不可复制也不可移动,且必须在定义时赋值。除了上述的主模板,其实还有几个特化版本,这些特化版本具有一些本版本的函数调用,主要说来有如下 3 种,写的比较省略,能理解意思即可:
// 部分特化,比如指针类型的 fetch_add 可以处理指针的偏移
struct atomic<T*>;
struct atomic<std::shared_ptr<T>>;
struct atomic<std::weak_ptr<T>>;
// 整数类型的特化,此处包含所有 <cstdint> 进行 typedef 的所有类型,如:
struct atomic<unsigned long>;
// 浮点类型的特化,标准布局,不会有未定义行为,如:
struct atomic<long double>;
所有标准类型都具有一个别名,如 std::atomic<int>
可以写作 std::atomic_int
,在 cpp23 以后,也可以使用 _Atomic(T)
这个宏来替代上述所有行为。
除了标准原子类型,其实还有一个 atomic_flag,这个我们在介绍自旋锁的时候已经使用过了,可以结合文档看一下这个内容。
成员函数
对于公共成员函数,有如下几个:
- (constructor):构造一个原子对象,可指定初始值
- is_lock_free:检查原子对象是否无锁,因为有些平台上原子对象的内部实现是基于锁的
- store:以原子方式用非原子参数替换原子对象的值
- operator=:将值存储到原子对象中,和 store 功能一样
- load:以原子方式读取原子对象的值
- operator T:从原子对象中加载值,和 load 功能一样
- wait:传入一个值,如果原子变量的值和传入值相等,则挂起当前线程,否则直接返回
- notify_one:唤醒一个等待的线程
- notify_all:唤醒所有等待的线程
- exchange:原子地替换原子对象的值并返回先前的值
- compare_exchange_weak/compare_exchange_strong:需要两个参数,期望值和新值。
- 如果原子变量当前值与传入期望值相等,则给原子变量写入新值并返回 true,否则将期望值更新为原子变量的值并返回 false
- strong 版本只有不相等才会返回 false,适合绝大部分场景,但是 weak 版本有时候即使相等也返回 false,不是很稳妥,但在部分平台性能更好
除此之外,还有一个静态的成员变量:
- is_always_lock_free:它可以在编译时告诉你,这个原子类型在 所有支持的平台上是否总是无锁的。
对于整数、浮点数和指针类型,他们又多了如下几个函数:
- fetch_add / fetch_sub:给原子变量加上/减去一个值,并返回操作 之前 的旧值。
- operator+= / operator-=:给原子变量加上/减去一个值,并返回操作 之后 的新值。
整数和指针类型又又多了几个函数:
- fetch_max / fetch_min:原子地将原子变量的值与给定值比较,更新为两者中的最大/最小值,并返回操作之前的旧值。
- operator++ / operator--:和普通变量一样,可以是前置自增/自减,也可以是后置自增/自减。
对于整数类型,又又又多了几个函数:
- fetch_and / fetch_or / fetch_xor:原子地将原子变量的值与给定值进行按位与/或/异或操作,并返回操作之前的旧值。
- operator&= / operator|= / operator^=:原子地将原子变量的值与给定值进行按位与/或/异或操作,并返回操作之后的新值。
内存序
引入原因
现代计算系统为了追求极致性能,存在 两层主要的指令重排和可见性 问题:
编译器重排:在编译阶段,编译器会分析代码,只要不改变单线程程序的最终结果,它就有权重新安排指令的顺序以提高效率,如更好地利用 CPU 流水线。
CPU 硬件重排:现代 CPU 采用乱序执行引擎,CPU 会读取一连串的指令,但并不会严格按照程序顺序执行,如果一条指令需要等待,CPU 会跳过它,先执行后面不相关的、已准备好的指令。
此外,我们在同步原语的章节还介绍了多级缓存架构,除此之外还有几种设计,它们共同带来了 可见性延迟 问题:
多级缓存:每个 CPU核心都有自己私有的 L1、L2 缓存,它们共享一个 L3 缓存和主内存。
Store Buffer:当一个核心执行一个写操作时,为了不阻塞后续指令,它通常不会立即将数据写入 L1 缓存,而是会把要写入的地址和值先放入一个私有的 写缓冲区,也就是 Store Buffer 中,然后继续执行,这个写入操作会在稍后的某个时间点被异步地提交到 L1 缓存。
缓存一致性协议,即MESI:这个协议确保一个核心对某个内存地址的写入最终对其他核心是可见的,但它不保证何时以及以何种顺序可见,我们稍后会详细介绍。
但是正是由于这些优化的存在,单线程来说是没有问题的,但在多线程环境下完全可能发生如下情况:Core B 看到的 Core A 的内存操作顺序,与 Core A 实际的代码执行顺序完全不同。
因此引入了 内存序 的概念,它的作用就是在指定位置插入 内存屏障。内存屏障是一种特殊的 CPU 指令,它强制编译器和 CPU 遵守特定的顺序和可见性规则。
各个序列详解
std::memory_order_relaxed
此内存序只保证操作本身的原子性,但从屏障上来说,它是 无屏障 的,编译器可以自由地将此原子操作前后的内存操作进行重排,CPU 也不会因为这个操作而清空它的 Store Buffer 或停止乱序执行。
这是开销最小的操作。在 Core A 上的一个 relaxed store,其结果对 Core B 的可见时间是不确定的。
std::memory_order_release
此内存序具有一个 写屏障 或 释放屏障,即 Release Fence
。它规定:在同一线程内,在该 release
操作之前的所有内存读写,都必须对其他核心可见,然后这个 release
操作本身的效果才能变得可见,即 此操作前的内存读写不会被重排到此操作之后。
当 CPU 执行一个 release-store 时,它会发出指令,强制将当前核心的 Store Buffer 中的所有待处理写入操作全部提交到其 L1 缓存中,只有在这些旧的写入都提交完毕后,这个 release-store 本身才会被提交,这阻止了程序顺序中在它之前的读写被重排到它之后。
std::memory_order_acquire
此内存序具有一个 读屏障 或 获取屏障,即 Acquire Fence
。它规定:在同一线程内,该 acquire
操作必须先完成,然后程序顺序中在它之后的所有内存读写才能被执行,即 此操作后的内存读写不会被重排到此操作之前。
当 CPU 执行一个 acquire-load 时,它会确保在此操作之后的所有读写指令,必须等待这个 acquire-load 从缓存或主存中获取到值之后才能开始执行,这阻止了程序顺序中在它之后的读写被重排到它之前。
因此,我们常常配套使用 acquire 和 release 操作来实现同步,前者可以确保所有操作都在它后面,后者可以确保所有操作都在它前面,那数据自然不存在可见性的延迟。
std::memory_order_acq_rel
此内存序同时具备 acquire
和 release
的双重特性,具有 Full Fence
,可以确保对单个原子类型的操作符合程序正常执行顺序。
对于 RMW 操作,它包含一个读和一个写。acq_rel
保证其读的部分具有 acquire 语义,其写的部分具有 release 语义,这形成了一个完整的屏障,有效地阻止了该操作与任何其他内存操作的重排。
std::memory_order_seq_cst
该内存序严格确保了 顺序一致性。不止具有 Full Fence
,还具有 Global Ordering
,即 全局总排序,所有线程都会以相同的顺序观察到所有 seq_cst
操作,这不只是最强的内存序,也是原子操作默认的内存序。
它不仅刷新 Store Buffer 并阻止本地重排,还需要与其他核心进行通信,以确保该操作在全局可见性顺序中处于一个确定的位置,这种全局同步的开销可能非常大,因为它严重限制了 CPU 的乱序执行能力。
上述五种内存序对程序的影响程度逐渐加重,从基本的原子性、单屏障、全屏障到全局一致性,因此性能上也是逐渐退步的,虽然从理论上说不用 seq_cst
是最好的,但是说实话,大部分情况下我们的程序瓶颈基本都不会在于这个内存序上的设计,而是其他宏观设计方面,因此很多时候采用默认内存序即可,但是我们这个专题作为学习,在后面的示例代码处会尽可能的组合内存序来实现并发控制。
内存模型
内存模型 定义了在多线程程序中,一个线程的读写操作在什么条件下对另一个线程可见,主要回答了两个问题:可见性和如何排序,我们只需要遵守内存内存模型的规则,即可实现可移植的代码,主要包含三个方面。
内存位置
内存位置是内存模型中 可以被独立访问的最小单元,只有并发访问同一个内存位置,且至少一个是写,才是数据竞争。
这个概念在标准类型中毫无疑问,我们访问如下类型的两个成员绝对不会有数据竞争,因为标准类型都独自占有一个内存位置:
struct Test {
int a;
int b;
}
需要特别注意的是 位域 这种数据类型:
struct Test {
unsigned int flag1 : 1;
unsigned int flag2 : 1;
unsigned int flag3 : 30;
};
在标准中,相邻的位域会被打包成同一个内存位置,比如这个例子中,三个成员变量都是访问同一个内存位置,这与编写代码的直觉不同,需要特别注意。
这个内存位置与内存对齐是不一样的,可以自行复习一下内存对齐的内容。
改动序列
对于任意一个对象来说,其改动序列是 程序针对该对象的所有写操作的总集合,该集合按时间排序。虽然在理论上来说,cpp标准会为所有的标量对象都定义一个改动序列,但在实践中,这个概念的作用一般会根据其原子性而有所区别。
非原子对象
这个也是我们前面几节一致在讨论的:对非原子对象的并发访问受到 数据竞争 规则的严格约束。当多个线程在没有显式同步的情况下并发访问同一非原子对象,且至少有一个访问是写入时,程序将产生数据竞争,其结果是未定义行为。
在这种情况下,虽然理论上存在一个改动序列,但由于程序的行为已不可预测,讨论该序列的具体内容变得毫无意义,编译器和硬件可能产生任何结果,包括丢失写入、乱序写入或更严重的问题。因此,必须通过锁等机制来避免对非原子对象的并发写入。
原子对象
在并发中,对于改动序列的讨论基本都是隐含在 std::atomic
的上下文中,对于任意一个原子对象的改动序列来说,此序列都具有如下特点:
- 内容构成:它由针对此对象的所有写操作构成,序列的第一个元素即为对象的初始化。
- 全局总序:在一个程序的单次执行中,对该对象的所有写入操作被排列成一个唯一的、全局的总顺序。
- 执行间差异:但由于线程调度的不确定性,这个序列的具体内容可能在程序的多次执行之间有所不同。
改动序列的构造必须基于如下几条要求:
读写的可见与连贯性:
- 如果一个线程观测到改动序列的一个写入值,那么此线程后续的所有读操作必须重排到该位置之后。
- 如果一个线程对某原子对象进行写入,随后又读取该对象,且在此期间没有其他线程的写入,那么它必须读取到自己写入的值。
- 如果在上面的读写之间,改动序列又引入了来自其他线程的写入,那么该线程的读操作必须返回改动序列中最后那个写入的值。
全局一致性:对于同一个原子对象,其改动序列是 全局唯一且一致的。程序中的所有线程,无论其执行上下文如何,都必须认同这唯一的、相同的写入顺序。
对象间的独立性:前面两条确保了一个对象的改动序列的一致性,但不同对象之间是相互独立的,比如标准库的内存序
std::memory_order_relaxed
不保证所有线程观测到的多个对象的改动序列的一致性。完全有可能出现这种情况,线程A 先后写入原子变量a和b,线程B 可能先观测到 b 的写入,后观测到 a 的写入。
总结下来其实很简单:改动序列为单个原子对象提供一个写入操作的总记录,保证了所有线程对该历史的一致看法。
先行关系
先行关系,又称 happens-before,是内存模型中最核心的概念了,它本质上是标准为我们定义的一种跨越线程级别的因果关系:如果操作 A happens-before 操作 B,那么 A 的所有内存效果必须对 B 可见。
Happens-before 关系不是凭空产生的,它必须通过以下三种方式之一来建立:
- 顺序先行:也被称作 Sequenced-before,这是最简单的一种,在同一个线程内,代码 A 写在代码 B 的前面,那么 A 就 sequenced-before B,这自然构成了一个 happens-before 关系。
// 在同一个线程中
int x = 10; // 操作 A
int y = x; // 操作 B
// A sequenced-before B, 所以 A happens-before B.
// 因此,B 读取 x 时保证能看到 10。这很符合直觉。
同步于:也被称作 Synchronized-with,这是一种跨线程的桥梁,用于在不同线程间建立 happens-before 关系,在上文介绍原子变量的内存序时其实就已经提到了,主要是通过
std::memory_order_acquire
和std::memory_order_release
来实现的,我们称 store 操作 happens-before load 操作。传递性:也被称作 Transitive,如果 A happens-before B,B happens-before C,那么 A happens-before C,这个是显而易见的。
只要我们通过如上三种方式之一创建了 A happens-before B 关系,那么就可以肯定的说,A 的一切效果都对 B 来说是可见的。
MESI
根据上面的介绍,应该对这个数据在内存处的交互有了更清晰的认知吧,那笔者此处出一道题目,假设内存中有两个变量 x 和 y,现在有两个核心分别跑着线程,都会操作这两个变量,那请问如何解决如下问题:
- 当一个线程修改变量 x 时,你怎么确保另外一个线程没有同时修改 x?
- 当一个线程从自己的私有缓存读取变量 x,你怎么确保此数据和内存中的是一致的?
- 当线程写入内存后,你如何通知其他线程得到这个更新?
很显然如果不能解决这些问题,数据就会一团糟,就像一个不规范的团队采用 git 协作,经常会发生冲突。
为了解决这个 多核缓存数据一致性 问题,所产生的协议就是 MESI协议,相比于给我们开发所需要遵守的内存模型,MESI 协议是硬件的底层实现,即内存模型的底层机制之一。
四个状态
MESI 是一个广泛使用的 缓存一致性协议,主要包含 四个状态和核心间的通信机制 这个名字是它所定义的缓存行四种状态的首字母缩写:
- M - Modified (已修改)
- E - Exclusive (独占的)
- S - Shared (共享的)
- I - Invalid (无效的)
每个 CPU 核心中的缓存,会为自己存储的每一个数据块即 Cache Line 维护一个状态位,记录它当前处于这四种状态中的哪一种,CPU 通过这个状态来决定下一步该怎么做。
我们先来看一下这四种状态:
Invalid:表示当前缓存行数据无效,一般是因为其他核心修改了此数据,导致此副本无用,如果需要读写,需要从内存中重新加载。
Shared:这个缓存行的数据是有效的,并且和主内存的数据完全一致,同时,其他核心中也存在这个数据的副本。
- 对于读操作,所有核心都可以直接读取此数据。
- 对于写操作,则需要先 广播 一个消息,使其他核心的此数据的缓存行状态更新为 Invalid,然后当前核心将此数据的缓存行状态更新为 Modified,最后将数据写回内存。
Exclusive:与 Shared 状态类似,区别在于只有一个核心存在此数据的副本,此种状态的读写可以任意,毕竟只有自己占有。
- 如果有其他核心读取数据,该核心会通过 总线嗅探 机制得知,并把自身缓存行状态更新为 Shared。
Modified:表示当前缓存行数据已修改,需要写回内存。
- 对于当前核心,当前数据的读写是安全的,因为自己的就是最新的
- 但是对于其他核心,此核心会通过 总线嗅探 机制,如果其他核心有对此数据的读请求,那么会拦截此请求,同时将自己的数据写入内存,随后再让其他核心读取,所有核心的副本缓存行状态变为 Shared。
通信机制
在上述描述中,我们看到了两个很关键的操作:广播和总线嗅探,总线是计组里面很重要的概念了,这里不介绍。
广播 指的是一个 CPU 核心在总线上发送给所有连接在总线上的其他组件的消息。常见的广播事件包括:
- 读请求:当一个核心的缓存中没有某个数据,它需要从主内存加载,这个请求被广播出去,让其他核心知道。
- 写请求 / 使无效请求:当一个核心想要写入一个当前处于 S 状态的数据时,会发出广播强制其他所有核心将它们对应的缓存行设置为 I 状态。
而 总线嗅探 则是每个核心的缓存控制器都在做的一件事情,它的主要功能在于:
- 每个缓存控制器都在不停地嗅探总线上传输的所有广播消息,嗅探到消息后,会立即检查这个广播涉及的内存地址是否也存在于自己的缓存中,如果缓存命中,控制器会根据广播的类型和自己缓存行当前的 MESI 状态来采取相应的行动。
此时,我们就可以理解此协议了:它通过广播和总线嗅探进行通信,各个核心自觉遵守 MESI 状态更换,从而确保了数据的一致性。
本节的知识非常庞杂且过于理论,但在实际开发中,我们只需要考虑如何设计先行关系即可,并通过有/无锁编程实现。