46 c++ atomic memory-model thread-safety c++11
我读了一章,我不喜欢它.我还不清楚每个内存顺序之间的差异.这是我目前的推测,我在阅读更简单的http://en.cppreference.com/w/cpp/atomic/memory_order后理解这一点.
以下是错误的,所以不要试图从中学习
a在另一个线程上放宽了存储.我b用seq_cst 存储.第三个线程读取a 放松将看到变化以及b任何其他原子变量? ).如果我错了,我想我理解但是纠正我.我找不到任何用易于阅读的英语解释它的东西.
Dam*_*mon 51
GCC Wiki 通过代码示例提供了非常详尽且易于理解的解释.
(摘录编辑,重点补充)
在重新阅读从GCC Wiki复制的以下引用的过程中,我将自己的措辞添加到答案中,我注意到引用实际上是错误的.他们获得并以完全错误的方式消费.甲释放消耗操作仅而提供相关的数据的排序保证释放获取无论操作提供数据依赖于所述原子值或不作出保证.
第一个模型是"顺序一致".这是未指定时使用的默认模式,并且是最严格的模式.它也可以通过明确指定
memory_order_seq_cst.它为顺序程序员本身熟悉的移动负载提供了相同的限制和限制,除了它适用于跨线程.
[...]
从实际的角度来看,这相当于所有原子操作都充当了优化障碍.可以在原子操作之间重新排序,但不能在整个操作中重新排序.线程本地内容也不受影响,因为其他线程没有可见性.[...]此模式还提供所有线程的一致性.所述相反的方法是
memory_order_relaxed.通过消除之前发生的限制,此模型允许更少的同步.这些类型的原子操作也可以对它们执行各种优化,例如死存储删除和共享.[...]没有任何发生之前的边缘,没有线程可以指望来自另一个线程的特定排序.当程序员只是希望变量本质上是原子的而不是使用它来同步其他共享内存数据的线程时,最常使用
松弛模式.第三种模式(
memory_order_acquire/memory_order_release)是另外两种模式的混合体.获取/释放模式类似于顺序一致模式,除了它仅将依赖于发生的关系应用于因变量.这允许放松独立写入的独立读取之间所需的同步.
memory_order_consume在发布/获取内存模型中进一步细化,通过在排序非依赖共享变量之前删除发生的事件来略微放宽需求.
[...]
真正的区别归结为硬件必须刷新多少状态才能进行同步.因为消费操作因此可以更快地执行,所以知道他们正在做什么的人可以将其用于性能关键应用程序.
另一种看待它的方法是从重新排序读取和写入的角度来看待问题,包括原子和普通:
所有原子操作都保证在其自身内是原子的(两个原子操作的组合不是整个原子!)并且在它们出现在执行流的时间轴上的总顺序中是可见的.这意味着在任何情况下都不能重新排序原子操作,但其他内存操作可能很好.编译器(和CPU)通常将这种重新排序作为优化.
它还意味着编译器必须使用任何必要的指令来保证在任何时间执行的原子操作都能看到每个其他原子操作的结果,可能是在另一个处理器核心(但不一定是其他操作)之前执行的.
现在,轻松就是那个,最低限度.它不会做任何其他事情,也不提供其他保证.这是最便宜的操作.对于强排序处理器体系结构(例如x86/amd64)上的非读取 - 修改 - 写入操作,这归结为普通的普通移动.
该顺序一致的操作是完全相反的,它强制执行严格的顺序不仅为原子操作,而且对于之前或之后发生的其他内存操作.两者都不能跨越原子操作所施加的障碍.实际上,这意味着失去了优化机会,并且可能必须插入围栏指令.这是最昂贵的型号.
甲释放操作防止普通加载和存储被重新排序后的原子操作,而一个获取操作防止普通加载和存储被重新排序之前的原子操作.其他所有东西仍然可以移动.
防止在之后移动存储以及在相应的原子操作之前移动加载的组合确保获取线程看到的任何内容是一致的,仅丢失少量优化机会.
人们可能会认为这就像是一个不存在的锁(由作者发布)并被(由读者获得)获得.除了......没有锁.
在实践中,发布/获取通常意味着编译器不需要使用任何特别昂贵的特殊指令,但它不能自由地对负载和存储进行重新排序,这可能会错过一些(小的)优化机会.
最后,consume是与acquire相同的操作,只是排序保证仅适用于依赖数据.从属数据例如是由原子修改的指针指向的数据.
可以说,这可能提供一些优化机会,这些优化机会并不存在于获取操作中(因为较少的数据受到限制),但这是以更复杂且更容易出错的代码和非平凡任务为代价的.获得依赖链正确.
目前不鼓励在修改规范时使用消费排序.
小智 28
这是一个非常复杂的主题.尝试多次阅读http://en.cppreference.com/w/cpp/atomic/memory_order,尝试阅读其他资源等.
这是一个简化的描述:
编译器和 CPU可以重新排序内存访问.也就是说,它们的发生顺序可能与代码中指定的顺序不同.这在大多数情况下都很好,当不同的线程尝试通信时会出现问题,并且可能会看到这样的内存访问顺序会破坏代码的不变量.
通常,您可以使用锁进行同步.问题是它们很慢.原子操作要快得多,因为同步发生在CPU级别(即CPU确保没有其他线程,即使在另一个CPU上,也会修改某些变量等).
因此,我们面临的一个问题是重新排序内存访问.该memory_order枚举指定什么类型的重新排序的编译器必须禁止.
relaxed - 没有限制.
consume - 没有依赖于新加载值的负载可以重新排序.原子载荷.即如果它们在源代码中的原子加载之后,它们将在原子加载之后发生.
acquire - 没有负载可以重新排序.原子载荷.即如果它们在源代码中的原子加载之后,它们将在原子加载之后发生.
release - 没有商店可以重新订购.原子商店.即如果它们在源代码中的原子库之前,它们也将在原子库之前发生.
acq_rel- acquire并release结合起来.
seq_cst - 更难理解为什么需要这种排序.基本上,所有其他排序仅确保仅对于使用/释放相同原子变量的线程不会发生特定的不允许重新排序.内存访问仍然可以按任何顺序传播到其他线程.这种排序可确保不会发生这种情况(因此顺序一致性).对于需要这种情况的情况,请参阅链接页面末尾的示例.
Hol*_*Cat 26
我想提供一个更精确、更接近标准的解释。
需要忽略的事情:
memory_order_consume- 显然没有主要的编译器实现它,并且他们悄悄地用更强大的memory_order_acquire. 甚至标准本身也说要避免它。
有关内存顺序的 cppreference 文章的很大一部分涉及“消耗”,因此删除它可以使事情简化很多。
它还可以让您忽略相关功能,例如[[carries_dependency]]和std::kill_dependency。
数据竞争:从一个线程写入非原子变量,并同时从不同线程读取/写入该变量称为数据竞争,并导致未定义的行为。
“同时”一词是一种简化。正式来说,这与时间无关(代码运行的速度对此 UB 没有影响)。我将在下面解释更多。
memory_order_relaxed是最弱且据说是最快的记忆顺序。
对原子的任何读/写都不会导致数据争用(以及后续的 UB)。relaxed为单个变量提供了这种最低限度的保证。它不为其他变量(原子或非原子)提供任何保证。
所有线程都同意每个特定原子变量的操作顺序。但这仅适用于个体变量。如果涉及其他变量(原子的或非原子的),线程可能会对不同变量上的操作的交错方式存在分歧。
就好像宽松的操作在具有轻微不可预测延迟的线程之间传播。
这意味着您无法使用宽松的原子操作来判断何时可以安全地访问其他非原子内存(无法同步对其的访问)。
我所说的“线程同意顺序”是指:
a.store(1, relaxed); a.store(2, relaxed);将写1,然后2,绝不会以相反的顺序。但是对同一线程中不同变量的访问仍然可以相对于彼此重新排序。示例用途:任何不尝试使用原子变量来同步对非原子数据的访问的事物:各种计数器(仅出于信息目的而存在),或用于指示其他线程停止的“停止标志”。另一个例子:对shared_ptrs 进行的操作在内部增加引用计数,使用relaxed.
栅栏: atomic_thread_fence(relaxed);什么都不做。
memory_order_release,memory_order_acquire做所有relaxed事情,以及更多(所以它应该更慢或相当)。
只有存储(写入)才能使用release. 只有加载(读取)才能使用acquire. 读-修改-写操作可以fetch_add是两者(memory_order_acq_rel),但也可以是只是release或只是acquire。
这些可以让你同步线程:
假设线程 1 读取/写入某个内存 M(任何非原子或原子变量都无关紧要)。
然后线程 1 对变量 A 执行释放存储。然后它停止接触内存 M。
如果线程 2 随后执行同一变量 A的获取加载,则线程 1 中的存储被认为与线程 2 中的此加载同步。
现在,线程 2 可以安全地读/写该内存 M(不会引发数据争用 UB,否则会出现这种情况)。
您仅与最新的写入器同步,而不与之前的写入器同步。
您可以跨多个线程进行链式同步。
有一个特殊的规则,即同步可以在任意数量的读取-修改-写入操作之间传播,无论其内存顺序如何。例如,如果线程 1 执行a.store(1, release);,那么线程 2 执行a.fetch_add(2, relaxed);,然后线程 3 执行a.load(acquire),那么线程 1 成功与线程 3 同步,即使中间有一个宽松的操作。
在上述规则中,释放操作 X 以及对同一变量的任何后续读-修改-写操作(在下一个非读-修改-写操作处停止)称为以X 为首的释放序列。(因此,如果从释放序列中的任何操作获取读取,它与序列的头部同步。)
如果涉及读-修改-写操作,没有什么可以阻止您同步多个操作。在上面的示例中,如果fetch_add使用acquireor acq_rel,它也会与线程 1 同步,相反,如果使用releaseor acq_rel,线程 3 除了 1 之外还会与 2 同步。
使用示例: shared_ptr使用类似 的方法减少其引用计数器fetch_sub(1, acq_rel)。
原因如下:假设线程 1 读取/写入*ptr,然后销毁其副本ptr,从而减少引用计数。然后线程 2 销毁最后剩余的指针,同时减少引用计数,然后运行析构函数。
由于线程 2 中的析构函数将访问线程 1 先前访问过的内存,因此必须进行acq_rel同步。fetch_sub否则你就会遇到数据竞争和 UB。
Fences:使用atomic_thread_fence,您基本上可以将宽松的原子操作转变为释放/获取操作。单个栅栏可以应用于多个操作,和/或可以有条件地执行。
如果您从一个或多个变量执行轻松读取(或以任何其他顺序),然后atomic_thread_fence(acquire)在同一线程中执行,则所有这些读取都算作获取操作。
相反,如果您这样做atomic_thread_fence(release),然后进行任意数量的(可能宽松的)写入,则这些写入将被视为释放操作。
栅栏结合了和栅栏acq_rel的效果。acquirerelease
当然,您无法从它和受影响的原子操作之间的栅栏中受益(在宽松的读取之后但在获取栅栏之前;或者相反,在释放栅栏之后但在宽松的写入之前)。如果没有这条规则,我们就会进行时间旅行:比如说,您可以在线程开始时调用释放栅栏,在线程末尾调用获取栅栏,从而祝福其间的所有操作,但这没有任何意义。
与其他标准库功能的相似之处:
几个标准库功能也导致类似的同步关系。例如,锁定互斥体与最新的解锁同步,就好像锁定是获取操作,而解锁是释放操作。
memory_order_seq_cst做所有事情acquire/release做,甚至更多。这被认为是最慢的命令,但也是最安全的。
seq_cst读取算作获取操作。seq_cst将 count 写入释放操作。seq_cst读-修改-写操作算作两者。
seq_cst操作可以彼此同步,并且可以与获取/释放操作同步。注意混合它们的特殊效果(见下文)。
seq_cst是默认顺序,例如给定atomic_int x;,x = 1;执行x.store(1, seq_cst);。
seq_cst与获取/释放相比,有一个额外的属性:所有线程都同意所有seq_cst操作发生的顺序。这与较弱的顺序不同,在较弱的顺序中,线程仅同意每个原子变量的操作顺序,而不同意操作的交错方式 - 请参阅relaxed上面的顺序。
这种全局操作顺序的存在似乎只影响您可以从seq_cstload获取哪些值,它不会以任何方式影响非原子变量和具有较弱顺序的原子操作(除非 seq_cst涉及栅栏,请参见下文),并且它本身不会影响与 acq/rel 操作相比,不会阻止任何额外的数据争用 UB。
除其他事项外,此顺序尊重上面为获取/释放描述的同步关系,除非(这很奇怪)同步来自混合seq-cst 操作与获取/释放操作(释放与 seq-cst 同步,或seq-cst 与 acquire 同步)。这种混合本质上将受影响的 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)) {...}
Run Code Online (Sandbox Code Playgroud)
假设您只希望一个线程能够进入主体if。seq_cst允许你这样做。在这里,获取/释放或更弱的订单是不够的。
栅栏: atomic_thread_fence(seq_cst);具有栅栏的所有功能acq_rel,甚至更多。
正如您所期望的,它们为使用较弱顺序完成的原子操作带来了一些 seq-cst 属性。
所有线程都同意seq_cst栅栏相对于彼此和任何seq_cst操作的顺序(即seq_cst栅栏参与全局操作顺序seq_cst,如上所述)。
它们本质上防止原子操作在自身之间重新排序。
例如我们可以将上面的例子改写为:
atomic_bool x = true;
atomic_bool y = true;
// Thread 1:
x.store(false, relaxed);
atomic_thread_fence(seq_cst);
if (y.load(relaxed)) {...}
// Thread 2:
y.store(false, relaxed);
atomic_thread_fence(seq_cst);
if (x.load(relaxed)) {...}
Run Code Online (Sandbox Code Playgroud)
两个线程不能if同时进入,因为这需要将跨越栅栏的负载重新排序到存储之前。
但从形式上来说,该标准并没有从重新排序的角度来描述它们。相反,它只是解释了如何seq_cst在全局操作顺序中放置栅栏seq_cst。比方说:
线程 1 使用 usingseq_cst顺序或前面有seq_cst栅栏的较弱顺序对原子变量 X 执行操作 A。
然后:
seq_cst线程 2 使用顺序或后跟seq_cst栅栏的较弱顺序对相同的原子变量 X 执行操作 B。
(这里 A 和 B 是任何操作,除非它们不能同时被读取,因为这样就不可能确定哪个是第一个。)
然后,第一个seq_cst操作/栅栏被排序在第二个seq_cst操作/栅栏之前。
然后,如果您想象一个场景(例如在上面的示例中,两个线程都进入if)对订单施加了矛盾的要求,那么这种场景是不可能的。
例如,在上面的示例中,如果第一个线程进入if,则第一个栅栏必须排在第二个栅栏之前。反之亦然。这意味着两个线程进入if都会导致矛盾,因此是不允许的。
不同内存顺序之间的互操作
总结以上内容:
relaxed写 |
release写 |
seq-cst写 |
|
|---|---|---|---|
relaxed加载 |
- | - | - |
acquire加载 |
- | 与同步 | 与*同步 |
seq-cst加载 |
- | 与*同步 | 与同步 |
* = 参与的 seq-cst 操作的 seq-cst 顺序混乱,实际上被降级为获取/释放操作。这已在上面解释过。
使用更强的内存顺序是否会使线程之间的数据传输更快?
无数据竞争程序的顺序一致性
该标准解释说,如果您的程序仅使用seq_cst访问(和互斥),并且没有数据竞争(导致 UB),那么您不需要考虑所有花哨的操作重新排序。该程序的行为就好像一次只执行一个线程,并且线程不可预测地交错。
“以前发生过”
在谈论内存顺序时,经常会出现“发生在之前”这个短语。它具有您应该知道的特定含义。
例如,在上面解释数据竞争时,我说它们发生在“同时”操作中。但该标准没有使用这个词,而是说“如果两个操作都不在另一个操作之前发生”。
A 在 B 之前发生,如果:
该标准实际上定义了一系列不同的变体:“简单发生在之前”、“强烈发生在之前”和“只是发生在之前”,每个都有自己模糊的定义(上面的定义是“简单发生在之前”)。但是,如果您不使用consume操作并且不将 acq/rel 混合使用seq_cst(如上所述),那么它们之间没有任何区别,并且您可以对所有三个使用此定义。
还有“线程间发生在之前”,它仅用于描述consume,其他情况下不需要。
| 归档时间: |
|
| 查看次数: |
19117 次 |
| 最近记录: |