C++内存模型和原子类型上操作

C++11 标准中最重要的特性之一,是大多数程序员甚至都不会关注的东西。它并不是新的语法特性,也不是新的类库功能,而是新的多线程感知内存模型。若是没有内存模型来严格定义基本构造模块如何工作,我所介绍的功能就不能可靠地工作。当然,大多数程序员没有注意到它是有原因的。如果使用互斥元来保护数据,以及条件变量或者 future 作为事件信号,他们为何工作的细节就不重要了。仅当你开始尝试“接近机器”时,内存模型的精确细节才重要。

无论其他语言如何,C++ 是一门系统编程语言。标准委员会的目标之一,是不再需要一个比 C++ 更低级的语言。应该在 C++ 里为程序员提供足够的灵活性,在做任何他们需要的事情时不会被语言挡在路中间,并且在提出需求时允许他们“接近机器”。原子类型和操作正是要允许这一点,提供了可通常减至一或两个 CPU 指令的低阶同步操作的功能。

在本章中,我将从阐述内存模型的基础开始,接着转到原子类型和操作,并最终介绍可用于原子类型上操作的多种同步类型。这是相当复杂的,除非你打算使用原子操作编写代码以实现同步(比如无锁数据结构),否则无需知道这些细节。

让我们放轻松,来看看内存模型的基础。

1. 内存模型基础

内存模型包括两个方面:基本结构方面,这与事物是如何放置在内存中的有关;然后是并发方面。结构方面对于并发是很重要的,尤其从低级原子操作中来看,因此我将从这里开始。在 C++ 中,一切都是关于对象和内存位置。

1.1 对象和内存位置

C++ 程序中的所有数据均是由对象(object)组成的。这并不是说你可以创建一个派生自 int 的新类,或是基本类型具有成员函数,或者当人们谈及如 Smalltalk 或 Ruby 这样的语言,说“一切都是对象”时暗指的任何其他结果。这只是关于 C++ 中数据的构造块的一种陈述。C++ 标准定义对象为“存储区域”,尽管它会为这些对象分配属性,如它们的类型和生存期。

其中一些对象是简单基本类型的值,如 int 或 float,而另一些则是用户定义类的实例。有些对象(例如数组、派生类的实例和具有非静态数据成员类的实例)具有子对象,但其他的则没有。

无论什么类型,对象均被存储于一个或多个内存位置中。每个这样的内存位置要么是一个标量类型的对象(或子对象),比如 unsigned short 或 my_class* ,要么是相邻位域的序列。如果使用位域,有非常重要的一点必须注意:虽然相邻位域是不同的对象,但它们仍然算作相同的内存位置。图 1 表示了一个 struct 如何划分为对象和内存位置。

图1 将 struct 划分为对象和内存位置

首先,整个 struct 是一个对象,它是由几个子对象组成,各子对象对应每个数据成员。位域 bf1 和 bf2 共享一个内存位置,而 std::string 对象在内部由几个内存位置组成,但是其余的每个成员都有自己的内存位置。注意零长度的位域 bf3 是如何将 bf4 分割进它自己的内存位置的。

可以从中汲取四个要点

    ■ 每个变量都是一个对象,包括其他对象的成员。

    ■ 每个对象占据至少一个内存位置。

    ■ 如 int 或 char 这样的基本类型的变量恰好一个内存位置,不论其大小,即使它们相邻或是数组的一部分。

    ■ 相邻的位域是相同内存位置的一部分。

我可以肯定你在怀疑这与并发有什么关系,那么让我们来看一看。

hanhan笔记:这张图一开始看有点误解,应该不要从内存对齐的角度看,因为变量 i、d 和 bf1+bf2 占据的内存大小肯定不是彼此一样的。作者想表达的意思可能是对象是否共用一个“寻址”地址区域,这应该和并发有关系。

1.2 对象、内存位置以及并发

好了,这里是对于 C++ 中多线程应用程序至关重要的部分,所有东西都取决于这些内存位置。如果两个线程访问不同的内存位置,是没有问题的,一切工作正常。另一方面,如果两个线程访问相同的内存位置,那么你就得小心了。如果线程都没有在更新改内存位置,没问题,只读数据不需要进行保护或同步。如果任意一个线程正修改数据,就会有竞争条件的可能。

为了避免竞争条件,在这两个线程的访问中间,就必须有一个强制的顺序。确保有一个确定顺序的方法之一,是使用互斥元。如果同一个互斥元在两个访问之前被锁定,则每次只有一个线程可以访问改内存位置,即有一个必然发生在另一个之前。另一种方法是在相同的或是其他的内存位置使用原子(atomic)操作的同步特性(参见 2 节对原子操作的定义),在两个线程的访问之间强加一个顺序。用原子操作来强制顺序描述于 3 节。如果多于两个线程访问同一个内存位置,则每一对访问都必须具有明确的顺序。

如果来自独立线程的两个对同一内存位置的访问没有强制顺序,其中一个或两个访问不是原子的,且一个或两个是写操作,那么这就是数据竞争并导致未定义行为。

这个陈述是至关重要的:未定义行为是 C++ 最令人不爽的状况之一。根据语言标准,一旦应用程序包含未定义行为,那么一切都很难说,整个应用程序的行为都是未定义的,它能干出任何事情来。我所知道的一个情况是,某个未定义行为的实例导致某人的监视器着火了。虽然这不太可能发生在你身上,但数据竞争绝对是一个严重的错误,且应该不惜一切代价来避免。

在改陈述中,还要另外一个很重要的点:可以通过使用原子操作来访问具有竞争的内存位置,来避免不确定行为。这并不阻止竞争本身——原子操作所接触的内存位置首先仍是未指定的——但是却能够将程序带回确定行为的领域。

在学习原子操作前,还有一个对了解对象与内存位置很重要的概念:修改顺序。

1.3 修改顺序

C++ 程序中每个对象,都具有一个确定的修改顺序(modification order),它是由来自程序中的所有线程的对该对象的所有写入组成的,由对象的初始化开始。在多数情况下,该顺序在每次运行之间都有所不同,但是在任意给定的程序执行里,系统中的所有线程必须一致同意此顺序。如果问题中的对象不是如 2 节所述的原子类型之一,你就得负责确认有足够的同步,来确保线程一致同意每个变量的修改顺序。如果不同的线程看到的是一个变量的不同的顺序值,就会有数据竞争和未定义行为。如果使用原子操作,编译器将负责确保必要的同步已就位。

这一要求意味着某些投机性的执行是禁止的,因为在修改顺序中,一旦某个线程看到了修改顺序中的特定条目,那么后续这个线程的读操作必须返回更晚的值,这个线程对这个对象的写操作必须相较于读操作之后发生。同样,在同一线程中,在对象的写操作之后的读操作,就必须返回要么写入的值,要么在该对象的修改顺序中更晚发生的另一个值。尽管所有的线程都必须一致同意程序中每一个独立对象的修改顺序,但却不必一致同意不同对象上操作的相对顺序。参见 3.3 节更多地了解线程间的操作顺序。

那么,是什么构成了原子操作,它又是如何用来强制顺序的?

2. C++ 中的原子操作及类型

