什么时候我真正需要使用atomic<bool>而不是bool?

137

atomic<bool>是不是多余的,因为bool本身就是原子性的?我认为部分修改布尔值是不可能的。什么时候我才真正需要使用atomic<bool>而不是bool呢?


9
为了避免竞争条件,您需要使用 atomic<bool>。当两个线程访问同一内存位置时,并且其中至少一个是写操作时,就会发生竞争条件。如果您的程序包含竞争条件,则行为未定义。 - nosid
21
@nosid说的是,OP的意思是他不相信你可以对布尔值进行部分写操作,就像你可以对int值进行逐个字节或字操作一样。因此,如果写入已经是原子性的,就不应该存在任何竞争条件。 - Robert Harvey
1
相关:https://dev59.com/W2445IYBdhLWcg3wDV8P - Paul R
20
没有原子操作,就不能保证您能在其他线程中看到更新,也不能保证您以不同的顺序进行变量更新时,在另一个线程中以相同的顺序看到它们。 - jcoder
1
@jcoder: 要非常严谨的说,我相信标准并没有要求缓存一致性(或者更确切地说是“可见性传播”),这被留作了“实施质量最佳努力”。也就是说,你可以使用原子变量来同步两个线程,但在标准中没有保证变化会传播。只有如果变化传播了,才会建立happens-before关系。(例如,线程A可能存储为“未锁定”,但线程B可能永远继续读取“已锁定”直到如果它读取到“未锁定”才能安全地继续执行。) - Kerrek SB
显示剩余5条评论
6个回答

127

是所有C++类型都天生"原子的",除非它是std::atomic*-something。这是因为标准规定如此。

实际上,在操作std::atomic<bool>时发出的硬件指令可能(也可能不会)与普通bool相同,但是原子性是一个更大的概念,具有更广泛的影响(例如,限制编译器重新排序)。此外,一些操作(如否定)被重载到原子操作上,以创建与非原子变量的本机非原子读取-修改-写入序列截然不同的指令。


14
小修正,std::atomic_flag是唯一的例外,尽管它的名称也以atomic开头。 - yngccc
8
我认为这就是Kerrek SB写std::atomic*而不是std::atomic<*>的原因。 - Sebastian Mach
这个 std::atomic* 是否包含 std::atomic<*>? - Nusrat Nuriyev

99

请记住 内存屏障(memory barriers)。虽然可能无法部分更改 bool,但可能会在多处理器系统中有多个副本并且一个线程即使在另一个线程将其更改为新值后仍可以看到旧值。原子操作引入内存屏障,因此这是不可能的。


4
关键字 volatile 能够解决多处理器问题吗? - Vincent Xue
11
不。Volatile与内存屏障无关。 - unexpectedvalue
18
为了更清楚明确,@Vincent的评论可能源自对Java中volatile关键字的理解。Java中的volatile关键字确实控制内存屏障,但其行为与C中的volatile关键字有很大不同,后者没有控制内存屏障的作用。此问题在这篇回答中有进一步的解释。 - Pace
是的,事实证明std::atomic<T>所做的不仅仅是它表面上看起来的那样。当然了。 - nmr
5
我认为这是真正正确的答案。因为关于“标准规定 bla-bla-bla … sizeof(bool) 可能 > 1”的回答在现实生活中从未发生过。所有主要编译器的sizeof(bool)==1,并且所有读写操作对于bool和atomic<bool>都会以类似的方式工作。但多核CPU和丢失的内存栅栏是几乎任何现代应用程序和硬件都会发生的事情,几乎有100%的概率。 - Ezh
显示剩余6条评论

38

C++的原子类型处理三个潜在的问题。首先,如果操作需要多个总线操作(并且这可能发生在bool上,具体取决于它的实现方式),则读取或写入可能会被任务切换中断。其次,读取或写入可能仅会影响执行该操作的处理器关联的缓存,而其他处理器的缓存中可能有不同的值。第三,如果操作不影响结果,则编译器可以重新排列操作的顺序(约束条件有些复杂,但现在足够了)。

您可以通过对使用的类型如何实现进行假设、显式刷新缓存以及使用特定于编译器的选项来防止重排序(不,除非您的编译器文档说它会这样做,否则volatile不能做到这一点)来解决这三个问题。

但为什么要这样做呢?atomic已经为您处理了这些问题,并且可能比您自己做得更好。


