C++并发编程(五)内存模型与原子操作

C++作为操作系统级别的编程语言,委员会的一个目标是令其尽量贴近计算机底层,原子类型(atomic)及其操作应运而生,提供了底层同步操作的功能,只需一两条CPU指令即可实现。

标准原子类型不仅能避免未定义操作、防范数据竞争,还能让用户强制线程间的操作服从特定次序,std::mutex和std::future<>都以这种强制服从内存次序为基础。

目录

1.内存模型基础

1.1对象和内存区域

1.2对象、内存区域与并发的关系

1.3改动序列

2.C++原子操作

2.1标准原子类型

2.1.1原子操作的内部互斥

2.1.2内建原子类型特化

2.1.3用户定义类型的原子特化

2.2标准原子类型的通用操作

2.2.1std::atomic_flag

2.2.2std::atomic

2.2.3“比较-交换”操作

2.2.4“比较-交换”操作的内存次序

2.3std::atomic*>

2.3.1标准整数原子类型

2.4泛化的std::atomic<>类模版

2.5原子操作的非成员函数

3.同步操作和强制次序

3.1同步关系

3.2先行关系

3.3原子操作的内存次序

3.3.1先后一致次序

3.3.2宽松次序

3.3.3获取-释放次序

3.3.4memory_order_consume

3.3.5释放序列和同步关系 

3.4栅栏

3.5凭借原子操作令非原子操作服从内存次序

3.6强制非原子操作服从内存次序

3.7相关工具的同步关系

小结


1.内存模型基础

内存模型涉及两个方面:基本结构和并发。

基本结构就是对象和内存区域,关系到整个程序在内存中的布局。

1.1对象和内存区域

C++程序的数据全部都由对象构成,并为对象赋予了各种性质,如类型和生存期,但C++标准只将“对象”定义为“某一存储范围”(a region of storage)。

在所有对象中,有的属于内建基本类型(如int和float),用于存储简单的数值,其它则是用户自定义的类型实例。

某些对象具有子对象,如结构体、数列、派生类的实例、非静态数据成员实例等。不论对象是哪种类型,其都会存储在一个或多个内存区域中。

每个内存区域或是对象/子对象,或是一串连续的位域(bit field),相邻的位域可以分属不同的对象,但属于同一内存区域。

每个变量都是对象,每个对象至少占用一块内存区域,内建的基本类型(如int、char),不论大小,都占用一块内存区域(仅此一块)。

1.2对象、内存区域与并发的关系

当多个线程需要访问同一内存区域,且需要改动内存区域内的数据时,就要避免线程间的条件竞争,线程之间需要按照一定次序访问。前面的章节介绍了使用互斥,使每次仅容许一个线程访问。我们也可以在数据竞争的内存区域使用原子操作的同步性质,在目标内存区域采用原子操作,从而强制线程遵循次序进行访问。

1.3改动序列

在C++程序中,每个对象都有一个改动序列,变量的值随着时间推移形成的序列,它由所有线程在对象上的全部写操作构成,第一个操作即为对象的初始化。在不同的线程上观察同一个变量的序列,若所见各异,说明出现了数据竞争和未定义行为。在程序的任意一次运行中,所含的全部线程都必须形成相同的改动序列。

2.C++原子操作

原子操作是不可分割的操作(indivisible operation),它或者完全做好,或者完全没做。

非原子操作(non-atomic operation)在完成到一半的时候,有可能为另一线程所见。多个原子操作会组合成非原子操作,在其中两个原子操作之间,其它线程可以对数据进行操作,造成条件竞争。

2.1标准原子类型

2.1.1原子操作的内部互斥

多数情况下,通过原子类型来实现原子操作。标准原子类型定义于头文件,内建的原子操作也仅仅支持这些原子类,它们几乎都具备成员函数is_lock_free(),准许使用者判定某一给定类型的操作是能由原子指令(atomic instruction)实现(is_lock_free()返回true),还是要借助程序库的内部锁实现(is_lock_free()返回false)。

只有一个原子类型不提供is_lock_free()成员函数,它是简单的布尔标志,因此无须采取无锁操作。

原子操作的关键用途是取代需要互斥的同步方式。但是,假如原子操作本身也在内部使用了互斥,就很可能无法达到期望的性能提升,此时不如使用基于互斥的方式更为直观。

C++17开始,全部原子类型都有一个静态常量表达式成员变量(static constexpr member variable),如X::is_always_lock_free,当所支持的所有硬件上的版本中,原子类型X全都以无锁结构实现,变量的值才为true。

假设一个程序有原子类型std::atomic,相关原子操作必须用到某些CPU指令,如果该程序可以在多种硬件上运行,但仅有一部分支持必须的CPU指令,那么等到运行时才能确定是否属于无锁结构,因此is_always_lock_free的值在编译期间确定为false。