原子操作(atomic operation)是一个不可分割的操作。从系统中的任何一个线程中,你都无法观察到一个完成到一半的这种操作,它要不做完了,要么就没做完。如果读取对象值得载入操作是原子的,并且所有对该对象的修改也都是原子的,那么这个载入操作所获取到的要么是该对象的初始值,要么是被某个修改者存储后的值。

其对立面,是一个非原子操作可能被另一个线程视为半完成的。如果这是一次存储操作,那么被另一个线程观察到的值可能既不是存储前的值也不是已存储的值,而是其他的东西。如果执行非原子的载入操作,那么它可能获取对象的一部分,由另一个线程修改了的值,然后再获取其余的对象,这样获取到的既不是第一个值也不是第二个值,而是两者的某种组合。这就是一个简单的有问题的竞争条件,但是在这个级别,它可能构成数据竞争并因而导致未定义的行为。

在 C++ 中,大多数情况下需要通过原子类型来得到原子操作,让我们来看一看。

2.1 标准原子类型

标准原子类型可以在 <atomic> 头文件中找到。在这种类型上的所有操作都是原子的,在语言定义的意义上说,只有在这些类型上的操作是原子的,虽然你可以利用互斥元使得其他操作看起来像原子的。事实上,标准原子类型本身就可以进行这样的模拟,它们(几乎)都具有一个 is_lock_free() 成员函数,让用户决定在给定类型上的操作是否直接用原子指令完成( x.is_lock_free() 返回 true),或者通过使用编译器和类库内部的锁来完成( x.is_lock_free() 返回 false)。

唯一不提供 is_lock_free() 成员函数的类型是 std::atomic_flag 。该类型是一个非常简单的布尔标识,并且在这个类型上的操作要求是无锁的。一旦你有了一个简单的无锁布尔标志,就可以用它来实现一个简单的锁,从而从此为基础实现所有其他的原子类型。当我说非常简单时,指的是:类型为 std::atomic_flag 的对象被初始化为清除,接下来,它们可以被查询和设置(通过 test_and_set() 函数)或者清除(通过 clear() 函数)。就是这样:没有赋值,没有拷贝构造函数,没有测试和清除,也没有任何其他的操作。

其余的原子类型全都是通过 std::atomic<> 类模板的特化来访问的,并且有一点更加的全功能,但可能不是无锁的(如前所述)。在大多数流行的平台上,我们认为内置类型的原子变种(例如 std::atomic<int>std::atomic<void*>) 确实是无锁的,但却并非是要求的。你马上会看到,每个特化的接口反映了类型的属性。例如,像 &= 这样的位操作没有为普通指针作定义,因此它们也没有为原子指针作定义。

除了可以直接使用 std::atomic<> ,你还可以使用表 1 所示的一组名称,来提及已经提供实现的原子类型。鉴于原子类型如何被添加到 C++ 标准的历史,这些替代类型的名称可能指的是相应的 std::atomic<> 特化的直接命名,可能会导致不可移植的代码。

表1 标准原子类型的替代名称和它们所对应的 std::atomic<> 特化
原子类型 对应的特化
atomic_bool std::atomic<bool>
atomic_char std::atomic<char>
atomic_schar std::atomic<signed char>
atomic_uchar std::atomic<unsigned char>
…… ……

同基本原子类型一样, C++ 标准库也为原子类型提供了一组 typedef ,对应于各种像 std::size_t 这样的非原子的标准库 typedef ,如表 2 所示。

表2 标准库原子类型定义以及其对应的内置类型定义
原子 typedef 对应的标准库 typedef
atomic_int_least8_t int_least8_t
atomic_uint_least8_t uint_least8_t
…… ……

hanhan笔记:这边两个表格只摘录一部分,因为在自己目前的编译器上不支持这些 typedef (应该是有原因的),而且太多了。摘录意义应该不太。

好多的类型啊!有一个很简单的模式,对于标准 typedef T,其对应的原子类型与之同名并带有 atomic_ 前缀: atomic_T 。这同样适用于内置类型,区别是 signed 缩写为 s, unsigned 缩写为 u, long long 缩写为 llong。一般来说,无论你要使用的是什么 T,都只是简单地说 std::atomic<T> ,而不是使用替代名称。

传统意义上,标准原子类型是不可复制且不可赋值的,因为它们没有拷贝构造函数和拷贝赋值运算符。但是,它们确实支持从相应的内置类型的赋值进行隐式转化并赋值,与直接 load() 和 store() 成员函数、 exchange() 、 compare_exchange_weak() 以及 compare_exchange_strong() 一样。它们在适当的地方还支持复合赋值运算符:+=、-=、*=、|= 等,同时整型和针对指针的 std::atomic<> 特化还支持 ++ 和 --。这些运算符也拥有相应命名的具有相同功能的成员函数: fetch_add()、fetch_or() 等。赋值运算和成员函数的返回值既可以是存储的值(在赋值运算符的情况下),也可以是运算之前的值(在命名函数的情况下)。这避免了可能出现的问题,这些问题源于这种赋值运算符通常会返回一个将要赋值的对象的引用。为了从这种引用中获取存储的值,代码就得执行独立的读操作,这就允许另一个线程在赋值运算和读操作之间修改其值,并为竞争条件敞开大门。

然而, <atomic> 类模板并不仅仅是一组特化。它具有一个主模板,可以用于创建一个用户定义类型的原子变种。由于它是一个泛型类模板,操作只限为 load()、store() (和与用户定义类型间的相互赋值)、 exchange() 、 compare_exchange_weak() 和 compare_exchange_strong() 。

在原子类型上的每一个操作均具有一个可选的内存顺序参数,它可以用来指定所需的内存顺序语义。内存顺序选项的确切语义参见 3 节。就目前而言,了解运算分为三种类型就足够了。

    ■ 存储(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 顺序。

所有操作的默认顺序为 memory_order_seq_cst 。

现在让我们来看看能够在标准原子类型上进行的实际操作,从 std::atomic_flag 开始。

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);
  • bool x = f.test_and_set();

此外,对 clear() 的调用明确要求使用释放语义清除该标志,而对 test_and_set() 的调用使用默认内存顺序来设置标志和获取旧的值。

你不能从一个 std::atomic_flag 对象拷贝构造另一个对象,也不能将一个 std::atomic_flag 赋值给另一个。这并不是 std::atomic_flag 特有的,而是所有原子类型共有的。在原子类型上的操作全都定义为原子的,以及包括两个对象的赋值和拷贝构造。在两个不同对象上的单一操作不可能时原子的。在拷贝构造或拷贝赋值的情况下,其值必须先从一个对象读取,再写入另外一个。这是在两个独立对象上的独立操作,其组合不可能是原子的。因此,这些操作是禁止的。

有限的特性集使得 std::atomic_flag 理想地适合于用作自选互斥锁。最开始,该标志置为清除,互斥是解锁的。为了锁定互斥锁,循环执行 test_and_set() 直至旧值为 false,指示这个线程将值设为 true。解锁此互斥元就是简单地清除标志。这个实现展示在清单 1 中。

