第五章——内存模型和原子操作
5.1 内存模型
C++所有的对象都和内存位置有关。
5.1.1 对象和内存位置
C++程序中数据都是由对象构成。
无论是怎么样的类型,都会存储在一个或多个内存位置上。每个内存位置不是标量类型的对象,就是标量类型的子对象。
这里有四个需要牢记的原则:
- 每个变量都是对象,包括其成员变量的对象。
- 每个对象至少占有一个内存位置。
- 基本类型都有确定的内存位置(无论类型大小如何,即使他们是相邻的,或是数组的一部分)。
- 相邻位域是相同内存中的一部分。
5.1.2 对象,内存位置和并发
当两个线程访问不同的内存位置时,不会存在任何问题,当两个线程访问同一个内存位置就要小心了。如果线程不更新数据,只读数据不需要保护或同步。当线程对内存位置上的数据进行修改,就可能会产生条件竞争。
为了避免条件竞争,线程就要以一定的顺序执行。第一种方式,使用互斥量来确定访问的顺序。当同一互斥量在两个线程同时访问前锁住,那么在同一时间内就只有一个线程能够访问对应的内存位置。另一种是使用原子操作决定两个线程的访问顺序,当多个线程访问同一个内存地址时,对每个访问者都需要设定顺序。
当程序对同一内存地址中的数据访问存在竞争,可以使用原子操作来避免未定义行为。当然,这不会影响竞争的产生——原子操作并没有指定访问顺序——而原子操作会把程序拉回到定义行为的区域内。
5.1.3 修改顺序
每一个在C++程序中的对象,都有确定好的修改顺序,在初始化开始阶段确定。大多数情况下,这个顺序不同于执行中的顺序,但是在给定的执行程序中,所有线程都需要遵守这个顺序。
如果对象不是一个原子类型,你必须要确保有足够的同步操作,来确定每个线程都遵守了变量的修改顺序。当不同线程在不同序列中访问同一个值时,你可能就会遇到数据竞争或未定义行为。如果你使用原子操作,编译器就有责任去替你做必要的同步。
这一要求意味着,投机执行是不允许的,因为当线程按修改顺序访问一个特殊的输入,之后的读操作,必须由线程返回较新的值,并且之后的写操作必须发生在修改顺序之后。同样的,在同一线程上允许读取对象的操作,要不返回一个已写入的值,要不在对象的修改顺序后再写入另一个值。
5.2 原子操作和原子类型
原子操作是个不可分割的操作。系统的所有线程中,不可能观察到原子操作完成了一半。如果读取对象的加载操作是原子的,那么这个对象的所有修改操作也是原子的,所以加载操作得到的值要么是对象的初始值,要么是某次修改操作存入的值。
另一方面,非原子操作可能会被另一个线程观察到只完成一半。如果这个操作是一个存储操作,那么其他线程看到的值,可能既不是存储前的值,也不是存储的值。如果非原子操作是一个读取操作,可能先取到对象的一部分,然后值被另一个线程修改,然后它再取到剩余的部分,所以它取到的既不是第一个值,也不是第二个值。这就构成了数据竞争,出现未定义行为。
5.2.1 标准原子类型
标准原子类型定义在头文件<atomic>
中。这些类型的操作都是原子的,语言定义中只有这些类型的操作是原子的,也可以用互斥锁来模拟原子操作。标准原子类型的实现可能是这样的:它们(几乎)都有一个is_lock_free()
成员函数,这个函数可以让用户查询某原子类型的操作是直接用的原子指令(x.is_lock_free()
返回true
),还是内部用了一个锁结构(x.is_lock_free()
返回false
)。
原子操作可以替代互斥量,来完成同步操作。如果操作内部使用互斥量实现,那么不可能有性能的提升。所以要对原子操作进行实现,最好使用不基于互斥量的实现。
标准库提供了一组宏,在编译时对各种整型原子操作是否无锁进行判别。C++17中,所有原子类型有一个static constexpr
成员变量,如果相应硬件上的原子类型X
是无锁类型,那么X::is_always_lock_free
将返回true
。例如:给定目标硬件平台std::atomic<int>
无锁,那么std::atomic<int>::is_always_lock_free
将会返回true
。不过std::atomic<uintmax_t>
因为这是一个运行时属性,所以std::atomic<uintmax_t>::is_always_lock_free
在该平台编译时可能为 false
。
宏都有ATOMIC_BOOL_LOCK_FREE
, ATOMIC_CHAR_LOCK_FREE
, ATOMIC_CHAR16_T_LOCK_FREE
, ATOMIC_CHAR32_T_LOCK_FREE
,ATOMIC_WCHAR_T_LOCK_FREE
,ATOMIC_SHORT_LOCK_FREE
, ATOMIC_INT_LOCK_FREE
, ATOMIC_LONG_LOCK_FREE
, ATOMIC_LLONG_LOCK_FREE
和ATOMIC_POINTER_LOCK_FREE
。它们指定了内置原子类型的无锁状态和无符号对应类型(LLONG
对应long long
,POINTER
对应所有指针类型)。如果原子类型不是无锁结构,那么值为0。如果原子类型是无锁结构,那么值为2。如果原子类型的无锁状态在运行时才能确定,那么值为1。
只有std::atomic_flag
类型不提供 is_lock_free()
。该类型是一个简单的布尔标志,并且在这种类型上的操作都是无锁的。当有一个简单无锁的布尔标志时,可以使用该类型实现一个简单的锁,并且可以实现其他基础原子类型。对std::atomic_flag
明确初始化后,做查询和设置(使用test_and_set()
成员函数),或清除(使用clear()
成员函数)都很容易:无赋值,无拷贝,没有测试和清除,没有任何多余操作。
剩下的原子类型都可以通过特化std::atomic<>
得到,并且拥有更多的功能,但不可能都是无锁的。
通常,标准原子类型不能进行拷贝和赋值,它们没有拷贝构造函数和拷贝赋值操作符。但是,可以隐式转化成对应的内置类型,所以这些类型依旧支持赋值,可以使用load()
和store()
、exchange()
、compare_exchange_weak()
和compare_exchange_strong()
。它们都支持复合赋值符:+=, -=, *=, |=
等等。并且使用整型和指针的特化类型还支持++
和--
操作。当然,这些操作也有功能相同的成员函数所对应:fetch_add()
, fetch_or()
等等。赋值操作和成员函数的返回值,要么是存储值(赋值操作),要么是操作值(命名函数),这就能避免赋值操作符返回引用。
std::atomic<>
类模板不仅仅是一套可特化的类型,作为原发模板也可以使用自定义类型创建对应的原子变量。因为是通用类模板,操作限制为load()
,store()
(赋值和转换为用户类型),exchange()
,compare_exchange_weak()
和compare_exchange_strong()
。
每种函数类型的操作都有一个内存序参数,这个参数可以用来指定存储的顺序。
- Store操作,可选如下内存序:
memory_order_relaxed
,memory_order_release
,memory_order_seq_cst
。 - Load操作,可选如下内存序:
memory_order_relaxed
,memory_order_consume
,memory_order_acquire
,memory_order_seq_cst
。 - Read-modify-write(读-改-写)操作:可选如下内存序:
memory_order_relaxed
,memory_order_consume
,memory_order_acquire
,memory_order_release
,memory_order_acq_rel
,memory_order_seq_cst
。
5.2.2 std::atomic_flag
std::atomic_flag
是最简单的原子类型,这个类型的对象可以在两个状态间切换:设置和清除。就是这么简单,只作为构建块存在。
std::atomic_flag
类型的对象必须被ATOMIC_FLAG_INIT
初始化。初始化标志位是“清除”状态。这里没得选择,这个标志总是初始化为“清除”:
std::atomic_flag f = ATOMIC_FLAG_INIT;
这适用于任何对象的声明,是唯一需要以如此特殊的方式初始化的原子类型,但也是唯一保证无锁的类型。首次使用时,需要初始化。如果std::atomic_flag
是静态存储的,那么就得保证其是静态初始化的,也就意味着没有初始化顺序问题。
当标志对象已初始化,只能做三件事情:销毁,清除或设置(查询之前的值)。这些操作对应的函数分别是:clear()
成员函数和test_and_set()
成员函数。
clear()
和test_and_set()
成员函数可以指定好内存顺序。clear()
是一个存储操作,所以不能有memory_order_acquire
或memory_order_acq_rel
语义,但test_and_set()
是一个“读-改-写”操作,可以应用于任何内存顺序。每一个原子操作,默认的内存序都是memory_order_seq_cst
。
f.clear(std::memory_order_release); // 1
bool x=f.test_and_set(); // 2
调用clear()
①明确要求,使用释放语义清除标志,当调用test_and_set()
②使用默认内存序设置表示,并且检索旧值。
不能拷贝构造std::atomic_flag
对象,不能将一个对象赋予另一个std::atomic_flag
对象。这不是std::atomic_flag
特有的属性,而是所有原子类型共有的属性。原子类型的所有操作都是原子的,而赋值和拷贝调用了两个对象,这就就破坏了操作的原子性。这样的话,拷贝构造和拷贝赋值都会将第一个对象的值进行读取,然后再写入另外一个。对于两个独立的对象,这里就有两个独立的操作了,合并这两个操作必定是不原子的。因此,操作就不被允许。
有限的特性使得std::atomic_flag
非常适合于作自旋锁。初始化标志是“清除”,并且互斥量处于解锁状态。为了锁