5.2 C++中的原子操作和原子类型

原子操作是个不可分割的操作。系统的所有线程中,不可能观察到原子操作完成了一半;要么就是做了,要么就是没做,只有这两种可能。如果读取对象的加载操作是原子的,那么这个对象的所有修改操作也是原子的,所以加载操作得到的值要么是对象的初始值,要么是某次修改操作存入的值。

另一方面,非原子操作可能会被另一个线程观察到只完成一半。如果这个操作是一个存储操作,那么其他线程看到的值,可能既不是存储前的值,也不是存储的值,而是别的什么值。如果非原子操作是一个加载操作,它可能先取到对象的一部分,然后值被另一个线程修改,然后它再取到剩余的部分,所以它取到的既不是第一个值,也不是第二个值,而是两个值的某种组合。如第3章所述,这就有了竞争风险,但在也就构成了数据竞争(见5.1节),会出现未定义行为。

C++中多数时候,需要原子类型对应得到原子的操作,我们先来看下这些类型。

5.2.1 标准原子类型

标准原子类型定义在头文件<atomic>中。这些类型的所有操作都是原子的,语言定义中只有这些类型的操作是原子的,不过可以用互斥锁来模拟原子操作。实际上,标准原子类型的实现就可能是这样模拟出来的:它们(几乎)都有一个is_lock_free()成员函数,这个函数可以让用户查询某原子类型的操作是直接用的原子指令(x.is_lock_free()返回true),还是内部用了一个锁结构(x.is_lock_free()返回false)。

原子操作的关键就是使用一种同步操作方式,来替换使用互斥量的同步方式;如果操作内部使用互斥量实现,那么期望达到的性能提升就是不可能的事情。所以要对原子操作进行实现,最好使用用于获取且基于互斥量的实现来替代。这就是第7章所要讨论的无锁数据结构。

标准库提供了一组宏,在编译时对各种整型原子操作是否无锁进行判别。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<>类型模板得到,并且拥有更多的功能,但不可能都是无锁的(如之前解释的那样)。在主流平台上,原子变量都是无锁的内置类型(例如std::atomic<int>std::atomic<void*>)。后面将会看到,每个特化接口所反映出的类型特点;位操作(如:&=)就没有为普通指针所定义,所以它也就不能为原子指针所定义。

除了直接使用std::atomic<>类型模板外,你可以使用在表5.1中所示的原子类型集。由于历史原因,原子类型已经添加入C++标准中,这些备选类型名可能参考相应的std::atomic<>特化类型,或是特化类型。同一程序中混合使用备选名与std::atomic<>特化类名,会使代码的移植性大打折扣。

表5.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>
atomic_int std::atomic<int>
atomic_uint std::atomic<unsigned>
atomic_short std::atomic<short>
atomic_ushort std::atomic<unsigned short>
atomic_long std::atomic<long>
atomic_ulong std::atomic<unsigned long>
atomic_llong std::atomic<long long>
atomic_ullong std::atomic<unsigned long long>
atomic_char16_t std::atomic<char16_t>
atomic_char32_t std::atomic<char32_t>
atomic_wchar_t std::atomic<wchar_t>

C++标准库不仅提供基本原子类型,还定义了与原子类型对应的非原子类型,就如同标准库中的std::size_t。如表5.2所示这些类型:

表5.2 标准原子类型定义(typedefs)和对应的内置类型定义(typedefs)

原子类型定义 标准库中相关类型定义
atomic_int_least8_t int_least8_t
atomic_uint_least8_t uint_least8_t
atomic_int_least16_t int_least16_t
atomic_uint_least16_t uint_least16_t
atomic_int_least32_t int_least32_t
atomic_uint_least32_t uint_least32_t
atomic_int_least64_t int_least64_t
atomic_uint_least64_t uint_least64_t
atomic_int_fast8_t int_fast8_t
atomic_uint_fast8_t uint_fast8_t
atomic_int_fast16_t int_fast16_t
atomic_uint_fast16_t uint_fast16_t
atomic_int_fast32_t int_fast32_t
atomic_uint_fast32_t uint_fast32_t
atomic_int_fast64_t int_fast64_t
atomic_uint_fast64_t uint_fast64_t
atomic_intptr_t intptr_t
atomic_uintptr_t uintptr_t
atomic_size_t size_t
atomic_ptrdiff_t ptrdiff_t
atomic_intmax_t intmax_t
atomic_uintmax_t uintmax_t