清单1 使用 std::atomic_flag 的自旋锁互斥实现
  • class spinlock_mutex
  • {
  •     std::atomic_flag flag = ATOMIC_FLAG_INIT;
  • public:
  •     spinlock_mutex()
  •     {
  •     }
  •  
  •     void lock()
  •     {
  •          while (flag.test_and_set(std::memory_order_acquire) );
  •     }
  •  
  •     void unlock()
  •     {
  •          flag.clear();
  •     }
  • };

这样一个互斥元是非常基本的,但它足以与 std::lock_guard<> 一同使用。就其本质而言,它在 lock() 中执行了一个忙等待,因此如果你希望有任何程序的竞争,这就是个糟糕的选择,但它足以确保互斥。当我们观察内存顺序语义时,将看到这是如何保证与互斥元锁相关的必要的强制顺序。这个例子将在 3.6 节中阐述。

std::atomic_flag 由于限制性甚至不能用作一个通用的布尔标识,因为它不具有简单的无修改查询操作。为此,你最好还是使用 std::atomic<bool> ,接下来我将介绍之。

hanhan笔记:test_and_set()函数:Atomically changes the state of a std::atomic_flag to set (true) and returns the value it held before.

2.3 基于 std::atomic<bool> 的操作

最基本的原子整数类型是 std::atomic<bool>。如你所预期那样,这是一个比 std::atomic_flag 功能更全的布尔标志。虽然它仍然是不可拷贝和拷贝赋值的,但可以从一个非原子的 bool 来构造它,所以它可以被初始化为 true 或 false,同时也可以从一个非原子的 bool 值来对 std::atomic<bool> 的实例赋值。

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

从非原子的 bool 进行赋值操作还要注意的一件事是它与通常的惯例不同,并非向其赋值的对象返回一个引用,它返回的是具有所赋值的 bool。这对于原子类型是另一种常见的模式,它们所支持的赋值操作符返回值(属于相应的非原子类型)而不是引用。如果返回的是原子变量的引用,所有依赖于赋值结果的代码将显示地载入该值,可能会获取被另一线程修改地结果。通过以非原子值的形式返回赋值结果,可以避免这种额外的载入,你会知道所获取到的值是已存储的实际值。

与使用 std::atomic_flag 的受限的 clear() 函数不同,写操作(无论是 true 还是 false)是通过调用 store() 来完成的。类似地,test_and_set() 被更通用地 exchange() 成员函数所替代,它可以让你用所选择的新值来代替已存储的值,同时获取原值。std::atomic<bool> 支持对值的普通无修改查询,通过隐式转化成普通的 bool,或显式调用 load()。正如你所期望的,store() 是一个存储操作,而 load() 是载入操作,exchange() 是读-修改-写操作。

  • 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> 支持的唯一的读-修改-写操作,它还引入了一个操作,用于在当前值与预期值相等时,存储新的值。

根据当前值存储一个新值(或者否)

这个新的操作被称为比较/交换,它以 compare_exchange_weak() 和 compare_exchange_strong() 成员函数形式出现。比较/交换操作是使用原子类型编程的基石,它比较原子变量值和所提供的期望值,如果两者相等,则存储提供的期望值。如果两者不相等,则期望值更新为原子变量的实际值。比较/交换函数的返回类型为 bool,如果执行了存储即为 true,反之则为 false。

对于 compare_exchange_weak() ,即便原始值等于期望值也可能出现存储不成功,在这种情况下变量的值是不变的,compare_exchange_weak() 的返回值为 false。这最有可能发生在缺少单个的比较交换指令的机器上,此时处理器无法保证该操作被完成——可能因为执行操作的线程在必需的指令序列中间被切换出来,同时在线程多余处理器数量的操作系统中,它被另一个计划中的线程代替。这就是所谓的伪失败(spurious failure),因为失败的原因是时间的函数而不是变量的值。

由于 compare_exchange_weak() 可能会伪失败,它通常必须用在循环中。

  • bool expected = false;
  • extern std::atomic<bool> b; //在别处设置
  • while (!b.compare_exchange_weak(expected, true) && !expected);

在这种情况下,只要 expected 仍为 false,表明 compare_exchange_weak() 调用伪失败,就保持循环。

另一方面,仅当实际值不等于 expected 值时 compare_exchange_strong() 才保证返回 false。这样可以消除对循环的需求,正如你希望知道它所显示的那样,是否成功改变了一个变量,或者是否有一个线程抢先抵达。

如果你想要改变变量,无论其初始值是多少(也许是用一个依赖于当前值的更新值),expected 的更新就变得很有用,每次经过循环时,expected 被重新载入,因此如果没有其他线程在此期间修改其值,那么 compare_exchange_weak() 或 compare_exchange_strong() 的调用在下一次循环中应该是成功的。如果计算待存储的值很简单,为了避免在 compare_exchange_weak() 可能会伪失败的平台上双重循环,(于是 compare_exchange_strong() 包含一个循环),则使用 compare_exchange_weak() 可能是有好处的。另一方面,如果计算待存储的值本身是耗时的,当 expected 值没有变化时,使用 compare_exchange_strong() 来避免被迫重新计算待存储的值可能是有意义的。对于 std::atomic<bool> 而言这并不重要——毕竟只有两个可能的值——但对于较大的原子类型这会有所不同。

比较/交换函数还有一点非同寻常,他们可以接受两个内存顺序参数。这就允许内存顺序的语义在成功和失败的情况下有所区别。对于一次成功调用具有 memory_order_acq_rel 语义而一次失败的调用有着 memory_order_relaxed 语义,这想必是极好的。一次失败的比较/交换并不进行存储,因此它无法具有 memory_order_acquire 或 memory_order_acq_rel 语义。因此在失败时,禁止提供这些值作为顺序。你也不应为失败提供比成功更严格的内存顺序。如果你希望 memory_order_acquire 或者 memory_order_seq_cst 作为失败的语义,你也必须为成功指定这些语义。

如果你没有为失败指定一个顺序,就会假定它与成功是相同的,除了顺序的 release 部分被除去:memory_order_release 变成 memory_order_acquire。如果你都没有指定,它们通常默认为 memory_order_seq_cst,这为成功和失败都提供了完整的序列顺序。以下对 compare_exchange_weak() 的两个调用是等价的。

  • std::atomic<bool> b;
  • bool expected;
  • b.compare_exchange_weak(expected, true,
  • std::memory_order_acq_rel, std::memory_order_acquire);
  • b.compare_exchange_weak(expected, true, std::memory_order_acq_rel);

我将把选择内存顺序的后果放在 3 节。

std::atomic<bool>std::atomic_flag 之间的另外一个区别是 std::atomic<bool> 可能不是无锁的。为了保证操作的原子性,实现可能需要内部地获得一个互斥锁。对于这种罕见地情况,当他重要时,你可以使用 is_lock_free() 成员函数检查是否对 std::atomic<bool> 的操作是无锁的。除了 std::atomic_flag ,对于所有原子类型,这是另一个特征共同。

原子类型中其次简单的是原子指针特化 std::atomic<T*>,那么接下来我们看这些。

2.4 std::atomic<T*> 上的操作:指针算术运算