2.1.2内建原子类型特化

原子类型通过类模版std::atomic特化得出(如std::atomic),功能齐全,但可能不属于无锁结构。除了标准原子类型,标准库还提供了一组标准原子类型的typedef,对应标准库内建类型的typedf,对于类型T的typedef,相应原子类型为atomic_T,如:atomic_int_least8_t对应int_least8_t等。signed缩写为s,unsigned缩写为u,long long缩写为llong。

标准原子类型不具备拷贝构造和拷贝赋值函数,无法复制和赋值。但可以接受内建类赋值,隐式转换为内建类型,支持复合操作符,如:+=、-=、*=等。操作符都有对应的具名成员函数如:fetch_add()(++)等。

2.1.3用户定义类型的原子特化

类模版std::atomic<>不仅局限于内建类型,还具有泛化模版,可以根据用户自定义类型创建原子类型变体,其操作权限仅限于:load(),store()(接受用户定义类型赋值,并转换为用户定义类型),exchange(),compare_exchange_weak(),compare_exchange_strong()。

对于上述每种原子类型的操作,我们可以提供额外的参数,用于设定所需内存次序语义(memory-ordering semantics),参数的值从枚举类std::memory_order中取,若用户没有给出则默认使用最严格的次序std::memory_order_seq_cst。操作可以划分为以下三类:

操作类型 可选参数(均省略“std::memory_order_”前缀)
存储(store) relaxed、release、seq_cst
载入(load) relaxed、consume、acquire、seq_cst
读改写(read-modify-write) relaxed、release、consume、acquire、acq_rel、seq_cst

2.2标准原子类型的通用操作

2.2.1std::atomic_flag

std::atomic_flag是最简单的标准原子类型,表示一个bool标志,该类型只有两种状态:成立或置零。

该类型的对象由宏ATOMIC_FLAG_INIT初始化,标志初始化只能为零

#include 
std::atomic_flag f = ATOMIC_FLAG_INIT;

完成对象初始化后,我们可以进行三种操作:销毁(析构函数)置零(clear())读取原有值返回并设置标志成立(test_and_set())

我们可以为clear()和test_and_set()指定内存操作次序。其中clear为存储操作,无法采用acquire和acq_rel内存次序;test_and_set为读-改-写,能采用各种次序:

f.clear(std::memory_order_release);
bool x = f.test_and_set();

所有原子类型都无法使用拷贝赋值和拷贝构造,因为这两项操作涉及两个对象,必须先从源对象读取值,再将其写入到目标对象,无法原子化 。

正因std::atomic_flag的功能受限,它可以完美扩展成自旋锁互斥(spin-lock mutex)。

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));//循环尝试锁住互斥,返回false说明线程已将标志设置为成立
	}
	void unlock()
	{
		flag.clear(std::memory_order_release);//标志位置0,解锁互斥
	}
};

上述自旋锁互斥在lock()函数内忙等(一直循环,占用大量时间)。

2.2.2std::atomic

由于atomic_flag不支持无修改查值操作,无法作为通用布尔标志,因此最好使用功能更齐全的std::atomic。std::atomic可能不具备无锁结构,需要内部互斥保证原子性,调用is_lock_free()可以检查。

该类型初始值可以是true或false,可以依据bool创建对象。

std::atomic b(true);
b = false;

原子类型的常见模式:赋值操作不返回引用,而是按值返回(非原子类型的值)。 若按照C++惯例返回接受赋值的目标对象的引用,若有代码需要加载结果值,另一线程可能在返回和加载之间改动其值,按值返回结果就能确保获取正确的值。

std::atomic支持store()存储、load()载入和exchange()读-改-写操作:

std::atomic b;
bool x = b.load(std::memory_order_acquire);//读取为bool
b.store(true);//写操作,可以设定内存次序语义
x = b.exchange(false, std::memory_order_acq_rel);//获取原值,自行选定新值替换

2.2.3“比较-交换”操作

还引入了一种操作:依据原子对象的当前值,决定是否保存新值。通常称为“比较-交换”,由成员函数compare_exchange_weak()compare_exchange_strong()实现。

入参为expect期望值、desire新值、order内存次序。给定期望值expect,原子变量将它和自身的值比较,相等就存入给定的新值desire,返回true;否则,将期望值expect所属变量赋予原子变量的值,返回false。

对于compare_exchange_weak(),即使原子变量等于期望值,保存动作也可能失败,原子变量维持原始值,返回false。原子化的比较-交换由一条指令单独完成,若某些处理器没有指令,无法保证原子化方式执行;或是由于线程可能执行中途由于系统调度切出,导致执行失败。这种不是变量本身的原因,而是由于函数的执行时机不对,引发的失败成为佯败(suprious failure),因此往往配合循环使用:

	bool expected = false;
	extern std::atomic b;//其它代码设定的初始值
	while (!b.compare_exchange_weak(expected, true) && !expected);//循环直到保存成功

