其他答案解释了相对于各种原子操作可以或不能重新排序哪些操作,但我想提供一个替代的、更高级的解释:各种内存顺序可以实际用于什么。
要忽略的事情:
memory_order_consume
- 显然没有主要的编译器实现它,他们默默地用更强的memory_order_acquire
. 甚至标准本身也说要避免它。
关于内存订单的 cppreference 文章的很大一部分涉及“消费”,因此删除它可以大大简化事情。
它还可以让您忽略相关功能,例如[[carries_dependency]]
和std::kill_dependency
。
数据竞争:从一个线程写入非原子变量,同时从另一个线程读取/写入它被称为数据竞争,并导致未定义的行为。
memory_order_relaxed
是最弱的,据说是最快的记忆顺序。
对原子的任何读/写都不会导致数据竞争(以及随后的 UB)。relaxed
只为单个变量提供这个最低限度的保证。它不为其他变量(原子或非原子)提供任何保证。
宽松的操作可以以奇怪的顺序在线程之间传播,并且随着不同的不可预测(但很小)的延迟,对不同变量的宽松更改可以以不同的顺序对不同的线程变得可见,等等。
唯一的规则是:
- 每个线程将按照您告诉它的确切顺序访问每个单独的变量。例如,
a.store(1, relaxed); a.store(2, relaxed);
将写1
, then 2
,从不以相反的顺序。但是在同一个线程中对不同变量的访问仍然可以相对于彼此重新排序。
- 如果线程 A 多次写入一个变量,然后线程 B 读取几次,它将以相同的顺序获取值(当然它可以读取一些值多次,或者跳过一些,如果你不同步其他方式的线程)。
示例用途:任何不尝试使用原子变量来同步对非原子数据的访问:各种计数器(仅供参考),或“停止标志”以指示其他线程停止。另一个例子:在shared_ptr
s 上增加引用计数的操作在内部使用relaxed
.
栅栏: atomic_thread_fence(relaxed);
什么都不做。
memory_order_release
,memory_order_acquire
做所有事情relaxed
,等等(所以它应该更慢或等效)。
只有商店(写入)可以使用release
. 只有加载(读取)可以使用acquire
. 诸如读取-修改-写入操作fetch_add
都可以是 ( memory_order_acq_rel
),但它们不是必须的。
那些让你同步线程:
假设线程 1 读取/写入某个内存 M(任何非原子或原子变量,都没有关系)。
然后线程 1 对变量 A 执行释放存储。然后它停止接触该内存。
如果线程 2 然后执行相同变量 A 的获取加载,则称此加载与线程 1 中的相应存储同步。
现在线程 2 可以安全地读取/写入该内存 M。
您只与最新的作者同步,而不是之前的作者。
您可以跨多个线程链接同步。
有一个特殊的规则,即同步在任意数量的读取-修改-写入操作中传播,而不管它们的内存顺序如何。例如,如果线程 1 执行a.store(1, release);
,则线程 2 执行a.fetch_add(2, relaxed);
,然后线程 3 执行a.load(acquire)
,则线程 1 与线程 3 成功同步,即使中间有一个宽松的操作。
在上面的规则中,一个释放操作 X,以及对同一个变量 X 的任何后续读-修改-写操作(在下一个非读-修改-写操作处停止)称为以 X 为首的释放序列。(所以如果获取从释放序列中的任何操作读取,它与序列的头部同步。)
如果涉及读-修改-写操作,没有什么能阻止您同步多个操作。在上面的例子中,如果fetch_add
使用acquire
or acq_rel
,它也会与线程 1 同步,反之,如果使用release
or acq_rel
,线程 3 除了与 1 之外,还会与 2 同步。
使用示例: shared_ptr
使用类似fetch_sub(1, acq_rel)
.
原因如下:假设线程 1 读取/写入ptr->...
,然后销毁其副本ptr
,递减 ref 计数。然后线程 2 销毁最后剩余的指针,同时减少 ref 计数,然后运行析构函数。
由于线程 2 中的析构函数将访问线程 1 先前访问的内存,因此需要进行acq_rel
同步fetch_sub
。否则,您将面临数据竞赛和 UB。
Fences:使用atomic_thread_fence
,您基本上可以将轻松的原子操作转变为释放/获取操作。单个栅栏可以应用于多个操作,和/或可以有条件地执行。
如果您从一个或多个变量执行轻松读取(或以任何其他顺序),然atomic_thread_fence(acquire)
后在同一个线程中执行,则所有这些读取都算作获取操作。
相反,如果你这样做atomic_thread_fence(release)
了,然后是任意数量的(可能是放松的)写入,这些写入都算作释放操作。
acq_rel
栅栏结合了栅栏acquire
和release
栅栏的效果。
与其他标准库功能的相似之处:
几个标准库特性也导致了与关系的类似同步。例如,锁定一个互斥锁与最新的解锁同步,就好像锁定是一个获取操作,而解锁是一个释放操作。
memory_order_seq_cst
做一切acquire
/release
做,等等。这应该是最慢的顺序,但也是最安全的。
seq_cst
读取算作获取操作。seq_cst
写入算作释放操作。seq_cst
读-修改-写操作都算作两者。
seq_cst
操作可以相互同步,也可以与获取/释放操作同步。小心混合它们的特殊效果(见下文)。
seq_cst
是默认顺序,例如 given atomic_int x;
,x = 1;
does x.store(1, seq_cst);
。
seq_cst
与获取/释放相比,有一个额外的属性:seq_cst
整个程序中的所有读取和写入都发生在一个全局顺序中。
除其他事项外,此顺序尊重上述获取/释放描述的同步关系,除非(这很奇怪)同步来自将seq-cst 操作与获取/释放操作混合(释放与 seq-cst 同步,或seq-cst 与获取同步)。这种混合本质上将受影响的 seq-cst 操作降级为获取/释放(它可能保留了一些 seq-cst 属性,但你最好不要指望它)。
示例使用:
atomic_bool x = true;
atomic_bool y = true;
// Thread 1:
x.store(false, seq_cst);
if (y.load(seq_cst)) {...}
// Thread 2:
y.store(false, seq_cst);
if (x.load(seq_cst)) {...}
假设您只希望一个线程能够进入if
正文。seq_cst
允许你这样做。获取/释放或较弱的订单在这里是不够的。
栅栏: atomic_thread_fence(seq_cst);
做栅栏所做的一切acq_rel
,外加一个附加功能。
比方说:
- 线程 1 使用
seq_cst
order 访问变量 X,然后
- 线程 2 线程使用任何顺序访问同一个变量 X(不一定会导致同步),然后
- 线程 2 可以
atomic_thread_fence(seq_cst)
。
然后在线程 1 中的 seq-cst 访问之后,线程 2 中的任何操作都将在线程 1 中发生(但线程 1 中的 seq-cst 操作之前的非 seq-cst 操作不一定会在线程 2 中的围栏之前发生)。
反过来也有效:
- 线程 1 执行
atomic_thread_fence(seq_cst)
,然后
- 线程 1 使用任意顺序访问变量 X,然后
seq_cst
线程 2 使用order访问相同的变量 X。
那么线程 1 中栅栏之前的任何操作都将发生在线程 2 中的 seq-cst 操作之前,但不一定在线程 2 中后续的非 seq-cst 操作之前。
您也可以在两侧设置栅栏:
- 线程 1 执行
atomic_thread_fence(seq_cst)
,然后
- 线程 1 使用任意顺序访问变量 X,然后
- 线程 2 使用任意顺序访问同一个变量 X,然后
- 线程 2 可以
atomic_thread_fence(seq_cst)
然后在栅栏之前线程 1 中的任何事情都会发生在栅栏之后线程 2 中的任何事情之前。
不同订单之间的互操作
总结以上内容:
|
relaxed 写 |
release 写 |
seq-cst 写 |
relaxed 加载 |
- |
- |
- |
acquire 加载 |
- |
与 |
与*同步 |
seq-cst 加载 |
- |
与*同步 |
与 |
* = 参与的 seq-cst 操作得到一个混乱的 seq-cst 顺序,实际上被降级为获取/释放操作。这在上面已经解释过了。
使用更强的内存顺序是否会使线程之间的数据传输更快?
不,似乎没有。
无数据竞争程序的顺序一致性
该标准解释说,如果您的程序仅使用seq_cst
访问(和互斥体),并且没有数据竞争(导致 UB),那么您不需要考虑所有花哨的操作重新排序。该程序的行为就像一次只执行一个线程,线程不可预测地交错。