对于某种类型 T 的指针的原子形式 std::atomic<T*> ,正如 bool 的原子形式是 std::atomic<bool> 一样。接口基本上是相同的,只不过它对相应的指针类型的值进行操作而非 bool 值。与 std::atomic<bool> 一样,它既不能拷贝构造,也不能拷贝赋值,虽然它可以从合适的指针值中构造和赋值。和必须的 is_lock_free() 成员函数一样,std::atomic<T*> 也有 load()、store()、exchange()、compare_exchange_weak() 和 compare_exchange_strong() 成员函数,具有和 std::atomic<bool> 相似的语义,也是接受和返回 T* 而不是 bool。

std::atomic<T*> 提供的新操作是指针算术运算。这种基本的操作是由 fetch_add() 和 fetch_sub() 成员函数提供的,可以在所存储的地址上进行原子加法和减法,+=、-=、带 ++ 和 -- 的前缀与后缀的自增自减等运算符,都提供了方便的封装。运算符的工作方式,与你从内置类型中所期待的一样。如果 x 是 std::atomic<Foo*> 指向 Foo 对象数组的第一项,那么 x+=3 将它改为指向第四项,并返回一个普通的指向第四项的 Foo*。fetch_add() 和 fetch_sub() 有细微的区别,它们返回的是原值(所以 x.fetch_add(3) 将 x 更新为指向第四个值,但是返回一个指向数组中的第一个值得指针)。该操作也称为交换与添加,它是一个原子的读-修改-写操作,就像 exchange() 和 compare_exchange_weak() / compare_exchange_strong()。与其他的操作一样,返回值是一个普通的 T* 值而不是对 std::atomic<T*> 对象的引用,因此,调用代码可以基于之前的值执行操作。

  • 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);
  • assert(x==&some_array[1]);
  • assert(p.load() == &some_array[1]);  // 将 p 减 1 并返回新的值

该函数形式也允许内存顺序语义作为一个额外的函数调用参数而被指定。

  • p.fetch_add(3, std::memory_order_release);

因为 fetch_add() 和 fetch_sub() 都是读-修改-写操作,所以它们可以具有任意的内存顺序标签,也可以参与到释放序列中。对于运算形式,指定顺序语义是不可能的,因为没有办法提供信息,因此这些形式总是具有 memory_order_seq_cst 语义。

其余的基本原子类型本质上都是一样的,它们都死原子整型,并且彼此都具有相同的接口,区别是相关联的内置类型是不同的。我们将它们视为一个群组。

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*> 的 fetch_add() 和 fetch_sub() 的语义密切地匹配,命名函数原子级执行它们的操作,并返回值,而复合赋值运算符返回值。前缀与后缀自增自减也跟平常一样工作,++x 增加变量值并返回新值,而 x++ 增加变量并返回旧值。正如你到目前所期待的那样,在这两种情况下,结果都是一个相关联的整数值。

现在,我们已经了解了所有的基本原子类型,剩下的就是泛型 std::atomic<> 初级模板而不是这些特化,那么让我们接着看看下面的内容。

2.6 std::atomic<> 初级类模块

除了标准的原子类型,初级模板的存在允许用户创建一个用户定义的原子变种。然而,你不能将用户定义类型用于 std::atomic<> ,该类型必须满足一定的准则。为了对用户定义类型 UDT 使用 std::atomic<UDT> ,这种类型必须有一个平凡的(trivial)拷贝赋值运算符。这意味着该类型不得拥有任何虚函数或虚基类,并且必须使用编译器生成的拷贝赋值运算符。这实质上允许编译器将 memcpy() 或一个等价的操作用于赋值操作,因为没有用户编写的代码要运行。

最后,该类型必须是按位相等可比较的。这伴随着赋值的要求,你不仅要能够使用 memcpy() 复制 UDT 类型的对象,而且还必须能够使用 memcmp() 比较实例是否相等。为了使比较/交换操作能够工作,这个保证是必需的。

这些限制的原因可以追溯到一个准则,不要在锁定范围之外,向受保护的数据通过将其作为参数传递给用户所提供的函数的方式,传递指针和引用。一般情况下,编译器无法为 std::atomic<UDT> 生成无锁代码,所以它必须对所有的操作使用一个内部锁。如果用户提供的拷贝赋值或比较运算是被允许的,这将需要传递一个受保护数据的引用作为一个用户提供的函数的参数,因而违背准则。同样地,类库完全自由地为所有需要单个锁定的原子操作使用单个锁定,并允许用户提供的函数被调用,虽然认为因为一个比较操作花了很长时间,该锁可能会导致死锁或造成其他线程阻塞。最后,这些限制增加了编译器能够直接为 std::atomic<UDT> 利用原子指令的机会(并因此使一个特地的实例无锁),因为它可以把用户定义的类型作为一组原始字节来处理。

需要注意的是,虽然你可以使用 std::atomic<float>std::atomic<double> ,因为内置的浮点类型确实满足与 memcpy 和 memcmp() 一同使用的准则,在 compare_exchange_strong() 情况下这种行为可能会令人惊讶。如果存储的值具有不同的表示,即使旧的存储值与被比较的值数值相等,该操作可能会失败。请注意,浮点值没有原子的算术操作。如果你使用一个具有定义了相等比较运算符的用户定义类型的 std::atomic<> ,你会得到和 compare_exchange_strong() 类似的情况,而且该操作符会与使用 memcmp 进行比较不同——该操作可能因另一相等值具有不同得表示而失败。

如果你的 UDT 和一个 int 或一个 void* 大小相同(或更小)。大部分常见得平台能够为 std::atomic<UDT> 使用原子指令。一些平台也能够使用大小是 int 或 void* 两倍得用户定义类型得原子指令。这些平台通常支持一个 compare_exchange_xxx 函数相对应的所谓的双字比较和交换(double-word-compare-and-swap,DWCAS)指令。正如你将在后续中看到的,这种支持可以帮助写无锁代码。

这些限制意味着,例如,你不能创建一个 std::atomic<std::vector<int>> ,但你可以把它与包含计数器、标识符、指针、甚至简单的数据元素的数组类一起使用。这不是特别的问题。越是复杂的数据结构,你就越有可能想在它之上进行简单的赋值和比较之外的操作。如果情况是这样,你最好还是使用 std::mutex ,来确保所需的操作得到适当的保护。

当使用一个用户定义的类型 T 进行实例化时, std::atomic<T> 的接口被限制为一组操作,对于 std::atomic<bool>:load()、store()、exchange()、compare_exchange_weak()、compare_exchange_strong() ,以及赋值自和转化到类型 T 的实例,是可用的。

表 3 展示了在每个原子类型上可用的操作。

表3 原子类型的可用操作
操作 atomic_flag atomic<bool> atomic<T*> atomic<integral-type> atomic<other-type>
test_and_set
clear
is_lock_free
load
store
compare_exchange_weak
compare_exchange_strong
fetch_add
+=
fetch_sub
-=
fetch_or
|=
fetch_and
&=
fetch_xor
^=
++
--

2.7 原子操作的自由函数

