c++ 原子操作 赋值_《C++并发编程实战第2版》第五章:C++内存模型与原子类型操作(2/4)...

本文深入探讨了C++并发编程中的原子操作,包括原子整型类型的常用操作如fetch_add和fetch_sub,以及类模板std::atomic用于自定义类型的原子操作。文章还介绍了内存模型中的“同步于”和“先发生于”关系,强调了这些关系在多线程编程中的重要性,以及不同内存顺序对性能和代码行为的影响。
摘要由CSDN通过智能技术生成

注:本章内存模型术语较多,翻译出来稍显晦涩,建议本章从头开始阅读

5.2.5 标准原子整型类型上的操作

和通常的操作集合一样(load(), store(), exchange(), compare_exchange_weak(), 和compare_exchange_strong()),原子整形类型如std::atomic<int>std::atomic<unsigned long long>有相当全面的一整套可用的操作:fetch_add(), fetch_sub(), fetch_and(), fetch_or(), fetch_xor(),还有复合赋值形式的操作((+=, -=, &=, |=和^=),以及++和--(++x, x++, --x和x--)。它并不是你可以在普通整数类型上执行的完整的复合赋值操作集,但它已经足够接近了:只有除法、乘法和移位操作符缺失了。因为原子整数值通常用作计数器或位掩码,所以这也不是一个特别明显的损失;如果需要的话,可以在循环中使用compare_exchange_weak()轻松完成其他操作。

std::atomic<T*>(译注:我怀疑是笔误,应该是std::atomic<T>,T是整型类型)的语义与fetch_add()和fetch_sub()非常匹配;命名函数自动执行其操作并返回旧值,而复合赋值操作符返回新值。前置和后置的增减操作与往常一样:++x递增变量并返回新值,而x++递增变量并返回旧值。正如你所期望的,在这两种情况下,结果都是相关整形的值。

我们已经看过所有基本原子类型;剩下的就只有泛型的std::atomic<>主类模板,而非其特化。所以接下来让我们来看一下这个。

5.2.6 std::atomic<>主类模板

主模板的存在允许用户创建用户自定义类型的原子变体,而不只限于标准原子类型。给定用户自定义的类型UDT,std::atomic<UDT>提供了与std::atomic<bool>相同的接口(如5.2.3节所述),除了将bool参数和与存储值相关的返回类型(而不是比较交换操作的成功/失败结果)换成UDT。你不能将任意的用户自定义类型和std::atomic<>一起使用;类型必须满足特定的条件。为了对某些用户自定义的类型UDT使用std::atomic<UDT>,该类型必须有一个平凡的(trivial)拷贝赋值操作符。这意味着该类型必须没有任何虚函数或虚基类,并且必须使用编译器生成的拷贝赋值操作符。不仅如此,用户自定义类型的每个基类和非静态数据成员还必须具有平凡的拷贝赋值操作符。这允许编译器使用memcpy()或一个等价的操作用于赋值操作,因为不需要运行用户写的代码。

最后,值得注意的是,比较交换操作就像使用memcmp一样进行按位比较,而不是使用任何可能为UDT定义的比较运算符。如果该类型提供的比较操作具有不同的语义,或者该类型具有不参与普通比较的填充位,那这可能导致比较-交换操作失败,即使比较的值是相等的。

这些限制背后的原因可以追溯到第3章中的一条指南:不要将指针和引用作为参数传递给用户提供的函数,从而在锁范围之外传递受保护数据。一般来说,编译器不能为std::atomic<UDT>生成无锁代码,因此它必须为所有操作使用一把内部锁。如果允许用户提供的拷贝赋值或比较操作符,则需要将对受保护数据的引用作为参数传递给用户提供的函数,这违反了指南。此外,库完全可以自由地对所有需要锁的原子操作使用单个锁,允许在持有该锁的同时调用用户提供的函数可能会导致死锁或因为比较操作花费了很长时间从而导致其他线程阻塞。最后,这些限制增加了编译器直接为std::atomic<UDT>使用原子指令的机会(并使特定的实例化无锁),因为它可以将用户自定义的类型视为一组原始字节。

注意,尽管你可以使用std::atomic<float>或std::atomic<double>,因为内置的浮点类型确实满足与memcpy和memcmp一起使用的条件,但在compare_exchange_strong(如前所述,compare_exchange_weak总是会由于任意的内部原因而失败)的情况下,行为可能会令人吃惊。如果存储的值具有不同的表示法,即使旧的存储值与比较数等价,操作也可能失败。需要注意的是,没有浮点值上的原子算术操作。如果你使用std::atomic<>来定义用户自定义的类型,该类型定义了一个相等比较操作符,并且该操作符与使用memcmp的比较不同,那么你将在compare_exchange_strong中获得类似的行为——操作可能会失败,因为原本相等的值有不同的表示法。

如果你的UDT与int或void*大小相同(或小于),那大多数常见平台都能够对std::atomic<UDT>使用原子指令。一些平台还能够对两倍于int或void*大小的用户自定义类型使用原子指令。这些平台通常支持所谓的双字比较和交换(DWCAS,double-word-compare-and-swap)指令,该指令对应于compare_exchange_xxx函数。正如你将在第7章中看到的,这种支持在编写无锁代码时非常有用。

这些限制意味着你不能,例如,创建std::atomic<std::vector<int>>(因为它有一个特殊的拷贝构造和拷贝赋值操作符)。但是你可以用包含计数器、标志、指针甚至简单数据元素的数组的类来实例化std::atomic<>。这并不是什么大问题;数据结构越复杂,你就越可能希望对其进行操作,而不只是简单的赋值和比较。如果是这种情况的话,你最好使用std::mutex,以确保对于所需的操作数据被适当地保护,就如第3章所述。

如前所述,当用用户自定义的类型T实例化时,std::atomic<T>的接口被限制为std::atomic<bool>可用的操作集合:load()、store()、exchange()、compare_exchange_weak()、compare_exchange_strong()以及类型T实例的赋值和转换。

表5.3 原子类型上的可用操作

652832a398a9ae60a006ec3fa55bd00a.png

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

到目前为止,我仅限于描述原子类型上成员函数形式的操作。但对于各种原子类型的所有操作,也有等价的非成员函数。在大多数情况下,非成员函数以对应的成员函数命名,但是使用atomic_前缀(例如,std::atomic_load())。这些函数都会被不同的原子类型重载。这里有机会指定一个内存顺序标签,他们有两个变种:一个不带标签,一个有_explicit后缀并且有一个或多个额外的参数,用于表示一个或多个内存顺序的标签(例如,std::atomic_store(&atomic_var,new_value)与std::atomic_store_explicit(&atomic_var,new_value,std::memory_order_release)。虽然成员函数引用的原子对象是隐式的,但所有自由函数都将原子对象的指针作为第一个参数。

例如,std::atomic_is_lock_free()只有一种形式(尽管每种类型都重载了),对原子类型的一个对象a,std::atomic_is_lock_free(&a)和a.is_lock_free()返回相同的值。同样std::atomic_load(&a)和a.load()是一样的,但a.load(std::memory_order_acquire)相对应的是std::atomic_load_explicit(&a, std::memory_order_acquire)。

自由函数被设计为与C兼容的,因此它们在所有情况下都使用指针而不是引用。例如,compare_exchange_weak()和compare_exchange_strong()成员函数的第一个参数(预期值)是一个引用,而std::atomic_compare_exchange_weak()(第一个是对象指针)的第二个参数是一个指针。std::atomic_compare_exchange_weak_explicit()也要求指定成功和失败的内存顺序,而比较交换成员函数有单一的内存顺序形式(默认为std::memory_order_seq_cst)和一个重载,分别采用成功和失败的内存顺序。

std::atomic_flag上的操作是“反潮流”的,它们在名字中拼出了flag:std::atomic_flag_test_and_set()std::atomic_flag_clear()。指定内存顺序的额外变体还是有_explicit后缀:std::atomic_flag_test_and_set_explicit()std::atomic_flag_clear_explicit()

C++标准库也提供了自由函数,用于以原子方式访问std::shared ptr<>的实例。这打破了原则:只有原子类型支持原子操作,因为std::shared_ptr<>很肯定不是一个原子类型(从多个线程访问同一个std::shared_ptr<T>对象,又不在所有线程中使用原子访问函数或使用适当的外部同步,是一种数据竞争和未定义的行为)。但是C++标准委员会认为提供这些额外的功能是非常重要的。可用的原子操作有load、store、exchange和compareexchange,它们都是标准原子类型上相同操作的重载,以std::shared_ptr<>*作为第一个参数:

std::shared_ptr<my_data> p;
void process_global_data()
{
  std::shared_ptr<my_data> local=std::atomic_load(&p);
  process_data(local);
}
void update_global_data()
{
  std::shared_ptr<my_data> local(new my_data);
  std::atomic_store(&p,local);
}

与其他类型的原子操作一样,也提供了_explicit变体来允许你指定所需的内存顺序,并且可以使用std::atomic_is_lock_free()函数来检查实现是否使用锁来确保原子性。

并行技术规范也提供了一种原子类型std::experimental::atomic_shared_ptr<T>。要使用它,必须包含<experimental/atomic>头文件。它提供了和std::atomic<UDT>一样的操作集合:load,store,exchange,compare-exchange。它是作为一个单独的类型提供的,因为它允许无锁实现,不会对简单的std::shared_ptr实例强加额外的成本。但是与std::atomic模板一样,你仍然需要检查它在你的平台上是否是无锁的,这可以用is_lock_ free成员函数进行测试。即使它不是无锁结构,也推荐使用std::experimental::atomic_shared_ptr,而不要在普通的std::shared_ptr上使用原子自由函数,因为该类型会让代码更加清晰,确保所有的访问都是原子的,并且能避免由于忘记使用原子自由函数,从而导致数据竞争。与原子类型和操作的所有使用一样,如果使用它们是为了提高速度,那么对它们进行分析并与使用其他同步机制进行比较是很重要的。

如简介中所述,标准原子类型不仅避免了与数据竞争相关的未定义行为;而且它们允许用户强制线程之间的操作顺序。这种强制的顺序是保护数据和同步操作,比如std::mutex和std::future<>的基础。记住这一点,让我们进入本章的实质内容:内存模型并发方面的细节,以及如何使用原子操作来同步数据和强制顺序。

5.3 同步操作和强制顺序

假设有两个线程,其中一个线程正在填充数据结构,以便第二个线程读取。为了避免出现有问题的竞争条件,第一个线程设置了一个标志来表示数据已经准备好了,而第二个线程直到设置了该标志才读取数据。

9285f3db2fdf1b1adc7ab69ab6e7639f.png

先不考虑循环等待数据准备的低效率①,你需要这么做,否则在线程之间共享数据将变得不切实际:每个数据项都必须是原子的。你已经知道,在没有强制顺序的情况下让非原子读②和写③访问相同的数据是未定义的行为,因此要使其工作,必须在某些地方强制顺序。

必需的强制顺序来自std::atomic<bool>变量data_ready上的操作,它们通过内存模型关系“先发生于”(happens-before)和“同步于”(synchronizes-with)提供了必要的顺序。对数据的写操作③发生在对data_ready标记的写操作④之前,对标志的读操作①发生在对数据的读操作②之前。当从data_ready中读取的值①为真时,写操作与读操作同步,创建了一个“先发生于”关系。因为“先发生于”是可传递的,写入数据③发生在写入标志前④,写入标志有发生又发生在从标志中读出true值之前①,读出true值又发生在读取数据之前②,然后你有一个强制顺序: 数据的写入发生在数据的读取之前,一切正常。图5.2显示了这两个线程中重要的“先发生于”关系。我已经从读取线程中添加了几个while循环的迭代。

6c9ab95a729b42ce5b2449d44d9a7c75.png
图5.2使用原子操作在非原子操作间强制一个顺序

所有这些看起来都很直观:写入值的操作发生在读取值的操作之前。对于默认的原子操作,确实是这样(这就是为什么它是默认的),但确实需要说清楚:原子操作对于顺序需求还有其他选项,我很快就会讲到。

既然你已经在实战中看到了“先发生于”和“同步于”,现在是时候看看它们意味着什么了。我将从“同步于”开始。

5.3.1 “同步于”关系

“同步于”关系只能在原子类型的操作之间获得。如果数据结构包含原子类型,并且数据结构上的操作在内部执行适当的原子操作,那么数据结构上的操作(比如锁住互斥锁)可能提供这种关系,但从根本上说,它只来自于原子类型上的操作。

基本思想是这样的:在变量x上,一个适当标记的原子写操作W同步于x上适当标记的原子读操作,读取的值或者是W操作写入的内容,或是和W操作同一个线程上随后的一个写操作对x写入的值;亦或是任意线程对x的一系列原子读-改-写操作(例如,fetch_add()或compare_exchange_weak())写入的值,这里,序列中第一个线程读取的值是W写入的值(参见5.3.4节)。

先把“适当标记”(suitably-tagged)放一边,因为所有对原子类型的操作,默认都是“适当标记”的。这也是你所预料的:如果线程A存储了一个值,并且线程B读取了这个值,线程A的存储操作与线程B的加载操作之间有一个“同步于”关系,如同清单5.2所示的那样。在图5.2中做了说明。

我相信你已经猜到了,细微的差别都在“适当标记”部分。C++内存模型允许对原子类型的操作应用各种顺序约束,这就是我所提到的标记。在5.3.3节中介绍了内存顺序的各种选项,以及它们如何与“同步于”关系相关联。首先,让我们回过头来看看“先发生于”关系。

5.3.2 “先发生于”关系

“先发生于”和“强先发生于”(strongly-happens-before)关系是一个程序中操作顺序的基础构件块:它指定了哪些操作能看见另一些操作的影响。对于单线程来说,这很大程度上是简单的:当一个操作排在另一个前面,那么它在另一个之前发生,并且“强先发生于”另一个。这意味着,在源代码中,如果一个操作(A)先于另一个操作(B)出现在语句中,那么A在B之前发生,并且A“强先发生于”B。在清单5.2中可以看到:对data的写入操作③在对data_ready的写入操作④前发生。如果操作发生在同一个语句,通常情况下它们之间就没有“先发生于”关系,因为它们是无序的。这是表示顺序未指定的另一种方式。你知道下面的程序会输出“1,2”或“2,1”,但是没有指定是哪一个,因为对get_num()的两次调用没有指定顺序。

08be79582a830770cec404709b848183.png

在某些情况下,单个语句中的操作是有序的,比如使用内置的逗号操作符,或者将一个表达式的结果用作另一个表达式的参数。但通常,单个语句中的操作是无顺序的,它们之间没有“先序于”(sequenced-before)(译注:sequenced-before特指同一个线程内的求值顺序,一个求值先于另一个求值)(因此也没有“先发生于”)关系。一个语句中的所有操作都发生在下一个语句中的所有操作之前。

这是对你所熟悉的单线程排序规则的重述,那有什么新东西吗?新的内容是线程间的交互:如果在一个线程上的操作A“线程间先发生于”另一个线程上的操作B,那么A“先发生于”B。这并没有多大帮助:你已经添加了一个新的关系(“线程间先发生于”,inter-thread happens-before),但当你编写多线程的代码时,这是一个重要的关系。

在基本层面上,“线程间先发生于”(inter-thread happens-before)比较简单,并且依赖于5.3.1节介绍的“同步于”关系(详见5.3.1节):如果操作A在一个线程上,与另一个线程上的操作B同步,那么A“线程间先发生于”B。它同样是一个传递关系:如果A “线程间先发生于” B,并且B “线程间先发生于” C,那么A就“线程间先发生于” C。在清单5.2你也看到了这个。

“线程间先发生于”也可以和“先序于”相结合:如果操作A “先序于”操作B,并且操作B“线程间先发生于”操作C,那么A“线程间先发生于”C。类似地,如果A“同步于”B,并且B “先序于”C,那么A “线程间先发生于”C。两者的结合意味着,当你对数据在一个线程内进行一系列修改时,为了让数据被执行C的线程上的后续操作可见,只需要一个“同步于”关系。

“强先发生于”关系会有一些不同,不过在大多数情况下是一样的。上面的两个规则同样适用于“强先发生于”:如果操作A“同步于”操作B,或操作A“先序于”操作B,那么A就是“强先发生于”于B。也可以应用顺序传递:如果A“强先发生于”B,并且B“强先发生于”C,那么A“强先发生于”C。不同点在于,标记为memory_order_consume(参见5.3.3节)的操作参与到"线程间先发生于"关系(因而也是“先发生于”关系),但不参与“强先发生于”关系。由于大多数代码并不适用memory_order_consume,因此这种区别在实际中可能不会有什么影响。为了简洁起见,我将在本书剩下部分使用“先发生于”。

这些是线程间强制操作顺序至关重要的规则,并使清单5.2中的所有内容正常工作。稍后你将看到,数据依赖还有一些额外的细微差别。为了让你理解这一点,我需要介绍用于原子操作的内存顺序标签,以及它们如何与“同步于”关系相关联。

5.3.3 原子操作的内存顺序

有六个内存顺序选项可以应用于原子类型上的操作:memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel, 以及memory_order_seq_cst。除非你为特定的操作指定一个其他的选项,否则内存顺序选项对所有原子类型上的操作都是memory_order_seq_cst,它是可用选项中最严格的。虽然有六个顺序选项,但它们仅代表三种模型:序列一致 (sequentially consistent) 顺序,获得-释放 (acquire-release)顺序(memory_order_consume, memory_order_acquire, memory_order_release和memory_order_acq_rel)和宽松 (relaxed)顺序(memory_order_relaxed)。

不同的内存顺序模型在不同的CPU架构下,成本是不一样的。例如,在基于通过处理器精细控制操作可见性的架构(而不是做出更改的架构)的系统上,“序列一致”顺序相较于“获得-释放”顺序或者“宽松”顺序,以及“获得-释放”顺序相较于“宽松”顺序,都需要额外的同步指令。如果这些系统有很多处理器,这些额外的同步指令可能会花费大量的时间,从而降低系统整体的性能。另一方面,使用x86或x86-64架构的CPU(例如,使用Intel或AMD处理器的台式电脑) 除了确保原子性所必需的指令之外,不需要为“获得-释放”顺序添加额外的指令,甚至“序列一致”顺序对于加载操作也不需要任何特殊的处理,尽管存储操作需要很小的额外成本。

不同内存顺序模型的有效性,允许专家利用更细粒度的顺序关系来提升性能,用于它们有优势的地方,同时允许使用默认的“序列一致”顺序 (相较于其他顺序,它比较简单的)用于非关键的情况。

为了选择使用哪个顺序模型或为了了解代码中的顺序关系,知道不同模型是如何影响程序的行为是很重要的。因此,让我们看看每种操作顺序和同步选择的影响。

“序列一致”顺序

默认的顺序名为序列一致(sequentially consistent),意味着程序的行为与世界的简单顺序视图是一致的。如果原子类型实例上的所有操作都是“序列一致”的,那么多线程程序的行为就好像所有这些操作都是由单个线程按照某种特定的序列执行的。这是迄今为止最容易理解的内存顺序,这就是为什么它是默认的:所有线程必须看到相同的操作顺序。这使得推断用原子变量编写的代码的行为变得很容易。你可以写下不同线程的所有可能的操作序列,消除那些不一致的操作,并验证你的代码在其他线程中的行为是否符合预期。这也意味着操作不能被重新排序;如果你的代码在一个线程中有一个操作先于另一个操作,那这个顺序必须被所有其他线程看到。

从同步的角度来看,一个“序列一致”的存储操作“同步于”相同变量的加载操作,这个加载操作读取存储的值。这为两个(或更多)线程的操作提供了一个顺序约束,但“序列一致”比这更强大。对系统中使用“序列一致”原子操作的其他线程,任何那个加载操作之后执行的“序列一致”的原子操作也必须出现在存储操作之后。清单5.4中的示例演示了这种顺序约束的作用。这种约束不适用于使用“宽松”内存顺序原子操作的线程;它们仍然可以看到不同顺序的操作,因此必须在所有线程上使用“序列一致”的操作,才能受益。

不过,易于理解是要付出代价的。在有许多处理器的弱顺序机器上,可能会造成明显的性能损耗,因为必须在处理器之间保持整个操作序列的一致性,可能需要在处理器之间进行大量(而且昂贵!)的同步操作。即便如此,一些处理器架构(比如通用的x86和x86-64架构)还是提供了成本相对较低的“序列一致”,如果你关心使用“序列一致”对性能的影响,就需要去查阅你目标处理器的架构文档。

以下清单展示了“序列一致”,对于x和y的加载和存储都显示标记为memory_order_seq_cst,尽管这个例子中,标签可以省略掉,因为它本身是默认项。

51f91546ee7187576276582ba45cb866.png

bd4e3a00e51f11453a2e97a68dcbd438.png

assert⑤语句永远不会触发,因为不是存储x的操作①发生,就是存储y的操作②发生,虽然没有指定哪个。如果在read_x_then_y中加载y③返回false,那么存储x的操作肯定发生在存储y的操作之前,在这种情况下read_y_then_x中加载x④必定会返回true,因为while循环能保证在某一时刻y是true。因为memory_order_seq_cst的语义需要在所有标记为memory_order_seq_cst的操作上有一个单一全序,所以在“加载y返回false③”与“存储y①”的操作之间存在一个隐含的顺序关系。因为是单个全序,如果一个线程看到x==true,随后又看到y==false,那就意味着在这个全序中存储x的操作发生在存储y的操作之前。

因为一切都是对称的,所以也可以反过来,如果加载x④的操作返回false,会强制加载y③的操作返回true。这两种情况下,z都等于1。如果两个加载操作都返回true,z就等于2;所以任何情况下,z都不能是0。

针对read_x_then_y看见x为true,并且y为false的情况,相关的操作和“先发生于”关系如图5.3所示。从read_x_then_y中对y的加载操作开始,到达write_y中对y的存储的虚线展示了为了保持“序列一致”而隐含的必须的顺序关系:在memory_order_seq_cst操作的全局顺序中,为了获得这里给出的结果,加载操作必须在存储操作之前发生。

“序列一致”是最简单、直观的顺序,但是也是最昂贵的内存顺序,因为它需要所有线程之间进行全局同步。在一个多处理器系统上,这可能需要在处理器之间进行大量并且耗时的通信。

为了避免这种同步成本,你需要走出“序列一致”的世界,并且考虑使用其他内存顺序。

936ffbda8a528a678d06f5651e2aa0b9.png
图5.3 “序列一致”与“先发生于”
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值