“他们都试过,而且都失败了?”
“哦,不。”她摇了摇头,“他们试过,然后死了。”——保罗·厄崔迪与圣母盖乌斯·海伦·莫希阿姆,《沙丘》
这是用 C 进行多线程编程时较有挑战性的方面之一。不过我们会尽量放轻松。
基本上,我会讲原子变量比较直接的用法:它们是什么、如何工作,等等。我还会提到一些你可以走的、复杂得离谱的路径。
但我不会走那些路径。我不光几乎没资格写这些东西,而且我琢磨着:如果你已经知道自己需要它们,那你懂的已经比我多了。
但即使在基础部分,这里也有些古怪的东西。所以各位,系好安全带吧,因为堪萨斯就要拜拜了。
原子操作是可选特性。有一个宏 __STDC_NO_ATOMICS__,如果你没有原子操作支持,它就是 1。
在 C11 之前,这个宏可能并不存在,所以我们应该用 __STDC_VERSION__ 检测语言版本208。
#if __STDC_VERSION__ < 201112L || __STDC_NO_ATOMICS__ == 1
#define HAS_ATOMICS 0
#else
#define HAS_ATOMICS 1
#endif如果这些检测都通过,你就可以放心地包含 <stdatomic.h>,本章其余内容都基于这个头文件。但如果没有原子操作支持,这个头文件可能根本不存在。
在某些系统上,你可能需要在编译命令行的末尾加上 -latomic,才能使用该头文件中的任何函数。
下面是原子变量工作原理的一部分:
如果你有一个共享的原子变量,并从某个线程向它写入,那么在另一个线程看来,这次写入是要么全有、要么全无的。
也就是说,另一个线程会看到,比如说,一个 32 位值的完整写入,而不是只看到一半。一个线程无法打断另一个正处于原子多字节写入中途的线程。
这几乎就像在这个变量的读取和设置周围有一把小锁。(而且也可能真有!见下文无锁原子变量。)
说到这个,如果你用互斥量来锁定临界区,那么完全可以一直不用原子操作。只不过有一类无锁数据结构,它们总能允许其他线程继续推进,而不是被互斥量阻塞……但从零开始正确地实现它们很难,遗憾的是,这也超出了本指南的范围。
这只是故事的一部分。但我们就从这一部分开始。
在继续之前,怎样把一个变量声明为原子变量呢?
首先,包含 <stdatomic.h>。
它为我们提供了 atomic_int 之类的类型。
然后我们只需把变量声明为该类型即可。
不过我们来做个小演示,其中有两个线程。第一个运行一段时间,然后把某个变量设为特定值,接着退出。另一个一直运行,直到看到那个值被设置,然后退出。
#include <stdio.h>
#include <threads.h>
#include <stdatomic.h>
atomic_int x; // 原子的力量!哇哈哈哈哈!
int thread1(void *arg)
{
(void)arg;
printf("Thread 1: Sleeping for 1.5 seconds\n");
thrd_sleep(&(struct timespec){.tv_sec=1, .tv_nsec=500000000}, NULL);
printf("Thread 1: Setting x to 3490\n");
x = 3490;
printf("Thread 1: Exiting\n");
return 0;
}
int thread2(void *arg)
{
(void)arg;
printf("Thread 2: Waiting for 3490\n");
while (x != 3490) {} // 在这里自旋
printf("Thread 2: Got 3490--exiting!\n");
return 0;
}
int main(void)
{
x = 0;
thrd_t t1, t2;
thrd_create(&t1, thread1, NULL);
thrd_create(&t2, thread2, NULL);
thrd_join(t1, NULL);
thrd_join(t2, NULL);
printf("Main : Threads are done, so x better be 3490\n");
printf("Main : And indeed, x == %d\n", x);
}第二个线程原地自旋,盯着这个标志,等待它被设为 3490。而第一个线程正是这么做的。
我得到如下输出:
Thread 1: Sleeping for 1.5 seconds
Thread 2: Waiting for 3490
Thread 1: Setting x to 3490
Thread 1: Exiting
Thread 2: Got 3490--exiting!
Main : Threads are done, so x better be 3490
Main : And indeed, x == 3490看哪,妈!我们在不同线程里访问同一个变量,而且没用互斥量!多亏了原子变量的原子性,这每次都能成功。
你可能想知道,如果那只是一个普通的非原子 int 会怎样。嗯,在我的系统上它照样能工作……除非我做优化构建,那样的话它就会卡在线程 2 上,等着看到 3490 被设置209。
但这只是故事的开始。下一部分需要动更多脑筋,而且涉及一个叫同步的东西。
我们故事的下一部分,讲的是一个线程中的某些内存写入何时对另一个线程可见。
你也许会觉得,马上就会可见,对吧?但并非如此。有好几件事都可能出岔子。出得很诡异。
编译器可能重排了内存访问,以至于你自以为的某个值相对于另一个值的设置顺序可能并不成立。就算编译器没这么干,你的 CPU 也可能在运行时这么干。又或许这个架构还有别的什么特性,使得一个 CPU 上的写入要延迟一段时间才对另一个 CPU 可见。
好消息是,我们可以把所有这些潜在的麻烦归结为一件事:未同步的内存访问可能看起来是乱序的,具体取决于哪个线程在观察,就好像代码行本身被重排过一样。
举例来说,在下面的代码中,写 x 和写 y 哪个先发生?
答案是:我们不知道。编译器或 CPU 可能悄无声息地把第 5 行和第 6 行对调,而我们毫不知情。在单线程运行时,这段代码会如同按代码顺序执行一样。
在多线程场景下,我们可能有类似这样的伪代码:
int x = 0, y = 0;
thread1() {
x = 2;
y = 3;
}
thread2() {
while (y != 3) {} // 自旋
printf("x is now %d\n", x); // 2?……还是 0?
}线程 2 的输出是什么?
嗯,如果 x 在 y 被赋为 3 之前就被赋为 2,那么我期望的输出就非常合理:
x is now 2 但某个狡猾的家伙可能重排第 4 行和第 5 行,让我们在打印 x 时看到它的值是 0。
换句话说,除非我们能以某种方式声明:“到此刻为止,我期望另一个线程先前所有的写入在这个线程中都可见”,否则一切都不作数。
当两个线程对共享内存的状态达成一致时,它们就同步了。如我们所见,它们并不总是与代码保持一致。那么它们是怎样达成一致的呢?
使用原子变量可以强制达成这种一致210。如果一个线程写入某个原子变量,它就是在说:“今后任何读取这个原子变量的人,也会看到我截至并包括该原子变量在内对内存所做的所有修改(无论是否原子)”。
或者用更人话的说法:咱们围着会议桌坐下,确保大家对“哪块共享内存里存着什么值”有共识。你同意:你截至并包括那次原子存储所做的内存修改,在我对同一个原子变量做一次加载之后,对我可见。
所以我们可以轻松修好这个例子:
int x = 0;
atomic int y = 0; // 让 y 成为原子类型
thread1() {
x = 2;
y = 3; // 在写入时同步
}
thread2() {
while (y != 3) {} // 在读取时同步
printf("x is now %d\n", x); // 就是 2。
}因为线程通过 y 同步,线程 1 中发生在写 y 之前的所有写入,都会在线程 2 从 y 读取之后(在 while 循环中)可见。
这里有几点值得注意:
没有任何休眠。同步不是阻塞操作。两个线程都全速运行,直到退出。即使是卡在自旋循环里的那个线程,也没有阻止其他任何线程运行。
同步发生在某个线程读取另一个线程写过的原子变量之时。因此,当线程 2 读取 y 时,线程 1 此前所有的内存写入(也就是对 x 的赋值)在线程 2 中都可见。
注意 x 并不是原子的。这没关系,因为我们并不是通过 x 来同步;而在线程 1 中写入 y 时发生的同步意味着:线程 1 此前所有的写入——包括对 x 的写入——都会对其他线程可见……前提是那些线程通过读取 y 来完成同步。
强制制造这种同步效率不高,可能比直接使用普通变量慢得多。正因如此,除非特定应用非得如此,我们不会使用原子操作。
以上就是基础知识。我们再往深处看看。
又是术语!现在把它们的含义弄清楚,后面会大有好处。
当一个线程读取原子变量时,这被称为一次获取(acquire)操作。
当一个线程写入原子变量时,这被称为一次释放(release)操作。
这些是什么?我们把它与你已经熟悉的原子变量术语对应起来:
读 = 载入 = 获取。比如比较一个原子变量,或读取它以便把值复制到另一个变量中时。
写 = 存储 = 释放。比如向一个原子变量赋值时。
当以这种获取/释放语义使用原子变量时,C 明确规定了什么情况下会发生什么。
获取/释放构成了我们刚才所讨论的同步的基础。
当一个线程获取某个原子变量时,它能看到另一个线程在释放同一个变量之前所设置的值。
换句话说:
当一个线程读取某个原子变量时,它能看到另一个线程写入同一个变量时所设置的值。
同步是跨越获取/释放这一对操作发生的。
更多细节:
对于某个特定原子变量的读/载入/获取:
另一个线程中,在该线程写入/存储/释放这个原子变量之前发生的所有写入(原子的或非原子的),现在在本线程中都可见。
另一个线程为这个原子变量设置的新值,在本线程中同样可见。
当前线程中对任何变量/内存的读或写,都不能被重排到这次获取之前发生。
就代码重排而言,获取起着单向屏障的作用;当前线程中的读写可以从获取之前下移到它之后。但更重要的是,就同步而言,没有任何操作能从获取之后上移到它之前。
对于某个特定原子变量的写/存储/释放:
当前线程中在这次释放之前发生的所有写入(原子的或非原子的),都会对已经读/载入/获取同一个原子变量的其他线程可见。
本线程写入这个原子变量的值,对其他线程同样可见。
当前线程中对任何变量/内存的读或写,都不能被重排到这次释放之后发生。
就代码重排而言,释放起着单向屏障的作用:当前线程中的读写可以从释放之后上移到它之前。但更重要的是,就同步而言,没有任何操作能从释放之前下移到它之后。
再次强调,结果就是内存从一个线程到另一个线程的同步。第二个线程可以确信,变量和内存的写入顺序与程序员预期的顺序一致。
int x, y, z = 0;
atomic_int a = 0;
thread1() {
x = 10;
y = 20;
a = 999; // 释放
z = 30;
}
thread2()
{
while (a != 999) { } // 获取
assert(x == 10); // 永远不会触发断言,x 始终是 10
assert(y == 20); // 永远不会触发断言,y 始终是 20
assert(z == 0); // 可能会触发断言!!
}
在上面的示例中,thread2 在获取 a 之后可以确定 x 和 y 的值,因为它们是在 thread1 释放原子变量 a 之前设置的。
但 thread2 无法确定 z 的值,因为这个值是在释放之后才出现的。也许对 z 的赋值被移动到了对 a 的赋值之前。
重要提示:释放一个原子变量,对不同原子变量的获取没有任何影响。每个变量都与其他变量相互隔离。
还跟得上吗?原子操作中较简单用法的主体部分,我们已经讲完了。而且这里我们根本不打算讨论更复杂的用法,所以你可以稍微放松一下。
顺序一致性是一种所谓的内存序。内存序有很多种,但顺序一致性是 C 能提供的最理智211的一种。它也是默认值。要改用其他内存序,你得费一番额外的功夫。
到目前为止我们讨论的所有内容,都发生在顺序一致性的范畴之内。
我们讨论过,只要遵循 as-if 规则,编译器或 CPU 就可以在单个线程内重排内存读写。
我们也看到了,如何通过原子变量进行同步来给这种行为踩下刹车。
我们再来把它形式化一点点。
如果操作是顺序一致的,那就意味着归根结底、尘埃落定之时,所有线程都能翘起脚、打开自己中意的饮料,并就运行期间内存变化的先后顺序达成一致。而这个顺序正是代码所规定的顺序。
不会出现这种情况:其他线程都说“A 肯定发生在 B 之前”,却有一个说“可 B 不是发生在 A 之前吗?”。在这里,大家都是好朋友。
特别地,在单个线程内,任何获取和释放都不能彼此重排。这是在上面那些关于哪些其他内存访问可以围绕它们重排的规则之外,额外的一条限制。
这条规则为原子载入/获取与存储/释放的推进过程,增加了一层额外的理智保障。
C 中其他每一种内存序都涉及对重排规则的放宽,放宽的对象要么是获取/释放,要么是其他内存访问(原子或非原子)。只有当你真的清楚自己在做什么,并且需要那点速度提升时,你才会这么做。此处恶龙成群……
这个后面再细说,眼下我们还是守住安全又实用的部分。
原子变量上的某些运算符是原子的,另一些则不是。
先从一个反例开始:
atomic_int x = 0;
thread1() {
x = x + 3; // 不是原子的!
}由于赋值右侧要读取 x,而左侧实际上要写入,这就是两个操作。另一个线程可能从中间插进来,让你不快。
但你可以使用简写形式 += 来获得原子操作:
atomic_int x = 0;
thread1() {
x += 3; // 是原子的!
}在这种情况下,x 会被原子地增加 3——没有其他线程能插到中间来。
特别地,下列运算符都是具有顺序一致性的原子读-修改-写操作,尽管放心大胆地使用。(示例中 a 是原子变量。)
a++ a-- --a ++a
a += b a -= b a *= b a /= b a %= b
a &= b a |= b a ^= b a >>= b a <<= b到目前为止,我们讨论的是如何用原子变量进行同步,但事实证明,还有几个库函数自身会在幕后做一些有限的同步。
call_once() thrd_create() thrd_join()
mtx_lock() mtx_timedlock() mtx_trylock()
malloc() calloc() realloc()
aligned_alloc()call_once()——与针对某个特定标志的所有后续 call_once() 调用同步。这样,后续调用就可以放心:如果另一个线程设置了这个标志,它们一定能看到。
thrd_create()——与新线程的起点同步。新线程可以确信,它会看到父线程在调用 thrd_create() 之前对共享内存的所有写入。
thrd_join()——当某个线程结束时,它会与这个函数同步。调用了 thrd_join() 的线程可以确信,它能看到那个已结束线程的所有共享写入。
mtx_lock()——对同一互斥量的更早的 mtx_unlock() 调用会与本次调用同步。这是最贴近我们前面讨论过的获取/释放过程的情形。mtx_unlock() 对互斥量变量执行一次释放,从而保证任何随后用 mtx_lock() 进行获取的线程都能看到临界区内的所有共享内存改动。
mtx_timedlock() 以及 mtx_trylock()——与 mtx_lock() 的情形类似,如果这次调用成功,更早的 mtx_unlock() 调用就会与它同步。
动态内存函数:如果你分配内存,这次分配会与之前对同一块内存的释放同步。而且对那个特定内存区域的分配与释放,会以所有线程都能达成一致的单一全序发生。我认为这里的想法是:释放操作可以选择把该区域抹掉,而我们要确保后续的分配不会看到未被抹掉的数据。如果其中还有更多门道,希望有人能告诉我。
我们放慢一点,看看有哪些类型可用,以及怎样才能造出新的原子类型。
先说最要紧的:看看内置的原子类型,以及它们被 typedef 成了什么。(剧透:_Atomic 是个类型限定符!)
| 原子类型 | 等价的完整写法 |
|---|---|
atomic_bool |
_Atomic _Bool |
atomic_char |
_Atomic char |
atomic_schar |
_Atomic signed char |
atomic_uchar |
_Atomic unsigned char |
atomic_short |
_Atomic short |
atomic_ushort |
_Atomic unsigned short |
atomic_int |
_Atomic int |
atomic_uint |
_Atomic unsigned int |
atomic_long |
_Atomic long |
atomic_ulong |
_Atomic unsigned long |
atomic_llong |
_Atomic long long |
atomic_ullong |
_Atomic unsigned long long |
atomic_char16_t |
_Atomic char16_t |
atomic_char32_t |
_Atomic char32_t |
atomic_wchar_t |
_Atomic wchar_t |
atomic_int_least8_t |
_Atomic int_least8_t |
atomic_uint_least8_t |
_Atomic uint_least8_t |
atomic_int_least16_t |
_Atomic int_least16_t |
atomic_uint_least16_t |
_Atomic uint_least16_t |
atomic_int_least32_t |
_Atomic int_least32_t |
atomic_uint_least32_t |
_Atomic uint_least32_t |
atomic_int_least64_t |
_Atomic int_least64_t |
atomic_uint_least64_t |
_Atomic uint_least64_t |
atomic_int_fast8_t |
_Atomic int_fast8_t |
atomic_uint_fast8_t |
_Atomic uint_fast8_t |
atomic_int_fast16_t |
_Atomic int_fast16_t |
atomic_uint_fast16_t |
_Atomic uint_fast16_t |
atomic_int_fast32_t |
_Atomic int_fast32_t |
atomic_uint_fast32_t |
_Atomic uint_fast32_t |
atomic_int_fast64_t |
_Atomic int_fast64_t |
atomic_uint_fast64_t |
_Atomic uint_fast64_t |
atomic_intptr_t |
_Atomic intptr_t |
atomic_uintptr_t |
_Atomic uintptr_t |
atomic_size_t |
_Atomic size_t |
atomic_ptrdiff_t |
_Atomic ptrdiff_t |
atomic_intmax_t |
_Atomic intmax_t |
atomic_uintmax_t |
_Atomic uintmax_t |
随你使用!它们与 C++ 中的原子别名保持一致,但愿这能帮到你。
但如果你想要更多呢?
你可以用类型限定符,也可以用类型说明符。
先说明符!它就是关键字 _Atomic,后面跟一个放在括号里的类型212——适合与 typedef 一起使用:
typedef _Atomic(double) atomic_double;
atomic_double f;对说明符的限制:你想使之原子的类型不能是数组类型或函数类型,也不能是原子的或带有其他限定符的。
接下来是限定符!它就是关键字 _Atomic,不带括号里的类型。
所以下面两种写法做的事差不多213:
_Atomic(int) i; // 类型说明符
_Atomic int j; // 类型限定符问题在于,后一种写法还可以带上其他类型限定符:
_Atomic volatile int k; // 带限定符的原子变量对限定符的限制:你想使之原子的类型不能是数组类型或函数类型。
在能原子读写的数量上,硬件架构是有限的。这取决于硬件是怎么连起来的,而且各不相同。
如果你使用原子类型,可以确信对该类型的访问将是原子的……但有个问题:如果硬件做不到,就会改用锁来实现。
于是原本的原子访问就变成了“加锁、访问、解锁”三步,这会慢不少,并且对信号处理函数有一些影响。
原子标志(见下文)是唯一在所有符合标准的实现中都保证无锁的原子类型。在典型的台式机/笔记本世界里,其他更大的类型很可能也是无锁的。
幸运的是,我们有几种办法可以判断某个特定类型是不是无锁原子类型。
首先是几个宏——你可以在编译期配合 #if 使用它们。它们对有符号类型和无符号类型都适用。
| 原子类型 | 无锁宏 |
|---|---|
atomic_bool |
ATOMIC_BOOL_LOCK_FREE |
atomic_char |
ATOMIC_CHAR_LOCK_FREE |
atomic_char16_t |
ATOMIC_CHAR16_T_LOCK_FREE |
atomic_char32_t |
ATOMIC_CHAR32_T_LOCK_FREE |
atomic_wchar_t |
ATOMIC_WCHAR_T_LOCK_FREE |
atomic_short |
ATOMIC_SHORT_LOCK_FREE |
atomic_int |
ATOMIC_INT_LOCK_FREE |
atomic_long |
ATOMIC_LONG_LOCK_FREE |
atomic_llong |
ATOMIC_LLONG_LOCK_FREE |
atomic_intptr_t |
ATOMIC_POINTER_LOCK_FREE |
有意思的是,这些宏可以有三种不同的取值:
| 取值 | 含义 |
|---|---|
0 |
从不是无锁的。 |
1 |
有时无锁。 |
2 |
始终无锁。 |
等等——某个东西怎么会有时无锁呢?这只是说答案在编译期未知,但到了运行期就可能知道了。也许答案取决于你是在正牌 Intel 还是 AMD 上运行这段代码,诸如此类214。
但你总可以在运行期用 atomic_is_lock_free() 函数来测试。该函数会返回 true 或 false,表示这个特定类型当下是否是无锁的。
那我们为什么关心这个?
无锁更快,所以也许你有速度上的顾虑,会用别的方式绕开。又或者你需要在信号处理函数里使用原子变量。
如果你在信号处理函数中读或写一个共享变量(静态存储期或 _Thread_Local),那就是未定义行为[倒吸一口气!]……除非你做到以下几点之一:
写入一个 volatile sig_atomic_t 类型的变量。
读取或写入一个无锁的原子变量。
据我所知,无锁原子变量是把信息从信号处理函数里可移植地送出来的少数几种办法之一。
在我看来,规范对于在信号处理函数中获取或释放原子变量时的内存序说得有点含糊。C++ 的说法是(而且这很合理):这类访问相对于程序其余部分是无顺序的215。毕竟信号可能在任何时刻被触发。所以我假定 C 的行为也类似。
标准只保证一种类型是无锁原子类型:atomic_flag。这是一个用于测试并设置216操作的不透明类型。
它要么是已设置的,要么是已清除的。可以用下面的写法把它初始化成清除状态:
atomic_flag f = ATOMIC_FLAG_INIT;你可以用 atomic_flag_test_and_set() 原子地设置标志,它会设置该标志并以 _Bool 返回它先前的状态(已设置为 true)。
你可以用 atomic_flag_clear() 原子地清除标志。
下面这个例子先把标志初始化成清除状态,然后设置它两次,再清除一次。
#include <stdio.h>
#include <stdbool.h> // 在 C23 中不需要
#include <stdatomic.h>
atomic_flag f = ATOMIC_FLAG_INIT;
int main(void)
{
bool r = atomic_flag_test_and_set(&f);
printf("Value was: %d\n", r); // 0
r = atomic_flag_test_and_set(&f);
printf("Value was: %d\n", r); // 1
atomic_flag_clear(&f);
r = atomic_flag_test_and_set(&f);
printf("Value was: %d\n", r); // 0
}
struct 与 union用 _Atomic 限定符或说明符,你可以做出原子的 struct 或 union!相当惊人。
如果里面的数据不多(也就是几个字节),得到的原子类型可能还是无锁的。用 atomic_is_lock_free() 测一下。
#include <stdio.h>
#include <stdatomic.h>
int main(void)
{
struct point {
float x, y;
};
_Atomic(struct point) p;
printf("Is lock free: %d\n", atomic_is_lock_free(&p));
}麻烦在于:你不能访问原子 struct 或 union 的成员……那这有什么用?嗯,你可以原子地把整个 struct 复制到一个非原子变量里再用它。反过来复制也可以。
#include <stdio.h>
#include <stdatomic.h>
int main(void)
{
struct point {
float x, y;
};
_Atomic(struct point) p;
struct point t;
p = (struct point){1, 2}; // 原子复制
//printf("%f\n", p.x); // 错误
t = p; // 原子复制
printf("%f\n", t.x); // 没问题!
}你也可以声明一个各成员都是原子的 struct。原子类型是否允许用在位域上由实现定义。
这里只是提一句:涉及指针时 _Atomic 该放在哪儿。
第一种,指向原子量的指针(即指针值不是原子的,但它指向的东西是原子的):
_Atomic int x;
_Atomic int *p; // p 是指向原子 int 的指针
p = &x; // 没问题!第二种,指向非原子值的原子指针(即指针值本身是原子的,而它指向的东西不是):
int x;
int * _Atomic p; // p 是指向 int 的原子指针
p = &x; // 没问题!最后,指向原子值的原子指针(即指针和它指向的东西都是原子的):
_Atomic int x;
_Atomic int * _Atomic p; // p 是指向原子 int 的原子指针
p = &x; // 没问题!
我们已经讲过顺序一致性,它是一堆内存序里最讲道理的那个。不过还有另外几种:
memory_order |
描述 |
|---|---|
memory_order_seq_cst |
顺序一致性 |
memory_order_acq_rel |
获取/释放 |
memory_order_release |
释放 |
memory_order_acquire |
获取 |
memory_order_consume |
消费 |
memory_order_relaxed |
松散 |
你可以用某些库函数指定其他的内存序。例如,可以像这样给一个原子变量加上一个值:
atomic_int x = 0;
x += 5; // 顺序一致性,默认选项也可以用这个库函数做同样的事:
atomic_int x = 0;
atomic_fetch_add(&x, 5); // 顺序一致性,默认选项或者用显式的内存序来做同样的事:
atomic_int x = 0;
atomic_fetch_add_explicit(&x, 5, memory_order_seq_cst);但如果我们不想要顺序一致性呢?又或者出于某种原因你想要获取/释放语义?那就直接点名要它:
atomic_int x = 0;
atomic_fetch_add_explicit(&x, 5, memory_order_acq_rel);下面我们会逐一分析不同的内存序。除非你清楚自己在干什么,否则别去碰顺序一致性以外的东西。很容易犯下那种导致罕见、极难复现的故障的错误。
另外,为了维持获取与释放的全序,任何获取或释放之间都不会互相重排。(获取/释放规则并不禁止把一个释放后跟一个获取重排。但顺序一致性的规则禁止。)
这是对原子变量做加载/读取操作时发生的事。
如果另一个线程释放过这个原子变量,那么那个线程做过的所有写入现在在本线程里都能看到。
本线程中在这次加载之后发生的内存访问,不能被重排到它前面。
这是对原子变量做存储/写入时发生的事。
如果另一个线程之后获取了这个原子变量,那么本线程在它的原子写入之前的所有内存写入,对那个线程都可见。
本线程中在释放之前发生的内存访问,不能被重排到它后面。
这一种比较古怪,类似获取的一个不那么严格的版本。它影响的是那些数据依赖于该原子变量的内存访问。
所谓“数据依赖”,大致是说该原子变量被用在了某次计算里。
也就是说,如果一个线程消费了某个原子变量,那么该线程中后续使用这个原子变量的所有操作,都能看到释放线程中的内存写入。
对比一下获取:在获取下,释放线程的内存写入对当前线程的所有操作都可见,而不只是数据依赖的那些。
和获取一样,消费对哪些操作可以被重排到它之前也有约束。在获取下,任何操作都不能被重排到它前面;而在消费下,不能重排到它前面的,是任何依赖于所加载原子值的操作。
这只适用于读-改-写操作。它是把获取和释放打包成了一个。
没有规则;一片无政府状态!谁都可以在任何地方重排任何东西!猫和狗住在一起——集体癔症!
其实还是有一条规则的:原子读和原子写仍然是全有或全无的。但这些操作可以被随意重排,而且线程之间没有任何同步。
这种内存序有一些适用场景,稍微搜一下就能找到,比如简单的计数器。
另外,你可以用栅栏在一连串松散写入之后强制同步。
你知道原子变量的释放和获取是随着你对它的读写而发生的吧?
其实,也可以不用原子变量就完成一次释放或获取。
这叫做栅栏。所以,如果你想让一个线程里的所有写入在别处可见,可以在一个线程里立一道释放栅栏、在另一个线程里立一道获取栅栏,原理和原子变量一样。
由于消费操作用在栅栏上没什么意义217,memory_order_consume 会被当作获取来处理。
你可以用任何指定的内存序立起栅栏:
atomic_thread_fence(memory_order_release);
还有一种供信号处理函数使用的轻量级栅栏,叫做 atomic_signal_fence()。
它的工作方式和 atomic_thread_fence() 完全一样,只是:
它只处理同一线程内值的可见性;与其他线程之间没有任何同步。
不会生成任何硬件栅栏指令。
如果你想确保非原子操作(以及松散原子操作)的副作用在信号处理函数中可见,就可以用这个栅栏。
其思路是:信号处理函数是在本线程中执行的,而不是在别的线程,所以这是一种更轻量的办法,用来确保信号处理函数之外的改动在它内部可见(也就是说,这些改动没有被重排)。
如果你想进一步了解这些东西,下面这些材料帮我啃下了它们:
Herb Sutter 的演讲atomic<> Weapons:
Jeff Preshing 的材料220,尤其是:
CPPReference:
还有 r/C_Programming 上那些乐于助人、见多识广的朋友们230