到现在为止,我一直限制自己去描述原子类型的操作的成员函数形式。然而,各种原子类型的所有操作也都有等到的非成员函数。对于大部分非成员函数来说,都是以相应的成员函数来命名的,只是带有一个 atomi_ 前缀(例如 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() 只有一个变种(尽管为每个类型重载),而 std::atomic_is_lock_free(&a) 对于原子类型 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<> 很肯定地不是原子类型。然而,C++标准委员会认为提供这些额外地函数是足够重要地。可用的原子操作有:载入(load)存储(store)交换(exchange)比较/交换(compare/exchange),这些操作以标准原子类型上的相同操作的形式被提供,接受 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::mutexstd::future<> 这样同步操作的工具的基础。考虑到这一点,让我们进入到本章真正的重点,内存模型的并发方面的细节,以及原子操作如何用来同步数据和强制顺序。

3. 同步操作和强制顺序

假设有两个线程,其中一个要填充由第二个线程所读取的数据结构。为了避免有问题的竞争条件,第一个线程设置一个标志来指示数据已经就绪,在标志设置之前第二个线程不读取数据。清单 2 展示了这样的场景。

清单2 从不同的线程中读取和写入变量
  • #include <vector>
  • #include <atomic>
  • #include <iostream>
  • #include <thread>
  •  
  • std::vector<int> data;
  • std::atomic<bool> data_ready(false);
  •  
  • void reader_thread()
  • {
  •     while (!data_ready.load())
  •     {
  •          std::this_thread::sleep_for(std::chrono::milliseconds(1) );
  •     }
  •     std::cout << "The answer = " << data[0] << "\n";
  • }
  •  
  • void writer_thread()
  • {
  •     data.push_back(42);
  •     data_ready = true;
  • }

撇开等待数据准备的循环的低效率,你确实需要这样工作,因为不这样的话在线程间共享数据就变得不可行,每一项数据都强制成为原子的。你已经知道在没有强制顺序的情况下,非原子的读和写同一个数据是未定义的行为,所以为了使其工作,某个地方必须有一个强制的顺序。

所要求的强制顺序来自于 std::atomic<bool> 变量 data_ready 上的操作,它们通过 happens-before(发生于之前)synchronizes-with(与之同步) 内存模型关系的优点来提供必要的顺序。数据写入发生于写入 data_ready 标志之前,标志的读取发生于数据的读取之前。但从 data_ready 读取的值为 true 时,写与读同步,创建一个 happens-before 的关系。因为 happens-before 是可传递的,数据的写入发生于标志的写入之前,发生于从标志读取 true 值之前,又发生于数据的读取之前,你就有了一个强制的顺序:数据的写入发生于数据的读取之前,一切都好了。图 2 表明了两个线程间 happens-before 关系的重要性。我从读线程添加了 while 循环的几次等待。

图2 用原子操作在非原子操作之间强制顺序

所有这一切似乎相当直观,写入一个值当然发生在读取这个值之前!使用默认的原子操作,这的确是真的(这就是为什么它是默认的),但它需要阐明:原子操作对于顺序要求也是有其他的选项,这一点我马上会提到。

现在,你已经在实战中看到了 happens-befor 和 synchronizes-with,现在是时候来看它们到底是什么意思了。我将从 synchronizes-with 开始。

3.1 synchronizes-with 关系

synchronizes-with 关系是你只能在原子类型上的操作之间得到的东西。如果一个数据结构包含原子类型,并且在该数据结构上的操作会在内部执行适当的原子操作,该数据结构上的操作(如锁定互斥元)可能会提供这种关系,但是从根本上说 synchronizes-with 关系只出自原子类型上的操作。

基本的思想是这样的:在 x 变量上的一个适当标记的原子写操作 W,与这个 x 变量上的一个适当标记的原子读操作同步(synchronizes-with),该读操作读取写操作(W)存储的值;或是由与执行最初的写操作 W 相同的线程在 x 上的后续原子写操作,或是由任意线程在 x 上一系列的原子的读-修改-写操作(如 fetch_add() 或 compare_exchange_weak() )来读取所存储的值的原子读操作同步,其中随后通过第一个线程读取的值是通过 W 写入的值(参见 3.4 节)。

现在将“适当标记”部分放在一边,因为原子类型的所有的操作是默认适当标记的。这基本上和你想的一样:如果线程 A 存储一个值而线程 B 读取该值,那么线程 A 中的存储和线程 B 中的存储存在 synchronizes-with 关系,如清单 2 中一样。

我敢肯定你已经猜到,微妙之处尽在“适当标记”的部分。C++ 内存模型允许各种顺序约束应用于原子类型上的操作,这就是我所指的标记。内存顺序的各种选项以及它们如何涉及 synchronizes-with 关系包含在 3.3 节中。让我们退一步来看看 happens-before 关系。

3.2 happens-before 关系

happens-before 关系是程序中操作顺序的基本构件,它指定了哪些操作看到其他操作的结果。对于单个线程,它是直观的,如果一个操作排在另一个操作之前,那么该操作就发生于另一个操作之前。这就意味着,在源代码中,如果一个操作(A)发生于另一个操作(B)之前的语句中,那么 A 就发生于 B 之前。你可以在清单 2 中看到:data 的写入操作发生于 data_ready 的读操作之前。如果操作发生在同一条语句中,一般它们之间没有 happens-before 关系,因为它们是无序的。这只是顺序未指定的另一种说法。你知道清单 3 中的代码程序将输出 “1,2” 或 “2,1”,但是并未指定究竟是哪个,因为对 get_num() 的两次调用的顺序未指定。

清单3 一个函数调用的参数的估计顺序是未指定的
  • #include <iostream>
  •  
  • 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() ); // 对 get_num() 的调用是无序的
  • }

有时候,单条语句中的操作是有顺序的,例如使用内置的逗号操作符或者使用一个表达式的结果作为另一个表达式的参数。但是,一般来说,单条语句中的操作是非顺序的,而且没有 sequenced-before (因此也没有 happens-before)。当然,一条语句中的所有操作在下一句的所有操作之前发生。

这确实只是你习惯的单线程顺序规则的一个重述,那么新的呢?新的部分是线程之间的交互,如果线程间的一个线程上的操作 A 发生于另一个线程上的操作 B 之前,那么 A 发生于 B 之前。这并没有真的起到多大帮助,你只是增加了一种新的关系(线程间 happens-before),但这在你编写多线程代码时是一个重要的关系。

在基础水平上,线程间 happens-before 相对简单,并依赖于 3.1 节中介绍的 synchronizes-with 关系,如果一个线程中的操作 A 与 另一个线程中的操作 B 同步,那么 A 线程间发生于 B 之前。这也是一个可传递的关系,如果 A 线程间发生于 B 之前,B 线程间发生于 C 之前,那么 A 线程间发生于 C 之前。你也可以在清单 2 中看到。

线程间 happens-before 还可以与 sequenced-before 关系结合,如果操作 A 的顺序在操作 B 之前,操作 B 线程间发生于 C 之前,那么 A 线程间发生于 C 之前。类似地,如果 A 与 B 同步,B 的顺序在操作 C 之前,那么 A 线程间发生于 C 之前。这两个在一起意味着,如果你对单个线程中的数据做了一系列的改变,对于执行 C 的线程上的后续线程可见的数据,你只需要一个 synchronizes-with 关系。