对于compare_exchange_strong(),只有当变量值不符合预期时,才返回false。但是其实现内部有一个循环,因此不利于性能。

如果expected和原子变量的值不相等,又没有其它线程发挥作用,expected将被赋予原子变量的值,compare_exchange_weak()或compare_exchange_strong()的下轮调用将成功,返回true。

场景:线程B等待线程A,线程Bwhile等待,A执行到某个步骤将对应的原子变量置为expect值即可。

2.2.4“比较-交换”操作的内存次序

比较-交换操作可以接收两个内存次序参数,对成功和失败采用不同内存次序语义,一般成功采用std::memory_order_acq_rel,失败则没有存储操作,使用std::memory_order_relaxed。

另外,成功的内存次序至少要于失败同样严格,如果没有设定失败的次序,编译器会假定他与成功相同的次序,但是解释语义被移除:memory_order_release变成relaxed,acq_rel变成acquire。

若都没设定,默认为则acq_cst,按照完全顺次的方式读写内存。

2.3std::atomic

指向T类型的指针,其原子化形式为std::atomic,同样不能拷贝赋值或赋值。

与std::atomic一样具有成员函数is_lock_free()、load()、store()、exchange()、compare_exchange_weak()和compare_exchange_strong(),与其他原子操作一样,接收和返回的参数是T*类型。

成员函数fetch_add()和fetch_sub()给出基本操作,分别就对象中存储的地址进行原子化加减,但返回的指针时原来的地址。而重载运算符+=、++、-=、--等则与内建类型一样,返回指针改变后的地址:

class Foo();
Foo some_array[5];
std::atomic p(some_array);
Foo* x = p.fetch_add(2);//返回旧值
assert(x == some_array);
assert(p.load() == &some_array[2]);
x = (p -= 1);//返回新值
assert(x == &some_array[1]);
assert(p.load() == &some_array[1]);
x.fetch_add(3, std::memory_order_release);//指定内存次序

fetch_add和fetch_sub都是“读-改-写”操作,可以选取任何内存次序,而重载运算符的形式无法给出内存次序参数,维持默认seq_cst。
其余原子类操作大同小异,具有相同接口,可以视为同一类型。 

2.3.1标准整数原子类型

std::atomic和std::atomic的整数原子类型上,可以执行的操作颇为齐全:包括load、store、exchange、compare_exchange_weak、compare_exchange_strong,也包括原子运算fetch_add(+=)、fetch_sub、fetch_and、fetch_or、fetch_xor,以及复合赋值形式+=、-=、&=、|=、^=,还有前后缀形式++、--。相比非原子的整型缺少的仅仅是乘除和位移。

实际上,整数原子类型往往用作计数器或掩码。

2.4泛化的std::atomic<>类模版

我们还可以依据自定义类型UDT创建原子类,前提是自定义类型具有平实拷贝赋值操作(trival copy-assignment operator)(指平实简单的原始内存复制及其等效操作),它不得含有任何虚函数,也不可以从虚基类派生,还必须由编译器代其隐式生成拷贝赋值操作符,其非静态数据成员或基类也同样。因此赋值操作不涉及任何用户代码,编译器可借用memcpy()行为完成。

比较-交换操作采用逐位比较(bitwise comparison)运算,等同于memcmp()函数,即使UDT定义了比较运算符也会忽略。因此,若UDT具有填充位(padding bit),即使UDT对象的值相等,比较-交换操作也会失败。

编译器根据类定义的alignas说明符或编译命令,可能会在对象内各数据成员后留出间隙,令它们按2次幂字节数对齐内存地址,从而加速读写,这些间隙称为填充位,不具名,对使用者不可见。

UDT不能自定义赋值拷贝赋值操作符或比较运算符,因为若没有该限制,就需要向用户定义的函数传入受保护数据的引用,违背了“若用锁保护数据,不得将受保护数据的指针或引用传入该函数”的安全准则。通过这种限制,编译器能将用户自定义的类型视为原始字节,从而使某些特定原子类按无锁方式具现化。

内建浮点类型(double或float)满足memcpy()和memcmp()的适用条件,但调用compare_exchange_strong()函数可能由于表示方式的不同而失败(如80.0可以表示成5*(2^4)或20*(2^2)),即便值相等。如果我们原子化自定义类型,该类型定义了自己的等值比较运算符重载,判别方式与memcmp()不同,如果用这一特例调用compare_exchange_strong(),同样会因为表示方式不同而失败。

数据结构越复杂,越倾向于采取繁杂的赋值操作和比较运算,那么最好还是使用std::mutex保护数据。