好多种类型!不过,它们有一个相当简单的模式;对于标准类型进行typedef T,相关的原子类型就在原来的类型名前加上atomic_的前缀:atomic_T。除了singed类型的缩写是s,unsigned的缩写是u,和long long的缩写是llong之外,这种方式也同样适用于内置类型。对于std::atomic<T>模板,使用相应的T类型去特化模板的方式,要好于使用别名的方式。

通常,标准原子类型是不能进行拷贝和赋值的,它们没有拷贝构造函数和拷贝赋值操作符。但是,可以隐式转化成对应的内置类型,所以这些类型依旧支持赋值,可以使用load()和store()、exchange()、compare_exchange_weak()和compare_exchange_strong()。它们都支持复合赋值符:+=, -=, *=, |= 等等。并且使用整型和指针的特化类型还支持++和–操作。当然,这些操作也有功能相同的成员函数所对应:fetch_add(), fetch_or()等等。赋值操作和成员函数的返回值,要么是被存储的值(赋值操作),要么是操作前的值(命名函数),这就能避免赋值操作符返回引用。为了获取存储在引用中的值,代码需要执行单独的读操作,从而允许另一个线程在赋值和读取的同时修改这个值,这也就为条件竞争打开了大门。

std::atomic<>类模板不仅仅是一套可特化的类型,作为一个原发模板也可以使用用户定义类型创建对应的原子变量。因为,它是一个通用类模板,操作被限制为load(),store()(赋值和转换为用户类型),exchange(),compare_exchange_weak()和compare_exchange_strong()。

每种函数类型的操作都有一个内存排序参数,这个参数可以用来指定存储的顺序。5.3节中,会对存储顺序选项进行详述。现在,只需要知道操作分为三类:

  1. Store操作,可选如下顺序:memory_order_relaxed, memory_order_release, memory_order_seq_cst。
  2. Load操作,可选如下顺序:memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_seq_cst。
  3. Read-modify-write(读-改-写)操作,可选如下顺序:memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel, memory_order_seq_cst。

现在,让我们来看一下每个标准原子类型的操作,就从std::atomic_flag开始吧。

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非常适合于作自旋互斥锁。初始化标志是“清除”,并且互斥量处于解锁状态。为了锁上互斥量,循环运行test_and_set()直到旧值为false,就意味着这个线程已经被设置为true了。解锁互斥量是一件很简单的事情,将标志清除即可。实现如下面的程序清单所示:

清单5.1 使用std::atomic_flag实现自旋互斥锁

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));
  }
  void unlock()
  {
    flag.clear(std::memory_order_release);
  }
};

这样的互斥量是最基本的,但它已经足够std::lock_guard<>使用了(详见第3章)。其本质就是在lock()中等待,所以几乎不可能有竞争的存在,并且可以确保互斥。当看到内存序语义时,会了解到它们是如何对一个互斥锁保证必要的强制顺序的。这个例子将在5.3.6节中展示。

由于std::atomic_flag局限性太强,没有非修改查询操作,甚至不能像普通的布尔标志那样使用。所以,实际中最好使用std::atomic<bool>,接下来让我们看看应该如何使用它。

5.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值来代替指定对象。原子类型中的另一种模式:赋值操作通过返回值(返回相关的非原子类型)完成,而非返回引用。如果原子变量的引用被返回了,任何依赖与这个赋值结果的代码都需要显式加载这个值。潜在的问题是,结果可能会被其他线程修改。通过返回非原子值进行赋值的方式,可以避免多余的加载过程,并且得到就是实际存储的值。

虽然有内存序语义指定,但是使用store()去写入(true或false)还是好于std::atomic_flag中限制性很强的clear()。同样,test_and_set()函数也可以被更加通用的exchange()成员函数所替换,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);

