[C++]C++并发编程实战Chapter5
5 C++内存模型和原子操作
内存模型基础
对象、内存区域和并发
所有与多线程相关的事项都会牵涉内存区域。如果两个线程各自访问分离的内存区域,则相安无事,一切运作良好;反之,如果两个线程访问同一内存区域,我们就要警惕了。假使没有线程更新内存区域,则不必在意,只读数据无须保护或同步。
任一线程改动数据都可能引发条件竞争。要避免条件竞争,就必须强制两个线程按一定的次序访问。
- 可以用互斥来保证这种次序。
- 利用原子操作的同步性质,强制两个线程遵循一定的访问次序。
改动序列
在一个C++程序中,每个对象都具有一个改动序列[1],它由所有线程在对象上的全部写操作构成,其中第一个写操作即为对象的初始化。大部分情况下,这个序列会随程序的多次运行而发生变化,但是在程序的任意一次运行过程中,所含的全部线程都必须形成相同的改动序列。
改动序列的要求:
- 只要某线程看到过某个对象,则该线程的后续读操作必须获得相对新近的值
- 该线程就同一对象的后续写操作,必然出现在改动序列后方
- 如果某线程先向一个对象写数据,过后再读取它,那么必须读取前面写的值。
- 若在改动序列中,上述读写操作之间还有别的写操作,则必须读取最后写的值。
- 在程序内部,对于同一个对象,全部线程都必须就其形成相同的改动序列,并且在所有对象上都要求如此,而多个对象上的改动序列只是相对关系,线程之间不必达成一致。
C++中的原子操作及其类别
原子操作是不可分割的操作(indivisible operation)。该操作要么完全做好,要么完全没做。
标准原子类型
标准原子类型的定义位于头文件 <atomic>
内。这些类型的操作全是原子化的,并且,根据语言的定义,C++内建的原子操作也仅仅支持这些类型。
标准的原子类型几乎都具备成员函数 is_lock_free()
,(只有 std::atomic_flag
不具备该成员函数,它一定采用无锁操作)准许使用者判定某一给定类型上的操作是能由原子指令直接实现,还是要借助编译器和程序库的内部锁来实现。
从C++17开始,全部原子类型都含有一个静态常量表达式成员变量,形如X::is_always_lock_free
,考察编译生成的一个特定版本的程序,当且仅当在所有支持该程序运行的硬件上,原子类型X全都以无锁结构形式实现,该成员变量的值才为true。
对于原子类型上的每一种操作,我们都可以提供额外的参数,从枚举类std::memory_order
取值,用于设定所需的内存次序语义。具有6个可能的值,包括 std::memory_order_relaxed
、std::memory_order_acquire
、std::memory_order_consume
、std::memory_order_acq_rel
、std:: memory_order_release
和 std::memory_order_seq_cst
。默认内存次序是最严格的 std::memory_order_seq_cst
操作的类别决定了内存次序所准许的取值:
- 存储(store)操作,可选用的内存次序有
std::memory_order_relaxed
、std::memory_order_release
或std::memory_order_seq_cst
。 - 载入(load)操作,可选用的内存次序有
std::memory_order_relaxed
、std::memory_order_consume
、std::memory_order_acquire
或std::memory_order_seq_cst
。 - “读-改-写”(read-modify-write)操作,可选用的内存次序有
std::memory_order_relaxed
、std::memory_order_acquire
、std::memory_order_consume
、std::memory_order_acq_rel
、std:: memory_order_release
和std::memory_order_seq_cst
std::atomic_flag
std::atomic_flag
是最简单的标准原子类型,表示一个布尔标志。该类型的对象只有两种状态:成立或置零。std::atomic_flag
类型的对象必须由宏ATOMIC_FLAG_INIT
初始化,它把标志初始化为置零状态。
完成初始化后,我们只能执行3种操作:销毁、置零、读取原有的值并设置标志成立。这分别对应于析构函数、成员函数 clear()
、成员函数 test_and_set()
。
我们可以采用 std::atomic_flag
来实现自旋锁。
1 |
|
std::atomic<bool>
std::atomic<bool>
是基于整数的最基本的原子类型。尽管也无法拷贝构造或拷贝赋值,但我们还是能依据非原子布尔量创建其对象,初始值是true或false皆可。该类型的实例还能接受非原子布尔量的赋值。
需要注意,与惯例不同,原子类型的赋值操作符不返回引用,而是按值返回(该值属于对应的非原子类型,返回的是赋予的值)。
std::atomic<bool>
通过 store()
操作进行写作操,通过 exchange()
操作代替 test_and_set()
操作,获取原有的值,自行选定新值作为替换。
支持单纯的读取:隐式做法是将实例转换为普通布尔值,显示做法是调用 load()
.
还引入了一种操作:若原子对象当前的值符合预期,就赋予新值。它与exchange()
一样,同为“读-改-写”操作。
这一新操作被称为“比较-交换”(compare-exchange),实现形式是成员函数compare_exchange_weak()
和 compare_exchange_strong()
。使用者给定一个期望值,原子变量将它和自身的值比较,如果相等,就存入另一既定的值;否则,更新期望值所属的变量,向它赋予原子变量的值。比较-交换函数返回布尔类型,如果完成了保存动作(前提是两值相等),则操作成功,函数返回 ture
;反之操作失败,函数返回 false
。
对于 compare_exchange_weak()
,即使原子变量的值等于期望值,保存动作还是有可能失败,在这种情形下,原子变量维持原值不变,返回false。原子化的比较-交换必须由一条指令单独完成,而某些处理器没有这种指令,无从保证该操作按原子化方式完成。要实现比较-交换,负责的线程则须改为连续运行一系列指令,但在这些计算机上,只要出现线程数量多于处理器数量的情形,线程就有可能执行到中途因系统调度而切出,导致操作失败。这种计算机最有可能引发上述的保存失败,我们称之为佯败(spurious failure)。其败因不是变量值本身存在问题,而是函数执行时机不对。因为 compare_exchange_weak
() 可能佯败,所以它往往必须配合循环使用。
1 |
|
weak和strong版本的区别
- weak版本可能会发生佯败,即虽然变量值和预期值相等,但是由于执行时机问题,导致返回false
- strong版本只有和预期值不一样的时候才会返回false。
strong版本自身内部含有一个循环,性能较差,对于某些经过简单计算就能得出的保存值,用weak版本性能更好,对于需要耗时才能得出的值,用strong版本可以避免重复计算。
std::atomic<T*>
接口、特性都类似 std::atomic<bool>
。提供的新操作是算术形式的指针运算。成员函数 fetch_add()
和 fetch_sub()
给出了最基本的操作,分别就对象中存储的地址进行原子化加减,返回原来的地址。另外,该原子类型还具有包装成重载运算符的+=和−=,以及++和−−的前后缀版本,用起来十分方便。
标准整数原子类型
在 std::atomic<int>
和 std::atomic<unsigned long long>
这样的整数原子类型上,我们可以执行的操作颇为齐全:既包括常用的原子操作(load()、store()、exchange()、compare_exchange_weak()和compare_exchange_strong()),也包括原子运算(fetch_add()、fetch_sub()、fetch_and()、fetch_or()、fetch_xor()),以及这些运算的复合赋值形式(+=、−=、&=、|=和^=),还有前后缀形式的自增和自减(++x、x++、−−x和x−−)。
泛化的 std::atomic<>
类模板
对于某个自定义类型UDT,要满足一定条件才能具现化出 std::atomic<UDT>
:
- 必须具备平实拷贝赋值操作符(trivial copy-assignment operator)
- 它不得含有任何虚函数,也不可以从虚基类派生得出
- 必须由编译器代其隐式生成拷贝赋值操作符;
- 若自定义类型具有基类或非静态数据成员,则它们同样必须具备平实拷贝赋值操作符。
值得注意的是,比较-交换操作所采用的是逐位比较运算,效果等同于直接使用memcmp()
函数。即使UDT自行定义了比较运算符,在这项操作中也会被忽略。若自定义类型含有填充位(padding bit),却不参与普通比较操作,那么即使UDT对象的值相等,比较-交换操作还是会失败。
类型 std::atomic<T>
的接口与 std::atomic<bool>
相似,可用的操作有限,包括 load()
、store()
、exchange()
、compare_exchange_weak()
和compare_exchange_strong()
,以及接受类型T的实例的赋值、转换成类型T的实例。
原子操作的非成员函数
目前为止,我们介绍了不少原子操作,但仅限于原子类型成员函数的形式。不过,还有众多非成员函数,与各原子类型上的所有操作逐一等价。大部分非成员函数依据对应的成员函数命名,只不过冠以前缀“ atomic_
”(如std::atomic_load()
),它们还针对各原子类型进行了重载。只要有可能指定内存次序,这些函数就衍化出两个变体:一个带有后缀“_explicit
”,接收更多参数以指定内存次序,而另一个则不带后缀也不接收内存次序参数,如std::atomic_store_explicit(&atomic_var,new_value,std::memory_order_release)
与 std::atomic_store(&atomic_var,new_value)
。成员函数的调用会隐式地操作原子对象,但所有非成员函数的第一个参数都是指针,指向所要操作的目标原子对象。
C++标准库还提供了非成员函数,按原子化形式访问 std::shared_ptr<>
的实例。标准库给出了共享指针的原子操作(载入、存储、交换和比较-交换),它们与标准原子类型上的操作一样,都是对应的同名函数的重载,而且第一个参数都属于 std:shared_ptr<>*
类型。
原子操作的内存顺序
原子类型上的操作服从 6 种内存次序:memory_order_relaxed、memory_order_consume、memory_order_acquire、memory_order_release、memory_order_acq_rel和memory_order_seq_cst。其中,memory_order_seq_cst是可选的最严格的内存次序,各种原子类型的所有操作都默认遵从该次序。
虽然内存次序共有6种,但它们只代表3种模式:
- 先后一致次序(memory_order_seq_cst)
- 获取-释放次序(memory_order_consume、memory_order_acquire、memory_order_release和memory_order_acq_rel)
- 宽松次序(memory_order_relaxed)