各原子类型可执行操作表

操作 atomic_flag atomic atomic atomic 其它
test_and_set Y
clear Y
is_lock_free Y Y Y Y
load Y Y Y Y
store Y Y Y Y
exchange Y Y Y Y

compare_exchange_weak

compare_exchange_strong

Y Y Y Y
fetch_add,+= Y Y
fetch_sub,-= Y Y
fetch_or,|= Y
fetch_and, &= Y
fetch_xor, ^= Y
++, -- Y Y

2.5原子操作的非成员函数

大部分非成员函数依据成员函数命名,冠以前缀“atomic_”(如std::atomic_load()),还针对各原子类型进行重载。只要可以指定内存次序,这些就有另一个函数变体,冠以“_explict”的后缀,接收更多参数指定内存次序。如std::atomic_store(&atomic_var,new_val)和std::atomic_store_explict(&atomic_var,new_val,std::memory_order_release)。

成员函数的调用会隐式操作原子对象,而非成员函数的第一个参数需要接收操作的目标对象指针。以下是等价的成员函数和非成员函数:

成员函数 非成员函数
a.is_lock_free() std::atomic_is_lock_free(&a)
a.load() std::atomic_load(&a)
a.load(std::memory_order_acquire) std::atomic_load_explicit(&a, std::memory_order_acquire)

根据C++的设计标准,非成员函数要兼容C语言,所以它们的第一个参数是目标原子对象的指针。

比较-交换的成员函数有两种重载,一个版本接收一种内存次序,另一版本接收成功和失败两种次序,但非成员函数std::atomic_compare_exchange_weak_explicit()没有重载版本必须同时指定两种情况。 

std::atomic_flag的非成员函数版本是std::atomic_flag_test_and_set()和std::atomic_flag_clear()。

C++标准库还提供了按原子化形式访问std::shared_ptr<>的实例,共享指针的原子操作包括载入、存储、交换和比较-交换,与上述标准原子类型操作一样,都是同名函数的重载,第一个参数属于std::shared_ptr<>*类型。

std::shared_ptr p;
void process_global_data()
{
	std::shared_ptr local = std::atomic_load(&p);//load
	process_data(local);
}
void update_global_data()
{
	std::shared_ptr local(new T);
	std::atomic_store(&p, local);//store
}

同样可以使用std::atomic_is_lock_free()检验锁结构,以及“_explicit”后缀指定内存次序。 

头文件定义了std::experimental::atomic_shared_ptr,它被设计为独立的类型,按照这种形式,它有机会通过无锁方式实现,而且比普通的shared_ptr无额外开销。在多线程环境下的共享指针,应该优先采用std::experimental::atomic_shared_ptr。优点:代码更清晰,确保全部访问都按原子化方式进行,预防使用普通成员函数。

3.同步操作和强制次序

假设有两个线程操作同一数据结构,一个负责增添数据,另一个负责读取数据。为了避免恶性竞争,给线程设置一个标志位,表示数据是否存储妥当,读线程一直待命,等到标志成立才着手读取。

#include
#include
#include

std::vector data;
std::atomic < bool > rdy(false);
void reader_thread()
{
	while (!rdy.load())//循环等待数据准备
	{
		std::this_thread::sleep_for(std::chrono::microseconds(1));
	}
	std::cout << data[0] << std::endl;//数据读取
}
void writer_thread()
{
	data.push_back(2);//写入数据
	rdy.store(true);//存储标志位
}

原子变量rdy的操作提供了所需的强制次序,数据写入操作在标志变量rdy设置为true之前,rdy读取为true在数据读取之前。 因为先行关系的可传递性,预定的次序被强制施行:数据写入操作在数据读取之前。

3.1同步关系

同步关系仅存在于原子类型之间的操作。基本思想是:对变量x执行原子写操作W和原子读操作R,且两者都有适当的标记,彼此同步需要满足:

1.R读取了W存入的值。

2.W所属线程随后还执行了另一原子写操作,R读取了后面存入的值。

3.任意线程执行一连串“读-改-写”操作,而其中第一个操作读取的值由W写出。

线程甲存入一个值,再由线程乙读取,其关键之处在于“适当的标记”,C++内存模型中,操作原子类型时所受的各种次序约束。

3.2先行关系

先行关系和严格先行(strongly-happens-before)关系是在程序中确立操作次序的基本要素:若某项操作按流程顺序在另一项操作之前,前者严格优先于后者。

如果同一语句内出现多个操作,则它们之间通常不存在先行关系,如以下例子,两个get_num()的次序不明,无法确定到底输出哪一个,遂打印的结果可能为"1,2"或"2,1":

#include
void foo(int a, int b)
{
	std::cout << a << "," << b << std::endl;
}

int get_num()
{
	static int i = 0;
	return ++i;
}