这些是在线程间强制操作顺序的关键规则,使得清单 2 中的所有东西得以运行。数据依赖性有一些额外的细微差别,你很快就会看到。为了让你理解这一点,我需要介绍用于原子操作的内存顺序标签,以及它们如何与 synchronizes-with 关系相关联。

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)顺序(memory_order_seq_cst)、获得-释放(acquire-release)顺序(memory_order_consume、memory_order_acquire、memory_order_release 和 memory_order_acq_rel),以及松散(relaxed)顺序(memory_order_relaxed)。

这些不同的内存顺序模型在不同的 CPU 架构上可能有着不同的成本。例如,在基于具有通过处理器而非做更改者对操作的可见性进行良好控制架构上的系统中,顺序一致的顺序相对于获得-释放顺序或松散顺序,以及获得-释放顺序相对于松散顺序,可能会要求额外的同步指令。如果这些系统拥有很多处理器,这些额外的同步指令可能占据显著的时间量,从而降低该系统的整体性能。另一方面,为了确保原子性,对于超出需要的获得-释放排序,使用 x86 或 x86-64 架构(如在台式 PC 中常见的 Intel 和 AMD 处理器)的 CPU 不会要求额外的指令,甚至对于载入操作,顺序一致顺序不需要任何特殊的处理,尽管在存储时会有一点额外的成本。

不同的内存顺序模型的可用性,允许高手们利用更细粒度的顺序关系来提升性能,在不关键的情况下,当允许使用默认的顺序一致顺序时,他们是有优势的。

为了选择使用哪个顺序模型,或是为了理解使用不同的模型的代码中的顺序关系,了解该选择会如何影响程序行为是很重要的。因此让我们来看一看每种选择对于操作顺序和 synchronizes-with 的后果。

1. 顺序一致顺序

默认的顺序被命名为顺序一致(sequentially consistent),因为这意味着程序的行为与一个简单的顺序的世界观是一致的。如果所有原子类型实例上的操作是顺序一致的,多线程程序的行为,就好像是所有这些操作由单个线程以某种特定的顺序进行执行一样。这是迄今为止最容易理解的内存顺序,这也是它作为默认值的原因。所有线程必须看到操作的相同顺序。这使得推断用原子变量编写的代码的行为变得容易。你可以写下不同线程的所有可能的操作顺序,消除那些不一致的,并验证你的代码在其他程序里是否和预期的行为一样。这也意味着,操作不能被重排。如果你的代码在一个线程中有一个操作在另一个之前,其顺序必须对所有其他的线程可见。

从同步的观点来看,顺序一致的存储与读取该存储值的同一变量的顺序一致载入是同步的。这提供了一种两个(或多个)线程操作的顺序约束,但顺序一致比它更加强大。在使用顺序一致原子操作的系统中,所有载入后完成的顺序一致原子操作,也必须出现在其他线程的存储之后。清单 4 中的实例实际演示了这个顺序约束。该约束并不会推进使用具有松散内存顺序的原子操作,它们仍然可以看到操作处于不同的顺序,所以你必须在所有的线程上使用顺序一致的操作以获利。

然而,易于理解就产生代价。在一个带有许多处理器的弱顺序机器上,它可能导致显著的性能惩罚,因为操作的整体顺序必须与处理器之间保持一致,可能需要处理器之间进行密集(且昂贵)的同步操作。这就是说,有些处理器架构(如常见的 x86 和 x86-64 架构)提供相对低廉的顺序一致性,因此如果你担心使用顺序一致性对性能的影响,检查你的目标处理器架构的文档。

清单 4 实际展示了顺序一致性。x 和 y 的载入和存储是用 memory_order_seq_cst 显式标记的,尽管此标记在这种情况下可以省略,因为它是默认的。

清单4 顺序一致隐含着总体顺序
  1. #include <atomic>
  2. #include <thread>
  3. #include <assert.h>
  4.  
  5. std::atomic<bool> x, y;
  6. std::atomic<int> z;
  7.  
  8. void write_x()
  9. {
  10.     x.store(true, std::memory_order_seq_cst);
  11. }
  12.  
  13. void write_y()
  14. {
  15.     y.store(true, std::memory_order_seq_cst);
  16. }
  17.  
  18. void read_x_then_y()
  19. {
  20.     while (!x.load(std::memory_order_seq_cst));
  21.     if (y.load(std::memory_order_seq_cst))
  22.          ++z;
  23. }
  24.  
  25. void read_y_then_x()
  26. {
  27.     while (!y.load(std::memory_order_seq_cst));
  28.     if (x.load(std::memory_order_seq_cst))
  29.          ++z;
  30. }
  31.  
  32. int main()
  33. {
  34.     x = false;
  35.     y = false;
  36.     z = 0;
  37.  
  38.     std::thread a(write_x);
  39.     std::thread b(write_y);
  40.     std::thread c(read_x_then_y);
  41.     std::thread d(read_y_then_x);
  42.     a.join();
  43.     b.join();
  44.     c.join();
  45.     d.join();
  46.     assert(z.load() != 0);
  47. }

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 的操作上有着单个总体顺序,返回 false 的载入 y 和 对 x 的存储之间有一个隐含的顺序关系。为了有单一的总体顺序,如果一个线程看到 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 的情况,其操作 happens-before 关系如图 3 所示。从 read_x_then_y 中 y 的载入到 write_y 中 y 的存储的虚线,展示了所需要的隐含的顺序关系以保持顺序一致。在 memory_order_seq_cst 操作的全局顺序中,载入必须发生在存储之前,以达到这里所给的结果。

图3 顺序一致和 happens-before

顺序一致是最直观和直观的排序,但也是最昂贵的内存顺序,因为它要求所有线程之间的全局同步。在多处理器系统中,这可能需要处理器之间密集和耗时的通信。

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

2. 非顺序一致的内存顺序

一旦你走出美好的顺序一致的世界,事情开始变得复杂起来。可能要面对的一个最大问题是事件不再有单一的全局顺序的事实。这意味着,不同的线程可能看到相同的操作的不同方面,以及你所拥有的不同线程操作一前一后整齐交错的所有心理模型都必须扔到一边。你不仅得考虑事情真正的并行发生,而且线程不必和事情的顺序一致。为了编写(或者甚至只是理解)任何使用非默认的 memory_order_seq_cst 内存顺序的代码,让你的大脑思考这个问题绝对是至关重要的。这不仅意味着编译器能够重新排列指令。即使线程正在运行完全相同的代码,由于其他线程中的操作没有明确的顺序约束,它们可能与事件的顺序不一致,因为不同的 CPU 缓存和内部缓冲区可能为相同的内存保存了不同的值。它是如此重要以至于我要再说一遍:线程不必和事件的顺序一致

你不仅要将基于交错操作的心理模型扔到以便,还得基于编译器或处理器重排指令的思想的心理模型也扔掉。在没有其他顺序约束时,唯一的要求是所有的线程对每个独立变量的修改顺序达成一致。不同变量上的操作可以以不同的顺序出现在不同的线程中,前提是所能看到的值与附加的顺序约束是一致的。

通过完全跳出顺序一致的世界,并未所有操作使用 memory_order_relaxed,就是最好的展示。一旦你掌握了,就可以回过头来看获取-释放顺序,它让你选择性地在操作之间引入顺序关系,夺回一些理性。

3. 松散顺序