std::atomic`提供多个“读-改-写”的操作,exchange()操作只是其中之一;它还介绍了一种新的存储方式:当当前值与预期值一致时,存储新值的操作。

存储一个新值(或旧值)取决于当前值

这种新型操作叫做“比较/交换”,它的形式表现为compare_exchange_weak()和compare_exchange_strong()成员函数。“比较/交换”操作是原子类型编程的基石,它比较原子变量的当前值和一个期望值,当两值相等时,存储所提供的值;当两值不等,期望值就会被更新为原子变量中的值。“比较/交换”函数值是一个bool变量,当返回true时执行存储操作,false则更新期望值。当存储完成(因为只相等),则操作时成功的,否则即为失败;操作成功是返回true,失败时返回false。

对于compare_exchange_weak()函数,当原始值与预期值一致时,存储也可能会不成功;在这个例子中变量的值不会发生改变,并且compare_exchange_weak()的返回是false。这可能发生在缺少单条CAS操作(“比较-交换”指令)的机器上,当处理器不能保证这个操作能够自动的完成——可能因为线程的操作将指令队列从中间关闭,并且另一个线程安排的指令将会被操作系统所替换(这里线程数多于处理器数量),被称为“伪失败”(spurious failure),因为造成这种情况的原因是时间,而不是变量值。

因为compare_exchange_weak()可以伪失败,所以通常使用一个循环:

bool expected=false;
extern atomic<bool> b; // 设置些什么
while(!b.compare_exchange_weak(expected,true) && !expected);

这个例子中,循环中expected的值始终是false,表示compare_exchange_weak()会莫名的失败。

另一方面,当实际值与期望值不符,compare_exchange_strong()就能保证值返回false。这就能消除对循环的需要,就可以知道是否成功的改变了一个变量,或已让另一个线程完成。

如果想要改变变量值,更新后的期望值将会变更有用;经历每次循环的时候,期望值都会重新加载,所以当没有其他线程同时修改期望时,循环中对compare_exchange_weak()或compare_exchange_strong()的调用都会在下一次(第二次)成功。如果值很容易存储,那么使用compare_exchange_weak()能更好的避免一个双重循环的执行,即使compare_exchange_weak()可能会“伪失败”(因此compare_exchange_strong()包含一个循环)。另一方面,如果值的存储本身是耗时的,那么当期望值不变时,使用compare_exchange_strong()可以避免对值的重复计算。对于std::atomic<bool>这些都不重要——毕竟只可能有两种值——但是对于其他的原子类型影响就比较大了。

“比较/交换”函数很少对拥有两个内存序的参数进行操作,这就允许内存序语义在成功和失败的例子中有所不同;可能是对memory_order_acq_rel成功调用,而对memory_order_relaxed语义的失败调用。失败的“比较/交换”将不会进行存储,所以“比较/交换”操作不能拥有memeory_order_release或memory_order_acq_rel语义。因此,不保证这些值能作为失败的顺序,也不能提供比成功顺序更加严格的失败内存序,当memory_order_acquire或memory_order_seq_cst作为失败序时,必须要如同“指定成功语序”那样去做。

如果没有指定失败语序,那就假设和成功的顺序一样,除了release部分的顺序:memory_order_release变成memory_order_relaxed,并且memoyr_order_acq_rel变成memory_order_acquire。如果都不指定,默认顺序将为memory_order_seq_cst,这个顺序提供了对成功和失败的全排序。下面对compare_exchange_weak()的两次调用是等价的:

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

在5.3节中会详解对于不同内存顺序选择的结果。

std::atomic<bool>std::atomic_flag的不同之处在于,std::atomic<bool>不是无锁的。为了保证操作的原子性,其实现中需要一个内置的互斥量。特殊情况时,可以使用is_lock_free()成员函数,检查std::atomic<bool>上的操作是否无锁。这是另一个,除了std::atomic_flag之外,所有原子类型都拥有的特征(is_lock_free)。

第二简单的原子类型就是特化原子指针——std::atomic<T*>,接下来就看看它是如何工作的吧。

5.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_exchage_strong()成员函数,与std::atomic<bool>的语义相同,获取与返回的类型都是T*,而不是bool。

std::atomic<T*>为指针运算提供新的操作。基本操作有fetch_add()和fetch_sub()提供,它们在存储地址上做原子加法和减法,为+=, -=, ++和–提供简易的封装。对于内置类型的操作,例如:如果x是std::atomic<Foo*>类型的数组的首地址,然后x+=3让其偏移到第四个元素的地址,并且返回一个普通的Foo*类型值,这个指针值是指向数组中第四个元素。fetch_add()和fetch_sub()的返回值略有不同(所以x.ftech_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);  // p减1,并返回原始值
assert(x==&some_array[1]);
assert(p.load()==&some_array[1]);

函数也允许内存序语义作为给定函数的参数:

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

因为fetch_add()和fetch_sub()都是“读-改-写”操作,可以拥有任意的内存序标签,以及加入到一个释放序列中。指定的语序不可能是操作符的形式,因为没办法提供必要的信息:这些形式都具有memory_order_seq_cst语义。

剩下的原子类型基本上都差不多:它们都是整型原子类型,并且都拥有同样的接口(除了相关的内置类型不一样)。下面我们就看看这一类类型。

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*>类型紧密相关的两个函数就是fetch_add()和fetch_sub();函数原子化操作,并且返回旧值,而符合赋值运算会返回新值。前缀加减和后缀加减与普通用法一样:++x对变量进行自加,并且返回新值;而x++对变量自加,返回旧值。这两个例子中,结果都是整型相关的一个值。

我们已经看过所有基本原子类型;剩下的就是std::atomic<>类型模板,而非其特化类型。那么接下来让我们来了解一下std::atomic<>类模板。

5.2.6 std::atomic<>类模板

模板的存在,除了标准原子类型之外,允许用户使用自定义类型创建一个原子变量。不是任何自定义类型都可以使用std::atomic<>的:需要满足一定的标准才行。为了使用std::atomic<UDT>(UDT是用户定义类型),这个类型必须有拷贝赋值运算符。这就意味着这个类型不能有任何虚函数或虚基类,以及必须使用编译器创建的拷贝赋值操作。不仅仅是这些,自定义类型中所有的基类和非静态数据成员也都需要支持拷贝赋值操作。这(基本上)就允许编译器使用memcpy()或赋值操作的等价操作,因为实现中没有用户代码。

最终,比较-交换操作操作就类似于memcmp使用位比较,而非为UDT类定义一个比较操作符。如果UDT类型具有对于不同语义的比较操作,或者是这个类型有不参与比较的填充位,那么即使两个对象的值是相等的,也可能导致比较-交换操作失败。

以上严格的限制都是依据第3章中的一个建议:不要将锁定区域内的数据以引用或指针的形式,作为参数传递给用户提供的函数。通常情况下,编译器不会为std::atomic<UDT>类型生成无锁代码,所以所有操作使用一个内部锁。如果用户提供的拷贝赋值或比较操作被允许,那么就需要传递保护数据的引用作为一个参数,这就有悖于指导意见了。当需要原子操作时,运行库也可自由的使用单锁,并且运行库允许用户提供函数持有锁,这样就有可能产生死锁(或因为做一个比较操作,而阻塞了其他的线程)。最终,因为这些限制可以让编译器将用户定义的类型当作为一组原始字节,所以编译器可以对std::atomic<UDT>直接使用原子指令(因此实例化一个特殊无锁结构)。

注意,虽然使用std::atomic<float>std::atomic<double>(内置浮点类型满足使用memcpy和memcmp的标准),但是在compare_exchange_strong函数中的表现可能会令人惊讶。当存储的值与当前值相等时,这个操作也可能失败,可能因为旧值是一个不同的表达。这就不是对浮点数的原子计算操作了。在使用compare_exchange_strong函数的过程中,可能会遇到相同的结果,如果你使用std::atomic<>特化一个用户自定义类型,且这个类型定义了比较操作,而这个比较操作与memcmp又有不同——操作可能会失败,因为两个相等的值拥有不同的表达方式。

如果UDT类型的大小如同(或小于)一个int或void*类型时,大多数平台将会对std::atomic<UDT>使用原子指令。有些平台可能会对用户自定义类型(两倍于int或void*的大小)特化的std::atmic<>使用原子指令。这些平台通常支持所谓的“双字节比较和交换”(double-word-compare-and-swapDWCAS)指令,这个指令与compare_exchange_xxx相关联。指令的支持,对于写无锁代码是有很大的帮助,具体的内容会在第7章讨论。

以上的限制也意味着有些事情不能做,比如:创建一个std::atomic<std::vector<int>>类型。不能使用包含有计数器,标志指针和简单数组的类型,作为特化类型。虽然这不会导致任何问题,但是越是复杂的数据结构,就有越多的操作,而非只有赋值和比较。如果这种情况发生了,最好使用std::mutex保证数据能被保护,就像第3章描述的一样。

当使用用户定义类型T进行实例化时,std::atomic<T>的可用接口就只有: load(), store(), exchange(), compare_exchange_weak(), compare_exchange_strong()和赋值操作,以及向类型T转换的操作。表5.3列举了每一个原子类型所能使用的操作。

表5.3 每一个原子类型所能使用的操作

5.2 C++中的原子操作和原子类型 - 图1

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语言兼容,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的操作是“反潮流”的,在那些操作中它们“标志”的名称为: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::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::mutexstd::future<>。所以,让我们继续了解本章存在的意义:内存模型在并发方面的细节,如何使用原子操作同步数据和强制排序。