int main()
{
	foo(get_num(), get_num());
}

某些单一语句含有多个操作,但还是按一定顺序执行:内建逗号操作符拼接的表达式;一个表达式的结果充当另一个表达式的参数(函数嵌套) 。单一语句的多个操作往往没有规定次序,不存在控制流程的先后关系(sequence before)。

假设甲线程执行甲操作,乙线程执行乙操作,甲操作跨线程地优先于乙操作,称为线程间先行关系(inter-thread happens-before),该关系依赖于同步关系。在此基础上,若乙操作跨线程地先于丙操作执行,则甲操作也优先于丙,优先关系具有传递性。

线程间的先行关系还能与线程内的控制流程结合:若甲操作在乙之前发生,且乙跨线程地先于丙,则甲同样跨线程优先于丙;又如甲乙操作同步,而丙在乙之后发生,则甲跨线程优先于丙。

严格先行关系与先行关系略有不同,区别在于,在先行关系中,各种操作都被标记为memory_order_consume,而严格先行关系无此标记。

3.3原子操作的内存次序

原子类型操作服从6种次序,它们都以memory_order_前缀开头,分别为:relaxed、consume、acquire、release、acq_rel和seq_cst。它们代表三种模式:先后一致次序(seq_cst)、获取-释放次序(consume、acquire、release、acq_rel)、宽松次序(relaxed)。

在不同CPU架构上,这些内存次序有不同运行的开销。比如首先采用获取-释放次序,然后换为宽松次序,若需要精确控制执行的结果,系统可能要插入额外的同步指令。采用X86或X86-64架构的CPU并不需要额外的同步命令,确保获取-释放次序操作的原子化。(甚至不需要采取特殊的载入操作就能保障先后一致次序,而且存储行为开销稍微增加)。

3.3.1先后一致次序

默认的内存次序,如果程序服从此次序,就简单地把一切事件视为按先后顺序发生。假设多线程程序的全部原子类型实例上,所有操作都保持先后一致,那么将它们改由单线程执行,两个程序的操作毫无区别。“先后一致次序”易于分析和推理,对于并发操作,我们可以把所有可能的顺序组合列出,剔除操作不一致的顺序,从而验证代码是否符合预期。

尽管这种内存次序易于理解,在弱保序的多处理器计算机上,为了保持多处理器之间维持全局操作次序,大量的同步操作为降低系统性能。

保持先后次序会形成一个全局总操作序列:

#include 
#include
#include 
std::atomic x, y;
std::atomic z;
void write_x()
{
	x.store(true, std::memory_order_seq_cst);
}

void write_y()
{
	y.store(true, std::memory_order_seq_cst);
}

void read_x_then_y()
{
	while (!x.load(std::memory_order_seq_cst));
	if (y.load(std::memory_order_seq_cst))
		++z;
}
void read_y_then_x()
{
	while (!y.load(std::memory_order_seq_cst));
	if (x.load(std::memory_order_seq_cst))
		++z;
}
int main()
{
	x = false;
	y = false;
	z = 0;
	std::thread a(write_x);
	std::thread b(write_y);
	std::thread c(read_x_then_y);
	std::thread d(read_y_then_x);
	a.join();
	b.join();
	c.join();
	d.join();
	assert(z.load() != 0);
}

上述代码中,x和y的存储操作必然先行发生一个,如果线程先见到x==true再见到y==false,变量x的存储发生在y之前;也有可能y==true,x==false,函数read_x_then_y等到x为true,执行xz++,最终z=1;也有可能两者均为true,z=2,但z不为0。 

3.3.2宽松次序

线程之间的全局同步会造成频繁通信,因此先后一致次序也是代价最高的内存次序。我们尝试脱离先后一致次序,使用并发思维分析同时发生的事件。

采用宽松次序,则原子类型上的操作不存在同步关系。memory_order_relaxed次序无须额外的同步操作,同一线程内,同一个变量的操作仍然服从先行关系,但几乎不要求线程间存在任何次序关系。

#include
#include
#include
std::atomic x, y;
std::atomic z;
void write_x_then_y()
{
	x.store(true, std::memory_order_relaxed);
	y.store(true, std::memory_order_relaxed);
}
void read_y_then_x()
{
	while (!y.load(std::memory_order_relaxed));
	if (x.load(std::memory_order_relaxed))
		++z;
}
int main()
{
	x = false;
	y = false;
	z = 0;
	std::thread a(write_x_then_y);
	std::thread b(read_y_then_x);
	a.join();
	b.join();
	assert(z.load() != 0);
}

这次我们采用宽松次序,最终z可能为0,断言出发错误,即使y载入为true,变量x存储操作在y之前,变量x的载入操作也可能读取false值。 

宽松次序使得每个原子变量相互独立,线程间异步执行,当原子变量多且线程多时难以控制,应该避免使用。