以松散顺序执行的原子类型上的操作不参与 synchronizes-with 关系。单线程中的同一变量的操作仍然服从 happens-before 关系,但相对于其他线程的顺序几乎没有任何要求。唯一的要求是,从同一个线程对单个原子变量的访问不能被重排,一旦给定的线程已经看到了原子变量的特定值,该线程之后的读取就不能获取该变量更早的值。在没有任何额外的同步的情况下,每个变量的修改顺序是使用 memory_order_relaxed 的线程之间唯一共享的东西。

为了展示松散顺序操作到底能多松散,你只需要两个线程,如清单 5 所示。

清单5 放松操作有极少数的排序要求
  1. #include <atomic>
  2. #include <thread>
  3. #include <assert.h>
  4.  
  5. std::atomic<bool> x, y;
  6. std::atomic<int> z;
  7.  
  8. void write_x_then_y()
  9. {
  10.     x.store(true, std::memory_order_relaxed);
  11.     y.store(true, std::memory_order_relaxed);
  12. }
  13.  
  14. void read_y_then_x()
  15. {
  16.     while (!y.load(std::memory_order_relaxed) );
  17.     if (x.load(std::memory_order_relaxed))
  18.          ++z;
  19. }
  20.  
  21. int main()
  22. {
  23.     x = false;
  24.     y = false;
  25.     z = 0;
  26.     std::thread a(write_x_then_y);
  27.     std::thread b(read_y_then_x);
  28.     a.join();
  29.     b.join();
  30.     assert(z.load() != 0);
  31. }

这次 assert 可以触发,因为 x 的载入能够读到 false,即便是 y 的载入读到了 true 并且 x 的存储发生于 y 存储之前。x 和 y 是不同的变量,所以关于每个操作所产生的值的可见性没有顺序保证。

不同变量的松散操作可以被自由地重排,前提是它们服从所有约束下的 happens-before 关系(例如,在同一线程中)。它们并不引入 synchronizes-with 关系。清单 5 中的 happens-before 关系如图 4 所示,伴随一个可能的结果。即便在存储操作之间和载入操作之间存在 happens-before 关系,但任一存储和任一载入之间却不存在,所以载入可以在顺序之外看到存储。

图4 松散的原子和 happens-before

hanhan笔记:这个结果第一次看有点吃惊,应该是编译器的问题,后面有讲到屏障,不知道是不是关联。

让我们在清单 6 中看一看带有三个变量和五个线程的稍微复杂的例子。

清单6 多线程的松散操作
  1. #include <thread>
  2. #include <atomic>
  3. #include <iostream>
  4.  
  5. std::atomic<int> x(0), y(0), z(0);
  6. std::atomic<bool> go(false);
  7.  
  8. unsigned const loop_count = 10;
  9.  
  10. struct read_values
  11. {
  12.     int x, y, z;
  13. };
  14.  
  15. read_values values1[loop_count];
  16. read_values values2[loop_count];
  17. read_values values3[loop_count];
  18. read_values values4[loop_count];
  19. read_values values5[loop_count];
  20.  
  21. void increment(std::atomic<int>* var_to_inc, read_values* values)
  22. {
  23.     while (!go)
  24.          std::this_thread::yield(); // 旋转,等待型号
  25.  
  26.     for (unsigned i = 0; i < loop_count; ++i)
  27.     {
  28.          values[i].x = x.load(std::memory_order_relaxed);
  29.          values[i].y = y.load(std::memory_order_relaxed);
  30.          values[i].z = z.load(std::memory_order_relaxed);
  31.          var_to_inc->store(i + 1, std::memory_order_release);
  32.          std::this_thread::yield();
  33.     }
  34. }
  35.  
  36. void read_vals(read_values* values)
  37. {
  38.     for (unsigned i = 0; i < loop_count; ++i)
  39.     {
  40.          values[i].x = x.load(std::memory_order_relaxed);
  41.          values[i].y = y.load(std::memory_order_relaxed);
  42.          values[i].z = z.load(std::memory_order_relaxed);
  43.          std::this_thread::yield();
  44.     }
  45. }
  46.  
  47. void print(read_values* v)
  48. {
  49.     for (unsigned i = 0; i < loop_count; ++i)
  50.     {
  51.          if (i)
  52.              std::cout << ",";
  53.          std::cout << "(" << v[i].x << "," << v[i].y << "," << v[i].z << ")";
  54.     }
  55.     std::cout << std::endl;
  56. }
  57.  
  58. int main()
  59. {
  60.     std::thread t1(increment, &x, values1);
  61.     std::thread t2(increment, &y, values2);
  62.     std::thread t3(increment, &z, values3);
  63.     std::thread t4(read_vals, values4);
  64.     std::thread t5(read_vals, values5);
  65.  
  66.     go = true;
  67.  
  68.     t5.join();
  69.     t4.join();
  70.     t3.join();
  71.     t2.join();
  72.     t1.join();
  73.  
  74.     print(values1);
  75.     print(values2);
  76.     print(values3);
  77.     print(values4);
  78.     print(values5);
  79. }

本质上这是一个非常简单的程序。你有三个共享的全局原子变量和五个线程。每个线程循环 10 次,用 memory_order_relaxed 读取三个原子变量的值,并将其存储在数组中。这三个线程中的每个线程每次通过循环更新其中一个原子变量,而其他两个线程只是读取。一旦所有的线程都被连接,你就打印由每个线程存储在数组中的值。

原子变量 go 用来确保所有的线程尽可能靠近相同的时间开始循环。启动一个线程是昂贵的操作,若没有明确的延迟,第一个线程可能会在最后一个线程开始前就结束了。每个线程在进入主循环之前等待 go 变成 true、仅当所有的线程都已经开始后,go 才被设置为 true。

这个程序的一个可能的输出如下所示。

(0,0,0),(1,0,0),(2,0,0),(3,0,0),(4,0,0),(5,0,0),(6,0,0),(7,0,0),(8,0,0),(9,0,0)

(10,0,0),(10,1,0),(10,2,0),(10,3,0),(10,4,0),(10,5,0),(10,6,0),(10,7,0),(10,8,0),(10,9,0)

(10,10,0),(10,10,1),(10,10,2),(10,10,3),(10,10,4),(10,10,5),(10,10,6),(10,10,7),(10,10,8),(10,10,9)

(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10)

(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10)

hanhan笔记:上面这个输出是自己实验的输出,感觉就像顺序输出,应该是调度的问题,增大 loop count 的值效果会更明显一点。

前三行是线程在进行更新,最后两行是线程仅进行读取。每个三元组是一组变量 x、y 和 z 以这样的顺序经历循环。这个输出中有几点要注意。

    ■ 第一系列值显式了 x 在每个三元组里依次增加 1,第二个系列 y 依次增加 1,以及第三系列依次增加 1。

    ■ 每个三元组的 x 元素仅在给定系列中自增,y 和 z 元素也一样,但增量不是平均的,而且在所有线程之间的相对顺序也不同。

    ■ 线程 3 并没有看到任何 x 或 y 的更新,它只能看到它对 z 的更新。然而这并不能阻止其他线程看到对 z 的更新混合在对 x 和 y 的更新中。

这对于松散操作是一个有效的结果,但并非唯一有效的结果。任何由三个变量组成,每个变量轮流保存从 0 到 10 的值,同时使得线程递增给定的变量,并打印出该变量从 0 到 9 的值的一系列值,都是有效的。

