C++内存模型与原子类型操作——原子操作与原子类型

前言

 
原子类型操作(atomic operation)具备不可分割性,其仅存在执行完成与未执行两种状态。

如若某线程正在以原子操作load一个对象的值(对该对象的所有修改都由原子操作完成),那么它仅能读取到该对象在修改前,亦或某一次修改后的值(无法获取中间状态)。非原子类型操作不具备此性质,因此线程很可能读取到某个对象的中间状态,从而造成数据竞争。


标准原子类型

 
所有标准原子类型都可以在<atomic>头文件中找到。尽管从理论角度出发,开发者可以使用互斥锁保证任何数据类型均对外展现出原子性,但从C++语言定义而言,仅有这些类型是真正的原子类型。

事实上,可能标准原子类型也是通过使用锁实现了原子性。为了探究这一点,几乎所有标准原子类型(特例为std::atomic_flag)都具备这样一个接口is_lock_free(),开发者可以通过调用此接口来判断,当前行为究竟是语义上的原子行为(此时该接口返回true)还是编译器或者函数库内部在执行加锁操作模拟原子性(此时该接口返回false)。了解原子类型是否具备语义上的原子性在性能优化领域较为重要:如果某类型内部同样使用了mutex,那么可能将无法带来期望的性能收益。

除了直接使用std::atomic类模板外,标准库也提供了一系列名称来引用原子类型。但由于历史原因,在较老的编译器上,这些替代类型名称可能引用对应 std::atomic<>的特化类或这个特化类的一个基类,支持C++17的编译器则不存在此问题。因此,如果混合使用类型名与std::atomic<>特化类,可能导致代码不可移植。

类型名与std::atomic<>特化类映射表如下:
WechatIMG13.png-417kB

除此以外,C++标准库还针对std::size_t等非基本原子类型提供typedef,其映射表如下:
image-20210610152408616.png-298.2kB

标准原子类型理论上不可复制或赋值,因为它们没有copy ctorcopy assignment operator。但它们支持通过诸如load(),storeexchange等被相应的内置类型赋值或隐式转换。此外,它们也支持相应的相关计算符,例如+=,-=等等。整形与指针的原子类型还支持++--。这些计算符同样具备实现同样功能的成员函数,如fetch_add()fetch_or()等等。
一般而言,对象的赋值运算符往往返回引用,如果将该惯例应用于原子类型,则获取存储数据时程序中势必存在一个特殊的读操作,这可能导致数据竞争(在assign和read之间可能存在一个线程正在修改数据)。因此,对于赋值运算符而言,其返回值是被存储的数据,而对于命名成员函数而言,返回值是操作前的数据。

std::atomic<>类模板不仅仅是一组特化。它确实存在一个主模板,可用于创建用户自定义类型的原子变体。其操作被限定为load()store()exchange()compare_exchange_weak()compare_exchange_strong()

每个原子类型上操作都有一个可选的内存顺序参数,它是std::memory_order枚举类型的某个值,可以用来指定所需的内存顺序语义。std::memory_order枚举值分别为:
1.std::memory_order_relaxed
2.std::memory_order_acquire
3.std::memory_order_consume
4.std::memory_order_acq_rel
5.std::memory_order_release
6.std::memory_order_seq_cst

该内存顺序参数依赖于操作的类别,在不指定的情况下,默认使用std::memory_order_seq_cst


std::atomic_flag

 
std::atomic_flag是最简单的标准原子类型,表征布尔量,此类型的对象仅有两种状态:set或clear。实际上,它被设计为一种基础构建模块,很少应用于实际工程。以下将简称std::atomic_flag类型对象为flag对象。

flag对象必须用ATOMIC_FLAG_INIT初始化,即初始化为clear状态,这也意味着它无法被初始化为set状态。

1
std::atomic_flag f = ATOMIC_FLAG_INIT;

它是唯一在初始化时需要这种特殊处理的原子类型,也是唯一保证无锁的原子类型。此外,static flag对象保证其具备静态初始化特性,即该对象总在首次使用前完成初始化。