任务切换不会导致撕裂,除非存储变量需要多个指令。整个指令在单个核上对中断是原子的(它们要么在中断之前完全完成,要么任何部分工作都被丢弃。这是存储缓冲区的一部分)。在实际同时运行的单独核上的线程之间,撕裂更有可能发生,因为此时您可以在一个指令执行的存储的部分之间获得撕裂,例如,未对齐的存储或总线过宽的存储。 - Peter Cordes
1
@PeterCordes — 我没有智慧来断言每个可能的硬件架构上运行 C++ 代码的行为。也许你有,但这并不意味着你应该重新开启一个六年前的帖子。 - Pete Becker
我在评论中简化了对普通机器的讨论:当然,在需要显式刷新以保证一致性的机器上可以有C++实现,但是只有在具备一致性内存时,C++内存模型和释放存储的概念才是高效的。否则,每个释放存储或seq-cst存储都必须刷新所有内容,除非采用巧妙的as-if优化。所有主流SMP系统都是缓存一致的。存在非一致性的大型集群,其中具有共享内存,但它们将其用于消息传递而不是运行单个程序的线程。 - Peter Cordes
1
在需要显式一致性的机器上实现高效的C++代码听起来不太可能,因此当将值保留在寄存器中会通过所有真实CPU上存在的机制产生与您所讨论的相同问题时,这是一个奇怪的问题。令我困扰的是,这个答案并没有帮助澄清我们使用的真实系统中关于缓存一致性的常见误解。许多人认为,在x86或ARM上需要显式刷新某种类型的缓存,并且从缓存中读取过期数据是可能的。 - Peter Cordes
1
如果C++标准真的关心在运行多个线程的非一致共享内存上的效率,那么就会有像release-stores这样的机制,只会使某个数组或其他对象全局可见,而不是在此之前(包括所有非原子操作)每个其他操作。在一致系统中,release stores只需等待先前正在进行的加载/存储完成并提交,而不是将任何私有缓存的整个内容写回。对我们脏私有缓存的访问是按需发生的。 - Peter Cordes
显示剩余16条评论

34

考虑一个比较和交换的操作:

bool a = ...;
bool b = ...;

if (a)
    swap(a,b);

当我们读取a时,得到true,另一个线程可以设置a为false,然后我们交换(a,b),所以退出时b为false,尽管进行了交换。

使用std::atomic ::compare_exchange,我们可以将整个if/swap逻辑 原子化,这样其他线程就不能在if和swap之间将a设置为false(无需锁定)。在这种情况下,如果进行了交换,则退出时b必须为false。

这只是一个适用于布尔类型等两个值类型的原子操作示例。


2
为什么这是最低评级的答案?这(或std::atomic_flag中的test_and_set)是使用原子布尔类型的主要原因。 - Szocske

21

原子操作不仅仅涉及到破碎的值,所以虽然我同意您和其他帖子发布者的观点,即我不知道 torn bool 可能存在的环境,但问题的关键在于更多方面。

Herb Sutter 在网上做了一次很棒的演讲,你可以观看它。请注意,这是一个漫长而又复杂的讲话。 Herb Sutter, 原子武器。问题归结为避免数据竞争,因为它使您具有顺序一致性的幻象。


11

某些类型的原子性完全依赖于底层硬件。每个处理器架构都对某些操作的原子性有不同的保证。例如:

Intel486处理器(以及更高版本的处理器)保证始终会原子地执行以下基本内存操作:

  • 读取或写入一个字节
  • 读取或写入一个按16位边界对齐的字
  • 读取或写入一个按32位边界对齐的双字

其他体系结构对哪些操作是原子操作具有不同的规定。

C++是一种高级编程语言,旨在使您抽象出底层硬件。因此,标准不能允许您依赖这些低级别的假设,否则您的应用程序将不可移植。因此,C ++11兼容的标准库通过提供 atomic 的替代方案为C++中的所有基本类型提供了开箱即用的支持。


3
另一个关键部分是C++编译器通常可以将变量保留在寄存器中或者优化掉访问,因为它们可以假设没有其他线程改变这个值(由于数据竞争UB)。atomic有点包含了volatile的这个属性,所以 while(!var){} 不会被优化成 if(!var) infinite_loop();。详见MCU programming - C++ O2 optimization breaks while loop - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接