在x86架构上,布尔读写操作是否可能不是原子操作?

37
假设我们有两个线程,一个在循环中读取bool值,另一个可以在特定时间切换它。我个人认为这应该是原子性的,因为在C++中sizeof(bool)是1字节,而且您不会部分读/写字节,但我想要100%确定。
所以是还是否?
编辑:
未来参考,对于int是否适用相同的情况?

1
底层架构字长小于一个字长大小,不是既原子化不够高效吗? - Cris Stringfellow
阅读Intel软件开发者手册,它确切地指定了什么情况下哪种写入/读取是原子的(例如,如果正确对齐,即使是对64位的写入也是原子的)。请注意,如果您的类型不占用所有位,也就是说,如果您的布尔值是位字段的一部分,情况将会发生变化。 - PlasmaHH
7
顺便说一下,我不知道标准中是否有规定强制要求sizeof(bool)的大小。 - Lightness Races in Orbit
2
@LightnessRacesinOrbit:甚至在5.3.3中也有一个关于它们如何被实现定义的备注。 - PlasmaHH
5
是的,sizeof(bool) 是实现定义的。我曾在一些架构上工作过,其中 sizeof(bool) == 4。 - Brian Neal
显示剩余5条评论
3个回答

80

"原子"类型在C++11中解决了三个独立的问题:

  1. 撕裂:读或写涉及多个总线周期,并且在线程操作过程中发生了切换。这可能会产生不正确的值。

  2. 缓存一致性:来自一个线程的写入更新其处理器高速缓存,但不更新全局内存;另一个线程的读取从全局内存读取,并且无法看到其他处理器高速缓存中的更新值。

  3. 编译器优化:编译器根据假设对读取和写入的顺序进行重排,认为不会从另一个线程访问值,导致混乱。

使用std::atomic<bool>确保正确管理这三个问题。不使用std::atomic<bool>会导致代码不可移植。


在运行时,难道不也有CPU指令(或内存访问)重排序吗?编译器可能会重新排列加载和存储,但CPU也可以这样做。 - Roman Kruglov
@RomanKruglov:在x86上,只有StoreLoad重排序是可能的(https://preshing.com/20120515/memory-reordering-caught-in-the-act/),因此只有seq-cst存储需要超出阻止编译时重排序的额外排序。(例如`mov`+`mfence`,或更好的`xchg`来实现seq-cst存储。)一般来说,在其他ISA上,如果不使用`mo_relaxed`,是的,加载、存储和RMW可能需要额外的屏障。 - Peter Cordes
2
参见程序员对CPU缓存的错误认识,关于手动一致性。C++是基于“一致性”共享内存的假设设计的,因此您只需要确保存储或加载实际发生在汇编中,而不是将值保留在寄存器中。在具有非一致性共享内存的假想机器上,每个同步都必须刷新所有内容(或需要大量跟踪),但我不知道是否有任何用于标准线程的非一致性共享内存的C++实现。 - Peter Cordes
我同意你的结论:使用至少memory_order_relaxed,如果不是默认的seq_cst,则使用atomic<bool>。但是你的一些推理并不成立。第二点非常误导,因为没有真正的CPU是这样的。 - Peter Cordes
1
@user179156:我认为第一点是回答了std::atomic<T>的一般情况,而不仅仅是像问题所问的那样T=bool。在某些ISA上,编译器可能有理由将变量存储为两个部分(在GNU C和GNU C++中,64位计算机上哪些类型是自然原子的?-意思是它们具有原子读取和原子写入有一个AArch64的例子,使用stp来存储一个两半相同的常量)。 - undefined
显示剩余6条评论

19

一切取决于你对“原子”这个词的实际理解。

你是指“最终值将一次性更新”(是的,在x86上,字节值肯定保证这样 - 至少可以正确对齐64位的任何值),还是“如果我将此设置为true(或false),在我设置后,没有其他线程会读取不同的值”(这并不像那么确定 - 你需要一个“锁”前缀来保证这一点)。


如果我将其设置为true(或false),在我设置后,没有其他线程会读取不同的值。我认为问题非常清楚。后一种解释与原子性无关。 - jberryman
2
@jberryman:问题出在缓存以及编译器优化内存读取上。某个线程中的 b=false; 并不保证所有其他线程在下一次使用 if (b) ... 时都能发现 b 是 false。这要求编译器没有把对 b 的访问优化为 tmp=b; ... if(tmp) ...(其中 tmp 是一个寄存器)。根据线程内部代码的不同,有些情况下编译器确实会这样做。 - Mats Petersson
设置完成后,没有其他线程会读取不同的值 - mfencelock前缀仅需要用于确定“之后”的含义。在所有x86系统上,内存是一致的,因此在存储指令最终提交到L1d缓存后,没有其他线程可以读取旧值。您只需要屏障来实现seq-cst存储,并确保在全局可见之前,线程不进行任何其他加载。它肯定很快就会变得全局可见。[我可以强制多核x86 CPU上的高速缓存一致性吗?] (//stackoverflow.com/a/558888) - Peter Cordes
简而言之:屏障并不会显式地刷新或写回缓存,它只会阻塞该线程,直到该值从存储缓冲区提交到该核心的L1d缓存(从而变得全局可见)。 - Peter Cordes
如果我将其设置为true(或false),在我设置之后,没有其他线程会读取不同的值(这并不完全是确定的 - 您需要“lock”前缀来保证)。bool可以有任何硬件实现,另一个线程可以读取任何状态,甚至可能是“部分”状态,但随后阅读的值被解释为* true false 。因此不可能有任何“不同的值”。从这个意义上说,bool的读取始终是“原子性”的 - 我们总是得到 true false *,而永远不会得到其他内容。仅在rmw操作或我们需要这个布尔值和其他内存之间的顺序时才需要锁定。 - RbMm

6

x86只保证按字大小进行字对齐的读写操作。除非明确声明原子性,否则不保证任何其他操作。当然,你还需要说服编译器首先发出相关的读写操作。


x86保证高速缓存一致性吗? - choxsword
@bigxiao:是的,每个普通的SMP系统无论ISA如何都保证缓存一致性,并使用MESI(或某些变体)来实现它。atomic<T>的一部分作用是防止编译器将值保存在寄存器中而不是内存中,因为寄存器是线程私有的。但是内存始终是一致的。只有在您想要在加载和存储之间进行排序时才需要屏障,例如使当前线程等待直到存储可见后再执行后续读取。使存储全局可见始终尽可能快地发生,而不受屏障的影响。(从存储缓冲区提交到L1d) - Peter Cordes
x86保证了更多的内容,例如字节的加载/存储始终是原子性的,并且不跨越4字节边界的16位加载/存储也是原子性的。并且双字(32位)对齐的加载/存储是原子性的。此外,在现代x86(AMD和Intel P6及更高版本)上,任何宽度的缓存加载/存储只要不跨越8字节边界就是原子性的。为什么在x86上对自然对齐变量进行整数赋值是原子性的? 因此,在x86上,所有std::atomic<>只需确保纯负载/纯存储的值自然对齐,并且不会被优化掉。 - Peter Cordes

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