4. 理解松散顺序

为了理解这是如何工作的,想象每个变量是一个在小隔间里使用记事本的人。在他的记事本上有一系列值。你可以打电话给他,要求他给你一个值,或者你可以告诉他写下一个新值。如果你告诉他写下一个新值,你就将其写在列表底部。如果你向他要一个值,他就为你从列表中读取一个数字。

第一次你跟这个人交谈,如果你向他要一个值,此时他可能从他记事本上的列表里任意给你一个值。如果你接着向他要另一值,他可能会再次给你同一个值,或是从列表下方给你一个。他永远不会给你一个在列表中更上面的值。如果你告诉他写下一个数字,然后再要一个值,他要么给你刚才你让他写下的数字,或者是列表上在那以下的数字。

假设某一次他的列表以这些值开始 5、10、23、3、1、2。如果你要一个值,你会得到其中的任意一个。如果他给你 10,下一次他可能再给你 10,或者后面的其他值,但不会是 5。如果你呼叫他 5 次,举个例子,他可能会说 “10、10、1、2、2”。如果你告诉他写下 42,他会将其添加在列表的末尾。如果你再向他要数字,他将一直告诉你 “42”,直到他的列表上有另一个数,并且他愿意告诉你时。

现在,假设你的朋友 Carl 也有这个人的号码。Carl 也可以打电话给他,要么请他写下一个数字或是索取一个数字,他跟对待你一样,对 Carl 应用相同的规则。他只有一部电话,因此他一次只能处理你们中的一个人,所以他记事本上的列表是一个非常直观的列表。但是,仅仅因为你让他写下了新的号码,并不意味着他必须将其告诉 Carl,反之亦然。如果 Carl 向他索取一个数字,并被告知 “23”,然后仅仅因为你要求这个人写下 42 并不意味着他下次就会告诉 Cael。他可能会将 23、3、1、2、42 这些数字中的任何一个告诉 Carl,或者甚至是在你呼叫他之后,Fred 告诉他写下来的 67。他很可能会告诉 Carl “23、3、3、1、67”,这与他告诉你的也没什么矛盾。就像是他为每个人设了一个小小的移动便签,把他对谁说了什么数都进行了记录,如图 5 所示。

现在假设不仅仅是一个人在一个小隔间,而是整个隔间群,有一大帮带着电话和笔记本的人。这些都是我们的原子变量。每一个变量都有自己的修改顺序(笔记本上的列表的值),但是它们之间完全没有关系。如果任意一个呼叫者(你、Carl、Anne、Dave 和 Fred)是一个线程,那么就是每个线程都是用 memory_order_relaxed 时你所得到的东西。还有一些你可以告诉隔间里的人的额外事情,比如“记下这个号码,并告诉我列表的底部是什么”(exchange)和“写下这个数字,如果列表底部的数字正是它,否则告诉我应该猜到什么”(compare_exchange_strong),但是者并不影响一般的原则。

如果你仔细地想一想清单 5 中的程序逻辑,write_x_then_y 就像是某个家伙打电话告诉小隔间 x 中的人让他写下 true,然后打电话给隔间 y 中的人让他写下 true。运行 read_y_then_x 的线程不断地呼叫小隔间 y 中的人询问一个值,一直到他说 true,然后再向小隔间 x 中的人询问值。这个在小隔间 x 中的人没有义务告诉你他列表上的任何一个具体的值,并且还有权力说 false。

这就使得松散的原子操作难以处理。他们必须与具有更强的顺序语义的原子操作结合起来使用,以便在线程间同步中发挥作用。我强烈建议避免松散的原子操作,除非绝对必要,即便这样,也应该极其谨慎地使用之。在清单 5 中,仅仅是两个线程和两个变量就能让所得到的结果这样不直观,鉴于此,不难想象在涉及更多线程和变量的时候,会变得多么复杂。

一种避免全面顺序一致性开销的达到额外同步的方法,是使用获取-释放顺序

5. 获取-释放顺序

获取-释放顺序是松散顺序的进步,操作仍然没有总的顺序,但的确引入了一些同步。在这种顺序模型下,原子载入是获取(aquire)操作(memory_order_acquire)。原子存储是释放(release)操作(memory_order_release),原子的读-修改-写操作(例如 fetch_add() 或 exchange())是获取、释放或两者兼备(memory_order_acq_rel)。同步在进行释放的线程和进行获取的线程之间是对偶的。释放操作与读取写入值的获取操作同步。这意味着不同的线程仍然可以看到不同的排序,但这些顺序是受到限制的。清单 7 采用获取-释放语义而不是顺序一致顺序,对清单 4 进行了改写。

清单7 获取-释放并不意味着总体排序
  1. #include <atomic>
  2. #include <thread>
  3. #include <assert.h>
  4.  
  5. std::atomic<bool> x, y;
  6. std::atomic<int> z;
  7.  
  8. void write_x()
  9. {
  10.     x.store(true, std::memory_order_release);
  11. }
  12.  
  13. void write_y()
  14. {
  15.     y.store(true, std::memory_order_release);
  16. }
  17.  
  18. void read_x_then_y()
  19. {
  20.     while (!x.load(std::memory_order_acquire))
  21.          std::this_thread::yield();
  22.     if (y.load(std::memory_order_acquire))
  23.          ++z;
  24. }
  25.  
  26. void read_y_then_x()
  27. {
  28.     while (!y.load(std::memory_order_acquire))
  29.          std::this_thread::yield();
  30.     if (x.load(std::memory_order_acquire))
  31.          ++z;
  32. }
  33.  
  34. int main()
  35. {
  36.     x = false;
  37.     y = false;
  38.     z = 0;
  39.     std::thread a(write_x);
  40.     std::thread b(write_y);
  41.     std::thread c(read_x_then_y);
  42.     std::thread d(read_y_then_x);
  43.     a.join();
  44.     b.join();
  45.     c.join();
  46.     d.join();
  47.     assert(z.load() != 0);
  48. }

在这个例子里,断言可以触发(就像在松散顺序中的例子),因为对 x 的载入和对 y 的载入都读取 false 是可能的。x 和 y 由不同的线程写入,所以每种情况下从释放到获取的顺序对于另一个线程中的操作是没有影响的。

图 6 展示了清单 7 中的 happens-before 关系,伴随着一种可能的结果,即两个读线程看到了世界的不同方面。这可能是因为没有像前面提到的 happens-before 关系来强制顺序。

图6 获取-释放和 happens-before

为看到获取-释放顺序的优点,你需要考虑类似清单 5 中来自同一个线程中的两个存储。如果你将对 y 的存储更改为使用 memory_order_release,并且让对 y 的载入使用类似于清单 5.8 中的 memory_order_acquire,那么你实际上就对 x 上的操作施加了一个顺序。

hanhan笔记:学到这里我已经学不下去了,实在是不理解这些情况为什么会发生。尽管是多线程,但同一个线程内的执行顺序至少要是一致的吧。就算编译器有优化,改变了代码看起来的执行顺序,那优化的原理和规则是什么,怎么根据不同的内存模型采用不同的编译手段。这着实令人费解,暂且先学到这边。