3.3.3获取-释放次序

获取-释放次序比宽松次序严格一点,会产生一定程度的同步效果。

原子化载入为获取操作(memory_order_acquire)。

原子化存储为释放操作(memory_order_release)。

原子化“读-改-写”操作(fetch_add或exchange)则为获取或释放操作,或二者皆是(memory_order_acq_rel)。

memory_order_acquire限制后方的任何操作不得重新编排到它前面,memory_order_release限制前方的任何操作不得重新编排到它后面。

#include
#include
#include
std::atomic x, y;
std::atomic z;
void write_x()
{
	x.store(true, std::memory_order_release);
}
void write_y()
{
	y.store(true, std::memory_order_release);
}
void read_x_then_y()
{
	while (!x.load(std::memory_order_acquire));
	if (y.load(std::memory_order_acquire))
		++z;
}
void read_y_then_x()
{
	while (!y.load(std::memory_order_acquire));
	if (x.load(std::memory_order_acquire))
		++z;
}
int main()
{
	x = false;
	y = false;
	z = 0;
	std::thread a(write_x);
	std::thread b(write_y);
	std::thread c(read_x_then_y);
	std::thread d(read_y_then_x);
	a.join();
	b.join();
	c.join();
	d.join();
	assert(z.load() != 0);
}

x和y的存储操作由不同线程写出,两个操作都不会影响对方的线程。变量x和y的载入操作有可能都读取为0,触发断言错误。

#include
#include
#include
std::atomic x, y;
std::atomic z;
void write_x_then_y()
{
	x.store(true, std::memory_order_relaxed);
	y.store(true, std::memory_order_release);
}
void read_y_then_x()
{
	while (!y.load(std::memory_order_acquire));
	if (x.load(std::memory_order_acquire))
		++z;
}
int main()
{
	x = false;
	y = false;
	z = 0;
	std::thread a(write_x_then_y);
	std::thread b(read_y_then_x);
	a.join();
	b.join();
	assert(z.load() != 0);
}

本例中,y存储操作使用release次序,y的载入操作使用acquire次序, 所以两者同步,后者会读取前者写出的true;同时x和y的存储操作在同一线程内,由于y的操作内存次序为release,所以x的存储会发生在y的存储之前,而y存储发生在y载入之前(同步),所以x的存储操作也在x的载入之前。因此,x必然加载为true不会触发断言。

释放获取操作成对,且在相同原子变量上执行,才可以产生同步,释放-存储的值为获取-载入操作所见,才会产生有效同步。

读-改-写操作采用memory_order_acq_rel次序,则其行为是获取和释放结合,因此前方的存储操作和后方的载入操作都会与之同步:

std::atomic sync(0);
void thread_1()
{
	//...
	sync.store(1, std::memory_order_release);
}
void thread_2()
{
	int expected = 1;
	while (!sync.compare_exchange_strong(expected, 2, std::memory_order_acq_rel))
		expected = 1;
}
void thread_3()
{
	while (sync.load(std::memory_order_acquire) < 2);//循环直到sync==2
	//...
}

我们可以用获取-释放操作实现简单的锁,其行为表现为先后一致次序,开销会低于全局一致顺序。 

3.3.4memory_order_consume

memory_order_consume次序是获取-释放次序的组成部分,它完全针对数据依赖不建议采用,以下介绍只是为了知识的完备性。

数据依赖:若我们执行两项操作,第一项得出的结果由第二项继续处理,即构成数据依赖。

携带依赖:甲操作的结果是乙操作的操作数,则甲操作带给乙操作携带依赖。

前序依赖:甲操作执行存储,次序为release、acq_rel或seq_cst,乙操作执行载入操作,memory_order_consume标记为消耗行为。那么乙操作读取了甲操作的值,乙操作前序依赖于甲操作,且甲操作跨线程地在乙操作之前发生。

 若代码中有大量携带依赖,会产生额外开销,我们可以运用std::kill_dependency()显式打断依赖链,std::kill_dependency()是一个简单的函数模版,它赋值调用者给出的参数,直接将其作为返回值,借此打断依赖链:

int global_data[] = {...};//只读的全局数组
std::atomic index;
void f()
{
	int i = index.load(std::memory_order_consume);//index由别的线程给出
	do_something(global_data[std::kill_dependency(i)]);//std::kill_dependency告诉编译器无需重读数组元素
}

3.3.5释放序列和同步关系 

对于同一个原子变量,我们在甲线程上对其存储,标记是release、acq_rel或seq_cst,在乙线程上载入,标记为acquire、consume或seq_cst,则操作前后相扣成链,即使存储和读取之间还存在多个任意次序(甚至relaxed)的“读-改-写”操作,同步关系依然成立,操作链由一个释放序列组成。