flag对象在完成初始化后,仅能对其施加三种操作:destroy,clear,test_and_set(读取值后set),分别对应着析构函数,clear()成员函数以及test_and_set()成员函数。clear()test_and_set()都可以指定内存顺序。

所有原子类型均不支持copy ctor && copy assignment,flag对象也不例外。原因很简单:应用于原子类型的所有操作均应具备原子性,但赋值和拷贝构造涉及到两个独立对象。在执行拷贝构造或拷贝赋值操作时,必须先从一个对象读取值,然后将其写入另一个对象,这一组合行为不可能具备原子性。

std::atomic_flag所具备的特性使其非常适合实现自旋互斥锁,具体实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
class spinlock_mutex {
std::atomic_flag flag;
public:
spinlock_mutex(): flag(ATOMIC_FLAG_INIT) {}
void lock() {
while(flag.test_and_set(std::memory_order_acquire)); // 忙-等待
}
void unlock() {
flag.clear(std::memory_order_release);
}
};

flag对象类似于bool,但其未提供标准get接口(C++20引入test()成员函数),因此如存在相关需求,请使用std::atomic<bool>


std::atomic<bool>

 
相较于flag对象,std::atomic<bool>的灵活程度大为提高。尽管不支持copy ctor与copy assign,但可以使用内置的bool类型构造它——这意味着它可以被初始化为true或false。此外,std::atomic<bool>对象亦可接受来自内置bool类型的赋值。

1
2
std::atomic<bool> b(true);
b=false;

这里需要注意一点:不同于常规赋值操作,原子类型的赋值操作返回的并非引用,而是被赋予的值。原因很简单,返回引用可能会导致另一个线程修改当前存储数据,这将导致数据竞争。

std::atomic<bool>使用strore()成员函数写入true或false值,使用exchange()成员函数完成对原始值的检索与替换,读取操作使用的是成员函数load()

1
2
3
4
std::atomic<bool> b;
bool x=b.load(std::memory_order_acquire);
b.store(true);
x=b.exchange(false, std::memory_order_acq_rel);

exchange外,std::atomic<bool>还有两个”read-change-write”操作,分别是compare_exchange_weak()compare_exchange_strong()

compare-exchange操作是使用原子类型进行编程的基石——它将原子变量的值与提供的预期值进行比较,若相等,则存储提供的值。如果值不相等,则预期值将被更新为原子变量存储的值。该操作返回布尔量,若执行了存储操作(值相等)且存储成功则为true,否则为false。

compare_exchange_weak()的weak之处在于,即使原始值与预期值一致时,存储也可能会不成功,此时原始值不变,compare_exchange_weak()返回false。这种情况多见于当前机器不支持compare-exchange指令(意味着无法保证操作原子性),若此时执行操作的线程在必要的指令序列中被切换出去,而操作系统在其位置调度了另一个线程,将导致数据存储失败。因此compare_exchange_weak()通常应用于循环内:

1
2
3
4
5
6
bool expected=false;
extern atomic<bool> b; // 在其他某个地方设置
// b内部数据为存储值
// expected为期待值
// true为提供值
while(!b.compare_exchange_weak(expected,true) && !expected); // 若存储失败,将持续执行

compare_exchange_strong()的strong之处在于,当存储值等于期待值时,该函数返回ture(即使存储失败),否则返回false。

若当前提供值依赖于存储值,则开发者可以利用期待值被更新为存储值的特性,利用期待值计算提供值。这里需要注意,若这种计算过程非常耗时,则使用strong优于weak,因为weak所在的循环将导致提供值被反复计算,即使期待值未发生变更。

除flag外,原子类型并不保证无锁性,若有需要,std::atomic<bool>可以通过is_lock_free()成员函数检查其操作是否具备无锁性。


std::atomic<T*>

 
std::atomic<T*>具备所有std::atomic的接口与性质,本节主要描述其特性。

