C++中的原子变量(atomic variables)是一种并发编程中用于保证数据一致性和线程安全的机制。在多线程环境下,当多个线程同时访问或修改同一个变量时,可能会产生竞争条件(race condition),导致未定义的行为。C++中的原子变量通过提供一种无锁(lock-free)的机制,确保变量的访问和操作是原子的,即不可被中断,从而避免了竞争条件的发生。
一、C++原子变量的特点
- 原子性操作:原子变量支持一系列原子操作(atomic operations),例如读、写、加减、比较交换等,所有这些操作都能够保证在多线程环境中是不可分割的(atomic),即操作要么完全执行,要么不执行。
- 无锁机制:原子变量通常是无锁实现的,这意味着它们不会像互斥锁(mutex)那样导致线程的阻塞,从而提高了程序的性能。
- 线程安全:原子变量确保在线程并发访问同一变量时,不会发生数据竞争,也就是说,不需要使用额外的同步机制来保护它们。
- 内存序:C++的原子操作支持多种内存顺序模型,例如顺序一致性(sequential consistency)、获取-释放(acquire-release)等,以控制不同线程之间的操作如何在内存中排序。这使得开发者可以在性能和正确性之间做出权衡。
二、C++中的std::atomic
类模板
C++11标准引入了<atomic>
头文件,提供了std::atomic
类模板和一组函数来操作原子变量。std::atomic
是一个类模板,可以用于各种类型的变量,例如整型、指针、布尔型等。
#include <iostream>
#include <atomic>
#include <thread>
std::atomic<int> counter(0); // 定义一个原子整型变量并初始化为0
void incrementCounter() {
for (int i = 0; i < 1000; ++i) {
++counter; // 原子递增
}
}
int main() {
std::thread t1(incrementCounter);
std::thread t2(incrementCounter);
t1.join();
t2.join();
std::cout << "Final counter value: " << counter << std::endl; // 输出结果应该是2000
return 0;
}
在上面的例子中,counter
是一个std::atomic<int>
类型的变量,它被两个线程并发递增。由于counter
是原子变量,递增操作是线程安全的,最终的结果总是正确的2000,而不会出现数据竞争。
三、常见原子操作
- 加载和存储操作(load, store):
std::atomic<int> a(10);
int x = a.load(); // 原子读取
a.store(20); // 原子写入
- 原子自增和自减操作(fetch_add, fetch_sub):
std::atomic<int> a(0);
a.fetch_add(1); // 原子自增1
a.fetch_sub(1); // 原子自减1
- 比较并交换(compare_exchange_weak, compare_exchange_strong): 比较并交换操作允许在线程间协调修改变量值:
std::atomic<int> a(0);
int expected = 0;
int desired = 1;
a.compare_exchange_strong(expected, desired); // 如果a的值等于expected,则将其设为desired
- 交换(exchange):
std::atomic<int> a(0);
int old_value = a.exchange(2); // 将a的值设为2,并返回旧值
四、原子性
原子性指的是一段指令即使映射到底层,在其他核心看来,要么全部没有没有开始,要么全部结束了,不会让让其他核心看到执行的一个中间状态。
4.1、单核处理器原子性实现
单核处理器上实现原子性只需要保证操作指令不被中断即可,可以使用自旋锁锁住操作,也可以直接关中断,保证其他线程不会进入cpu。
4.2、多核处理器原子性实现
多核处理器还需要避免其他核心操作相关内存,曾经是通过“锁总线”的方式实现的,锁住其他核心只允许当前核心访问总线。这种方法的弊端是其他核心连同非相关的内存空间也无法使用。“总线锁”这种方式在x86架构cpu中可以使用lock
指令去实现。
五、存储体系
可以看到一级缓存和二级缓存是每个核心独有的,三级缓存和内存是公有的,越往下,容量越大,速度越慢。
Cpu访问缓存的时候会有一个最小的读取单位,叫做cache line,一般是64个字节。cache line包含三部分
Flag标识的是当前的缓存是否可用。
Tag标识的是当前的缓存是否命中。
Data是缓存数据。
缓存的读写
核心计算完毕意味着要将计算好的数据写会内存,曾经的策略是写到缓存中,也会写到内存中,这样的话写性能会很低,现代计算机很少使用了。现在的策略是尽量把数据存储到缓存之中,如果能写到缓存之中就避免写道内存中。被修改的数据在缓存中会被标记为“脏”,表示它与主内存中的数据不一致。脏数据在缓存达到一定阈值、缓存被替换或系统需要进行内存同步时写回到主内存。这种方式可以提高系统性能,尤其在需要频繁读写的场景下。
write through 策略
每当cache收到写数据指令时,若写命中,则cpu会同时将数据写到cache和主存
write back 策略
只有在一个cache行被选中替换回主存时,如果cache行是脏数据,才将他写回主存。如果cache行没有被修改过不是脏数据那么不需要把数据写回主存,这样就有效降低了cache到主存的写次数。
也可以看下面的广为流传的图
六、缓存一致性问题-MESI协议
缓存一致性解决的是不同的核心的缓存中内容不一致的问题,这是由于write back策略和cpu多核属性造成的。该问题通过实现MESI一致性协议解决。相关的缓存一致性协议还有MSI,MOSI,Synapse,Firefly及DragonProtocol等。
6.1、写传播
某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的Cache,这个称为写传播(Write Propagation)。
写传播最常见的实行方式就是总线嗅探,当 core 1 中修改了 L1 cache 中 i 变量的值,通过总线把这个事件广播通知给其他所有的核心。每个 CPU core 都会监听总线上的广播事件,并检查是否有相同的数据在自己的 L1 Cache 里面,如果 core 2 的 L1 Cache 中有该数据,那么也需要把该数据更新到自己的 L1 Cache。
其优点是简单,但是缺点是不论别的核心是否缓存相同的数据都需要发出一个广播事件,会加重总线的负担。
6.2、事物串行化
但是由此又引发了一个问题,如果多个核心同时修改了数据,其他核心该怎么办?这时候需要用事务串行化解决。事务串行化,让修改事件按时间的先后顺序发生。举个例子
- 内存中有变量
i=1
- core 1 将
i=100
,同时 core 2 将i=200
- 上面两步都会将修改传播到 core 3 和 core 4
- 如果 core 3 先看到的是
i=200
后看到的是i=100
,那么 core 3 中缓存的就是i=100
。 - 但是 core 4 收到的事件是反过来的,先看到的是
i=100
后看到的是i=200
,那么 core 4 中缓存的就是i=200
。
可见,仅仅只有写传播的话,各个cache中的数据还是无法保持一致性,要实现一致性就必须引入事物串行化,保证 core 3 和 core 4 看到相同顺序的数据变化。这有两点要求
- CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU core
- 要引入“锁”的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了"锁",才能进行对应的数据更新
6.3、MESI状态表示
MESI协议基于总线嗅探机制实现了事务串形化,也用状态机机制降低了总线带宽压力,做到了 CPU 缓存一致性。
上面也提到了,CPU读取一个缓存其实不是直接读这个变量,而是这个变量所在的整个缓存行,其中MESI分别表示四种缓存行状态:
状态 | 描述 |
---|---|
M修改(Modefiy) | 该缓存行有效,数据被修改了,和内存中的数据不一样,数据只存在于本缓存行中 |
E独享(Exclusive) | 该缓存行有效,数据和内存中的数据一致,数据只存在本缓存行中 |
S共享(Shared) | 该缓存行有效,数据和内存中的数据一致,数据同时存在于其他缓存中 |
I无效(Invalid) | 该缓存行数据无效 |
M和E的数据都是本内核(core)独有的,区别在于M状态的数据是dirty(和内存中的不一致),E状态的数据是clean(和内存中的一致);S状态是所有Core的数据都是共享的,只有clean的数据才能被多个core共享;I表示这个Cache line无效。
“独占”和“共享”的差别在于,独占状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。
6.4、MESI状态转换
6.4.1、在状态I
-
local read:
- 当其他核心没有这个缓存行时,CPU从内存取缓存行更新到cache,并把状态设置为E
- 当其他核心存在这个缓存行时
- 若其他核心是M状态,则同步其到主存,然后两个核心的状态都设置为S
- 若其他核心是S或者E状态,则两个核心状态都变为S
-
local write:
- 当其他核心没有这个缓存行时,CPU从内存中读取数据,缓存到cache,在cache中更新数据,状态设置为M
- 当其他核心存在这个缓存行时
- 若其他核心是M状态,则同步其到主存,然后将其他核心状态设置为I
- 若其他核心是S或者E状态,则直接设置其他核心状态为I
-
remote read:
- 既然是invalid,那么其他核心操作与它无关
-
remote write:
- 既然是invalid,那么其他核心操作与它无关
6.4.2、在状态E
-
local read:
- 从 cache 中取数据,状态不变
-
local write:
- 从 cache 中读数据,状态变为M
-
remote read:
- 数据和其他核心公用,状态变为S
-
remote write:
- 数据被修改,本地核心的 cache line 中的数据不能再使用,状态变为I
6.4.3、在状态S
-
local read:
- 从 cache 中取数据,状态不变
-
local write:
- 从 cache 中读数据,状态变为M,其他核心贡献的 cache line 状态变为 I。
-
remote read:
- 状态不变
-
remote write:
- 数据被修改,本地核心的 cache line 中的数据不能再使用,状态变为I
6.4.3、在状态M
-
local read:
- 从 cache 中取数据,状态不变
-
local write:
- 修改Cache中数据,状态不变
-
remote read:
- cache line 的数据被写到主存中,使其他核能使用到最新的数据,状态变为S
-
remote write:
- cache line 的数据被写到主存中,使其他核能使用到最新的数据,由于其他核会修改这行数据,状态变为I
六、内存顺序模型
C++原子操作可以指定内存顺序模型,用于控制不同线程对共享数据的访问顺序,指导编译器进行cpu指令重排的优化。
编译器和cpu会在判断代码顺序不影响程序的情况下,有可能会重排相邻的代码执行顺序。比如一个线程锁住了一块内存空间,另一个线程无法操作那块内存空间,这项操作之后的操作并不刚需这块内存空间,这时候可能会重排先往后执行。这是为了提升整个系统的性能,但有时我们要求一定要按某个顺序执行,那么就有时候就不允许重排操作。
常见的内存顺序包括:
- 顺序一致性(std::memory_order_seq_cst):这是最严格的内存顺序,保证所有原子操作在所有线程上都以相同的顺序出现。它是默认的内存顺序,易于理解但性能可能较低。
- 松散顺序(std::memory_order_relaxed):允许不对线程之间的操作顺序做任何保证,适用于对性能要求较高但不要求严格顺序的情况。
- 获取-释放(std::memory_order_acquire, std::memory_order_release):用于同步线程间的数据依赖关系。
acquire
确保读取前的操作不会被重排序到读取后,release
确保写入后的操作不会被重排序到写入前。
七、何时使用原子变量?
- 原子变量适合用于在多线程环境中,对单个变量进行简单的操作(如递增、交换、读写)时,可以避免使用锁,从而提高性能。
- 如果操作涉及多个变量的协调,或者是更复杂的共享资源操作,则需要使用更强的同步机制(如互斥锁或条件变量)。
八、总结
C++中的原子变量通过无锁的原子操作来保证多线程环境下的线程安全,并提供多种内存顺序模型供开发者根据需求进行性能调优。使用原子变量可以有效避免数据竞争,并简化多线程编程中的同步问题。