#include 
#include

std::vector queue;
std::atomic count;

void populate()
{
	const unsigned n = 20;
	queue.clear();
	for (unsigned i = 0; i < n; ++i)
	{
		queue.push_back(i);
	}
	count.store(n, std::memory_order_release);//最初的存储操作
}

void consume()
{
	while (true)
	{
		int index;
		if ((index = count.fetch_sub(1, std::memory_order_acquire)) <= 0)//读-改-写操作,取出一项数据,若刚好空了则等待
		{
			wait_for_more_items();//等待装入新数据项
			continue;
		}
		process(queue[n - 1]);//从容器读取数据(安全行为)
	}
}

int main()
{
	std::thread a(populate);
	std::thread b(consume);
	std::thread c(consume);
	a.join();
	b.join();
	c.join();
}

上例中fetch_sub是服从 acquire次序的读取操作,存储则是服从release次序,构成同步关系。但现在有两个线程读取数据,它们都按照原子化方式执行fetch_sub(),后一个进程读取前一个输出的结果,实际上,第一个线程处于释放序列,所以store()仍与第二个fetch_sub()同步。

操作链上可以有多个读-改-写操作,每个都应与对应的store()同步。

3.4栅栏

完整的原子操作库应当有栅栏(fence)功能。栅栏操作通过全局函数执行,常被称作“内存卡”或“内存障”,它们在代码中划出界线,限定某些操作不得通过。针对不同变量的宽松操作,编译器或硬件可以自主对其进行重新编排,栅栏限制了这一重新编排。

上述例子在写入线程中加入释放栅栏,在读取线程中加入获取栅栏,配对构成同步,使得x的存储操作必然在x的载入操作之前,主线程的断言不会触发。

#include 
#include
#include

std::atomic x, y;
std::atomic z;
void write_x_then_y()
{
	x.store(true, std::memory_order_relaxed);
	std::atomic_thread_fence(std::memory_order_release);
	y.store(true, std::memory_order_relaxed);
}
void read_y_then_x()
{
	while (!y.load(std::memory_order_relaxed));
	std::atomic_thread_fence(std::memory_order_acquire);
	if (x.load(std::memory_order_relaxed))++z;
}
int main()
{
	x = false;
	y = false;
	z = 0;
	std::thread a(write_x_then_y);
	std::thread b(read_y_then_x);
	a.join();
	b.join();
	assert(z.load() != 0);
}

释放栅栏令y的操作不再服从宽松次序,而如是release一样,同理,载入操作也如acquire次序,但真正的同步点是栅栏本身。 

3.5凭借原子操作令非原子操作服从内存次序

我们把上面例子中x的类型改成非原子化的bool类型,程序的行为完全相同:

#include 
#include
#include

bool x = false;
std::atomic y;
std::atomic z;
void write_x_then_y()
{
	x = true;
	std::atomic_thread_fence(std::memory_order_release);
	y.store(true, std::memory_order_relaxed);
}
void read_y_then_x()
{
	while (!y.load(std::memory_order_relaxed));
	std::atomic_thread_fence(std::memory_order_acquire);
	if (x) ++z;
}
int main()
{
	x = false;
	y = false;
	z = 0;
	std::thread a(write_x_then_y);
	std::thread b(read_y_then_x);
	a.join();
	b.join();
	assert(z.load() != 0);
}

本例中,有两个栅栏,分别位于x、y存储操作和y、x载入操作之间,使得读取和载入操作服从先后次序,x的存储载入操作依旧存在先后关系,断言依旧不出发。 但是变量y的存储和载入操作必须采用原子操作,否则会出现数据竞争。

我们也可以用release和consume、acquire令非原子操作服从次序,本章的relaxed标记的原子操作可以重写为非原子操作。

3.6强制非原子操作服从内存次序

lock实现的方式(2.2.1有介绍)是在循环中调用flag.test_and_set(),次序为std::memory_order_acquire;unlock实现方式是在循环中调用flag.clear(),次序为std::memory_order_release。

开始时,flag标志位为0(false),test_and_set的第一次调用会设置标志位成立,并返回当前状态false(0),表示线程已获取锁,循环结束,此时其它线程再调用lock,都会在test_and_set()循环中阻塞。

持锁线程完成改动后,调用unlock(),调用release次序的flag.clear(),即受保护的数据改动需按流程顺序在unlock调用前完成

若第二个线程调用lock反复执行test_and_set(),该操作使用了acquire语义次序,即需要获取锁再进行数据改动,flag上的两项操作形成同步,只有解锁后才能重新获取锁。

3.7相关工具的同步关系

1、std::thread

凭借std::thread实例创建线程并执行,那么该实例构造函数完成与前者的调用形成同步。

对象上执行了join,此函数成功返回,则线程运行完成与这一返回动作同步。