std::atomic支持指针运算,类似于普通指针,其行为是在存储的地址上做原子加法与减法,其接口也类似与普通指针类型,有+=,-=,++(前置&&后置), —(前置&&后置),它们的返回值是一个普通的T*,指向增加/减少后的内存地址。

除上述接口外,std::atomic还提供了接口fetch_add()fetch_sub(),其返回值与上述接口不同,返回执行加/减操作前的原始值。

std::atomic具体使用示例如下:

1
2
3
4
5
6
7
8
9
class Foo{};
Foo some_array[5];
std::atomic<Foo*> p(some_array);
Foo*x=p.fetch_add(2); //p加2,并返回老的值
assert(x==some_array);
assert(p.load()==&some_array[2]);
x=(p-=1); //p减1,并返回新的值
assert(x==&some_array[1]);
assert(p.load()==&some_array[1]);


标准整形原子类型

 
除load(), store(), exchange(), compare_exchange_weak(),compare_exchange_strong()接口外,原子整形类型如std::atomic和 std::atomic有相当全面的一整套运算接口:fetch_add(), fetch_sub(), fetch_and(), fetch_or(), fetch_xor(),以及复合赋值形式的操作((+=, -=, &=, |=和^=),++和—(前置&&后置)。因为原子整数值通常用作计数器或位掩码,因此缺失乘法,除法,移位操作符也许并不能算作遗憾。如有需要,在循环内反复调用compare_exchange_weak()即可。

类似于指针操作,fetch()接口均返回修改前的旧值,前置操作符返回修改后的新值,后置操作符返回修改前的旧值。


std::atomic<T>

 
给定用户自定义的类型UDT,std::atomic<UDT>提供了与std::atomic<bool>相同的接口,当然,与存储值相关的返回类型为UDT,而非bool。

并非任意UDT均可与std::atomic<>搭配使用:UDT必须具备一个trivial拷贝赋值操作符,这意味着该类型必须没有虚函数和虚基类,并且必须使用编译器生成的拷贝赋值操作符——这一要求对其基类和非静态数据成员同样生效。这保证了编译器可以使用memcpy(或一个等价操作)来完成赋值。此外,compare_exchange使用的是memcmp式的比较操作,因此不支持bitwise相等的UDT实例在执行compare_exchange操作时将会不符合预期,即使比较值满足自定义operator==(顺带提一嘴,std::atomic<double>与std::atomic<float>尽管是标准类型,使用compare_exchange_strong时也存在同样的坑,原因在于数据的存储形式不同)。

这些限制背后的原因前文已经提及——不要将指针和引用作为参数传递给用户提供的函数,从而在锁范围外传递受保护数据。一般来说,编译器无法为std::atomic<UDT生成无锁代码,因此它必须生成和使用一个内部锁。如果允许用户提供自定义拷贝赋值或比较操作符,则需要将对受保护数据的引用作为参数传递给用户提供的函数,这可能导致数据竞争。此外,这些限制增加了编译器直接为 std::atomic<UDT>使用原子指令的机会,因为它可以直接将UDT实例视为一组原始字节。

这些限制意味着开发者不能创建一些较为复杂的原子数据类型,例如std::atomic<std::vector<int>>(它有一个特殊的拷贝构造和拷贝赋值操作符)。但这并不算大问题——数据结构越复杂,对其进行复杂操作的可能性越高,而不只是原子操作里简单的赋值和比较,此类数据最好使用std::mutex,以确保为所需操作适当地保护数据。

如果UDT的大小 <= int或void*,那么大多数平台都能够对std::atomic<UDT>使用原子指令。一些平台还能对两倍于int或void*大小的用户自定义类型使用原子指令。这些平台通常支持所谓的双字比较-交换(double-word-compare-and-swap)指令,该指令对应于compare_exchange_xxx函数。

综上,下表展示了std::atomic<T>上可使用的各项操作:
image.png-224.6kB


应用于原子操作的自由函数

TODO::不甚重要,日后补充