2、std::mutex、std::timed_mutex、std::recursive_mutex、std::timed_recursive_mutex、std::shared_mutex和std::shared_timed_mutex

给定一个互斥对象,对其的每个unlock()都与下一个lock()或try_lock()、try_lock_until()、try_lock_for()、try_lock_shared()、try_lock_shared_for()、try_lock_shared_until()的成功调用构成同步关系。

但是try调用失败则不构成同步关系。

3.std::async、std::promise和std::packaged_task的future对象

给定promise、packaged_task对象,我们可以由get_future()得到关联的std::future对象,或调用std::async启动任务(launch::async异步运行,launch::deferred同步运行)在关联对象上调用wait、get、wait_for或wait_until成功返回std::future_status::ready,则任务运行完成与成功返回std::future_status::ready构成同步

如果,出现异常,则异步状态存储到std::future_error异常对象,析构函数的运行与返回std::future_status::ready构成同步

4.std::experimental::future、std::experimental::shared_future和后续函数

异步共享状态会因目标事件触发而成就绪,共享状态的后续函数也随之运行,事件触发与后续函数启动构成同步

5.std::experimental::latch

复习:线程闩,count_down()减持,直到计数器0,线程在wait()处等待。

给定std::experimental::latch实例,count_down或count_down_and_wait调用的启动与自身的完成同步。

6.std::experimental::barrier与std::experimental::flex_barrier

复习:线程卡,需要指定线程组的线程数,可重复使用的同步构件,arrive_and_wait()处等待,arrive_and_drop()线程移出线程组。felx_barrier的补全函数(一般以lamda形式传入构造函数):执行过程中由其中一个线程完成的函数。

arrive_and_wait()或arrive_and_drop()的调用与下一次arrive_and_wait()运行同步。

felx:arrive_and_wait()每次调用的启动与其补全函数的下一次启动同步。

调用的arrive_and_wait()会等待补全函数而阻塞,补全函数的返回与调用的完成构成同步。

7.std::condition_variable和std::condition_variable_any

条件变量,本质上是忙等循环的优化,同步功能由互斥提供。

小结

原子类                                                                                                                                             原子类(除了std::atomic_flag)都提供is_lock_free操作用于检验内部锁结构,或使用静态成员变量X::is_always_lock_free(C++17)。

原子类型通过类模版std::atomic特化而成,泛化模版可以创建自定义类型的原子类变体,操作仅限load()、store()、exchange()、compare_exchange_weak()、compare_exchange_strong()、对象地址的原子化加减fetch_add()(返回原地址)、++、+=(返回改变后地址)等。

最简单的原子类型std::atomic_flag,由宏ATOMIC_FLAG_INIT初始化,可以进行销毁(析构函数)、置零clear()、读取返回原值并设置成立test_and_set()三种操作。
std::atomic支持store()存储、load()载入和exchange(true/false)读-改-写操作(获取并返回原值,自行选定新值替换)。

泛化原子类型的限制:泛化成原子类型的自定义类不得含有任何虚函数,也不可以从虚基类派生;不能自定义拷贝赋值操作和比较运算符,编译器借助memcpy和memcmp完成;该限制包括静态数据成员和基类。

原子操作的非成员函数:大部分非成员函数依据成员函数命名,冠以前缀“atomic_”(如std::atomic_load());
只要函数可以指定内存次序,就有另一个函数变体,冠以“_explict”的后缀,接收内存次序参数。如:std::atomic_store(&atomic_var,new_val)和std::atomic_store_explict(&atomic_var,new_val,std::memory_order_release)。

比较交换操作:compare_exchange_weak()(会引发佯败,配合循环使用)和compare_exchange_strong()(只有变量不符预期才返回false,开销较大)。                             
参数:入参为expect期望值、desire新值、order内存次序。
解释:给定期望值expect,原子变量将它和自身的值比较,相等就存入给定的新值desire,返回true;否则,将期望值expect所属变量赋予原子变量的值,返回false。
语义次序限制:比较交换操作接收成、败两个语义次序,成功严格度>=失败严格度。

其它:头文件定义了std::experimental::atomic_shared_ptr,有机会通过无锁方式实现,无额外开销,在多线程环境下的共享指针,应该优先采用

内存次序                                                                                                                  std::memory_order_前缀。先后一致:seq_cst;宽松:relaxed(不推荐);释放:release(前方不能到后);获取:acquire(后方不能到前);释放获取:acq_rel(可"承上启下");consume(不建议)。

store(std::memory_order_release)和load(std::memory_order_acquire)构成同步关系。

栅栏
std::atomic_thread_fence(std::memory_order_release)和std::atomic_thread_fence(std::memory_order_acquire),两者构成同步。

你可能感兴趣的:(C++并发,c++)