为什么在x86上对自然对齐的变量进行整数赋值是原子性的?

60

我一直在阅读关于原子操作的文章,这篇文章提到在x86架构上32位整数赋值是原子性的,只要变量自然对齐。

为什么自然对齐可以确保原子性?


13
这个问题是第一个结果 :-) - Tadeusz Kopec for Ukraine
1
这个复制品没有解释为什么它保证了原子性。 - timlyo
6
@user3528438: 那不是特别有帮助。 - Bathsheba
2
回答这个问题的整个答案是“因为CPU文档如此规定”。何必让它变得更加复杂呢? - Art
1
为什么这个问题被标记为C或C++?它不是关于特定的CPU操作吗? - David Schwartz
显示剩余6条评论
5个回答

72

“自然”对齐意味着按照其自身类型的宽度进行对齐。因此,加载/存储永远不会跨越任何比它本身更宽的边界(例如页面、缓存行或用于不同缓存之间数据传输的甚至更窄的块大小)。

CPU通常以2的幂次大小的块进行缓存访问或缓存行转移,因此小于缓存行的对齐边界确实很重要。(请参见下面@BeeOnRope的评论)。有关CPU如何在内部实现原子加载或存储的更多详细信息,请参见 x86上的原子性,有关像atomic<int>::fetch_add() / lock xadd这样的原子RMW操作的实现方式,请参见对于“int num”,num++可以是原子的吗?


首先,这假定使用单个存储指令更新了int,而不是分别写入不同的字节。这是std::atomic所保证的一部分,但普通的C或C++没有保证。虽然通常情况下会是这样,但x86-64 System V ABI并不禁止编译器使对int变量的访问非原子化,尽管它要求int为4B,并具有默认对齐方式为4B。例如,如果编译器希望,x = a<<16 | b可以编译成两个独立的16位存储。
数据竞争在C和C++中都是未定义行为,因此编译器可以并且确实假定内存不会被异步修改。 对于保证不会出问题的代码,请使用C11 stdatomic或C++11 std::atomic。否则,编译器将仅仅将值保存在寄存器中而非每次读取时重新加载, 就像volatile一样,但具有实际保证,并得到了语言标准的官方支持。 在C++11之前,原子操作通常使用volatile或其他方式完成,并且需要考虑“适用于我们关心的编译器”,因此C++11是一个巨大的进步。现在,您不再需要关心编译器对普通int的操作;只需使用atomic<int>即可。如果您发现旧的指南讨论了int的原子性,则它们可能是C++11之前的版本。When to use volatile with multi threading?解释了为什么这在实践中有效,并且memory_order_relaxedatomic<T>是获得相同功能的现代方法。
std::atomic<int> shared;  // shared variable (compiler ensures alignment)

int x;           // local variable (compiler can keep it in a register)
x = shared.load(std::memory_order_relaxed);
shared.store(x, std::memory_order_relaxed);
// shared = x;  // don't do that unless you actually need seq_cst, because MFENCE or XCHG is much slower than a simple store

注:对于大于CPU可原子操作的atomic<T>(因此.is_lock_free()为false),请参见std::atomic的锁在哪里?。然而,在所有主要的x86编译器上,intint64_t/uint64_t都是无锁的。


因此,我们只需要讨论像mov [shared], eax这样的指令的行为。
TL;DR:x86 ISA保证了自然对齐的存储和加载是原子性的,最多可达64位。因此,只要编译器确保std::atomic<T>具有自然对齐,它就可以使用普通的存储/加载。
(但请注意,i386 gcc -m32无法为结构体内的C11 _Atomic 64位类型执行此操作,仅将其对齐到4B,因此在某些情况下atomic_llong可能不是原子性的。https://gcc.gnu.org/bugzilla/show_bug.cgi?id=65146#c4)。使用std::atomicg++ -m32是可以的,至少在g++5中,因为https://gcc.gnu.org/bugzilla/show_bug.cgi?id=65147通过对<atomic>头文件进行更改于2015年修复了该问题。但这并没有改变C11的行为。)
据我所知,有SMP 386系统,但当前的内存语义是在486之后确立的。这就是为什么手册上说“486及更高版本”。

来自“Intel® 64和IA-32体系结构软件开发人员手册,第3卷”,附有我的斜体注释。(另请参见标签wiki获取所有卷的当前版本链接,或直接链接到2015年12月vol3 pdf的第256页

在x86术语中,“字”是两个8位字节。32位是双字,或DWORD。

###第8.1.1节保证原子操作

英特尔486处理器(以及之后的处理器)保证始终以原子方式执行以下基本内存操作:
- 读取或写入一个字节 - 读取或写入在16位边界上对齐的字 - 读取或写入在32位边界上对齐的双字(这是说“自然对齐”的另一种方式)
我加粗的最后一点是你问题的答案:这种行为是x86 CPU(即ISA的实现)所必需的一部分。
本节的其余部分为新一代英特尔CPU提供了更多保证:Pentium将此保证扩展到64位
Pentium处理器(以及后来的处理器)保证始终原子地执行以下附加内存操作:
- 对齐在64位边界上的四字节读取或写入(例如x87加载/存储双精度或cmpxchg8b(在Pentium P5中是新的)) - 适合32位数据总线的未缓存内存位置的16位访问。
该部分继续指出,跨缓存行(和页面边界)的访问不能保证是原子性的,并且:
- “访问大于四字节的数据的x87指令或SSE指令可能使用多个内存访问。”

AMD 的手册与 Intel 一致,认为对齐的 64 位及更窄的加载 / 存储是原子的

因此,整数、x87 和 MMX / SSE 加载 / 存储最多可达 64b,即使在 32 位或 16 位模式下(例如 movqmovsdmovhpspinsrqextractps 等),如果数据对齐,则 原子性的。 gcc -m32 使用 movq xmm, [mem] 来实现像 std::atomic<int64_t> 这样的原子 64 位加载。 Clang4.0 -m32 不幸地使用 lock cmpxchg8b bug 33109

在一些具有128b或256b内部数据路径(在执行单元和L1之间以及不同高速缓存之间)的CPU上,128b甚至256b向量加载/存储是原子性的,但这并不受任何标准保证,在运行时也很难查询,这对于实现std::atomic<__int128>或16B结构的编译器来说是不幸的。更新:x86供应商已经决定AVX特性位也指示了128位对齐加载/存储的原子性。在此之前,我们只有实验性测试来验证它。
如果您想在所有x86系统上使用原子128b,则必须使用lock cmpxchg16b(仅在64位模式下可用)。 (它在第一代x86-64 CPU中不可用。您需要与GCC / Clang一起使用-mcx16 以便它们发出。)
即使内部执行原子128b加载/存储的CPU,在具有操作小块的相干协议的多插座系统中也可能表现出非原子行为:例如,使用HyperTransport连接的运行在线程不同插座上的AMD Opteron 2435(K10)

Intel和AMD的手册在访问不对齐的可缓存内存时有所不同。所有x86 CPU的共同子集是AMD规则。可缓存意味着写回或写透传内存区域,而不是通过PAT或MTRR区域设置为不可缓存或写组合。它们并不意味着缓存行必须已经在L1高速缓存中。

  • Intel P6及其后续版本保证了高达64位的可缓存加载/存储的原子性,只要它们在单个缓存行(64B,或者在像Pentium III这样的非常古老的CPU上为32B)内。
  • AMD保证可缓存的加载/存储的原子性适合于单个8B对齐块。这是有道理的,因为我们从多插槽Opteron上的16B存储器测试中知道,HyperTransport仅以8B块传输,并且在传输时不锁定以防止撕裂。(见上文)。我猜lock cmpxchg16b必须特殊处理。
可能相关:AMD使用MOESI直接在不同核心的缓存之间共享脏缓存行,因此一个核心可以从其有效的缓存行副本中读取,而更新正在来自另一个缓存。
英特尔使用MESIF,它需要将脏数据传播到作为一致性流量后备的大型共享包容性L3缓存中。即使对于必须处于L3的无效状态的线路,也是在每个核心L2 / L1缓存中具有标记包含的。 Haswell / Skylake的L3与每个核心缓存之间的数据路径仅为32B,因此必须进行缓冲或其他操作,以避免从一个核心的写入L3发生在读取两个缓存行半部分之间,这可能会导致在32B边界处撕裂。
手册的相关部分:

P6系列处理器以及新的英特尔处理器保证以下额外的内存操作始终是原子性的:

  • 在缓存内存中不对齐的16位、32位和64位访问,且适合于一个高速缓存行。

AMD64手册7.3.2 访问原子性
任何处理器型号上,可缓存、自然对齐的单个读取或存储量到四个字节都是原子的。对于小于四个字节的不自然对齐的读取或存储,只要完全包含在自然对齐的四字节中,则同样也是原子的。

请注意,AMD保证任何小于四字节的读取均是原子性的,但是Intel仅保证2次幂大小的读取是原子性的。在32位保护模式和64位长模式下,可以将48位的m16:32作为内存操作数装入cs:eip中进行far-call或远跳转。 (Far-call会向栈中推送内容。)我不知道这是否算作单个的48位访问,还是分别访问16位和32位。

有人试图将x86内存模型形式化,最新的一篇是来自2009年的x86-TSO(扩展版)论文(来自标签维基的内存排序部分的链接)。由于他们定义了一些符号来表达自己的记号,所以它不太容易浏览,我也没有尝试真正阅读它。我不知道它是否描述了原子性规则,还是仅关注内存排序


原子读-改-写

我提到了cmpxchg8b,但我只是在讨论单独的加载和存储各自是原子的(即没有“撕裂”,其中一半的加载来自一个存储器,另一半的加载来自不同的存储器)。

为了防止在加载和存储之间修改该内存位置的内容,您需要使用lockcmpxchg8b,就像您需要使用lock inc [mem]一样,以使整个读-改-写过程成为原子操作。此外,请注意,即使cmpxchg8b没有lock执行单个原子加载(和可选存储),通常也不能将其用作带有期望=所需的64位加载。如果内存中的值恰好与您的期望匹配,则会对该位置进行非原子读-改-写。

lock 前缀使得即使是跨越缓存行或页面边界的未对齐访问也是原子性的,但你不能使用它与 mov 一起使未对齐的存储或加载变为原子性。它只能用于内存目标读-修改-写指令,如 add [mem], eax
lockxchg reg, [mem] 中是隐含的,因此除非性能无关紧要,否则不要使用 xchg 来保存代码大小或指令计数。只有在需要内存屏障和/或原子交换时,或者当代码大小是唯一重要的事情时,例如在引导扇区中,才使用它。)
另请参见:对于 'int num',num++ 是否可以是原子的?

为什么原子非对齐存储不支持lock mov [mem], reg

根据指令参考手册(Intel x86 manual vol2)中的cmpxchg

该指令可使用LOCK前缀使其具有原子性。为简化处理器总线的接口,目标操作数将接收一个写周期,而不考虑比较结果。如果比较失败,则将目标操作数写回;否则,将源操作数写入目标操作数。(处理器永远不会产生仅有锁定读取而没有锁定写入的情况。)

这种设计决策减少了芯片组复杂度,在内存控制器集成到CPU之前很有用。在命中PCI-express总线而不是DRAM的MMIO区域上进行locked指令仍然可能如此。但是对于lock mov reg,[MMIO_PORT]来说,它既产生读取又产生写入,这将会令人困惑。

另一种解释是,确保数据具有自然对齐并不难,与仅确保数据对齐相比,“lock store” 的性能表现非常糟糕。在某些情况下,为了避免使用不值得的缓存开销,将晶体管用于此类操作是愚蠢的。如果您真的需要它(并且不介意读取内存),可以使用“xchg [mem],reg”(XCHG 具有隐式 LOCK 前缀),这甚至比假设的“lock mov”更慢。
使用“lock”前缀还会产生完全的内存屏障,因此除了原子 RMW 之外,还会带来性能开销。即 x86 无法进行松散的原子 RMW(而无需刷新存储器缓冲区)。其他 ISA 可以,因此在非 x86 上使用“.fetch_add(1, memory_order_relaxed)” 可能更快。
有趣的事实:在mfence出现之前,一种常见的习惯用法是lock add dword [esp], 0,除了破坏标志并执行锁定操作外,它是一个无操作。 [esp]几乎总是在L1缓存中,不会与任何其他核心产生争用。这种习惯用法可能仍然比MFENCE作为单独的内存屏障更有效,特别是在AMD CPU上。 xchg [mem],reg可能是在Intel和AMD上实现顺序一致性存储的最有效方法,与mov+mfence相比。 mfence 至少在Skylake上阻止非内存指令的乱序执行,但xchg和其他 lock ed操作则不会。除了gcc之外的编译器在进行存储时也使用xchg,即使它们不关心读取旧值。

这个设计决策的动机:

如果没有它,软件将不得不使用1字节锁(或某种可用的原子类型)来保护对32位整数的访问,这与共享原子读访问相比是极其低效的,例如由定时器中断更新的全局时间戳变量。对于总线宽度或更小的对齐访问来说,保证这一点在硅上可能基本免费。

要想实现锁定,需要一些原子访问方式。(实际上,我想硬件可以提供一些完全不同的硬件辅助锁定机制。)对于一个在其外部数据总线上执行32位传输的CPU,使用它作为原子性单位是很有意义的。


由于您提供了赏金,我想您正在寻找一篇长篇回答,其中涉及所有有趣的副题。如果有您认为可以使这个Q&A更有价值的内容没有涵盖到,请告诉我。

既然您在问题中提到了一个链接, 我强烈建议阅读更多Jeff Preshing的博客文章。它们非常优秀,帮助我将我所知道的关于不同硬件架构下C/C++源码与asm中内存排序的各个方面组合起来,以及在不直接编写asm的情况下如何/何时告诉编译器您想要什么。


1
AMD64手册 7.3.2 访问原子性: “每个处理器模型上的可缓存、自然对齐的单个加载或存储,最大可达四个双字节,在任何情况下都是原子的,包含在自然对齐四个双字节内的小于四个双字节的非对齐加载或存储也是原子的。” - bartolo-otrit
1
@bartolo-otrit:嗯,所以AMD对可缓存的加载/存储的原子性要求比Intel更严格?这与套接字之间的HyperTransport以8B为单位传输缓存行相符。我希望Intel或其他人能记录下被称为x86 CPU所需的公共功能子集。 - Peter Cordes
2
你提到了具有原子访问的动机(即,它使CPU作为编程目标更加功能强大),但我认为问题有点暗示了想要理解另一半的动机:仅自然对齐访问是原子的限制背后的动机是什么?也就是说,根据您的答案,该限制在AMD(实际上是“在四个字中”)中仍然存在,并且在过去的英特尔中也存在。一个简单的答案是它简化了硬件设计:超传输的例子是一个很好的例子,但也... - BeeOnRope
1
像缓存这样的东西可能被设计为传递对齐的块,其中一些不对齐的情况涉及读取两个对齐的块并将它们组合(类似于跨缓存行读取)。也许值得在其中加入一两句话,即你在回答中讨论的所有内容大多适用于“对齐”的访问,也许还包括完全包含在早期访问中的访问(尽管“早期”意味着您需要使用排序来复杂化一切)-但不适用于部分重叠的访问,至少不是以简单的方式,因为我们知道... - BeeOnRope
1
@Binarus:中断总是发生在指令之间的边界上,因此在单处理器(或只有信号处理程序或绿色线程的单线程进程)上,除了rep movs或gather load/scatter store之外的每个指令都是原子的,甚至包括fld m80或page-split vmovdqu zmm0, [rdi]。请参见Is x86 CMPXCHG atomic, if so why does it need LOCK?Interrupting instruction in the middle of execution - Peter Cordes
显示剩余5条评论

6

如果32位或更小的对象在“常规”内存部分自然对齐,那么除了80386sx处理器外,任何80386或兼容处理器都可以在单个操作中读取或写入该对象的所有32位。虽然平台能够以快速和有用的方式执行某项任务并不一定意味着该平台不会因为某些原因以其他方式执行该任务,并且虽然我相信在许多x86处理器中甚至所有处理器中都可能存在只能以8位或16位逐位访问的内存区域,但我认为英特尔从未定义请求对“常规”内存区域进行对齐的32位访问会导致系统在读取或写入整个值之前读取或写入部分值的任何条件,并且我认为英特尔没有任何意图为“常规”内存区域定义任何此类条件。


1
我认为没有任何x86内存类型会分割更宽的访问或禁止它们。 "不可缓存"和"写组合"内存区域只是意味着每个N字节的加载指令在缓存层次结构之外产生一个单独的该宽度请求。我忘记了普通存储器是否可以在"写组合"区域中合并,或者它是否仅适用于弱序的movnt存储器。虽然可能有我忘记的晦涩的内存类型。除了常规的写回,还有写穿透。 - Peter Cordes
1
@PeterCordes:英特尔的处理器,至少从80486开始,我认为一直到现在都能够以不同的速度和总线宽度访问RAM,如果这种支持没有被保留,我会感到有些惊讶。在旧的处理器上,当处理器发出16位内存请求时,它会寻找一个信号,表明内存只能支持8位请求。如果处理器连接到一个8位内存系统,硬件将把16位请求视为读取或写入单词的底半部分的请求,但会要求处理器跟进... - supercat
1
... 其中一半的访问是8位的。事实上,内存的一部分连接到8位总线而不是16位总线对于代码来说是看不见的(除了内存访问的执行时间更长之外),但如果硬件无法同时处理超过8位,则处理器无法一步完成操作。我不会惊讶,如果所有英特尔处理器在8位访问之间保持总线(以使32位序列在使用双端口RAM的系统中有效地原子化),但操作必须在硬件层面上拆分。 - supercat
自 Nehalem 以来,英特尔已经将 DRAM 控制器集成到 CPU 中,因此接口固定为 64 位宽度(如果带有 ECC 则为 72 位)。由于没有任何其他东西可以通过 CPU 的内存控制器访问 RAM,所以这个宽度是不可见的。I/O 访问通过前端总线进行,该总线已经经历了各种迭代。因此,您的基本观点仍然成立,我想,即任何假设的窄总线都会在需要进行原子操作的单个访问期间被锁定。是的,486 和可能一些后续的一些世代可能实际上支持这种方式的窄总线。 - Peter Cordes

2
如果你想知道为什么它被设计成这样,我会说这是CPU架构设计中的一个好的附带产品。
回到486时代,没有多核CPU或QPI链接,因此原子性在那个时候并不是一个严格要求(DMA可能需要它?)。
在x86上,数据宽度为32位(或x86_64的64位),意味着CPU可以一次读写高达数据宽度。内存数据总线通常与该数字相同或更宽。再加上读/写对齐地址时可以一次完成,自然没有什么可以阻止读/写变得不原子。您同时获得了速度和原子性的提升。

2
自然对齐是指类型的地址是类型大小的倍数。
例如,一个字节可以在任何地址上,一个 short(假设为16位)必须是2的倍数,一个 int(假设为32位)必须是4的倍数,一个 long(假设为64位)必须是8的倍数。
如果访问的数据不是自然对齐的,则 CPU 将引发故障或读/写内存,但不作为原子操作。CPU 所采取的操作将取决于架构。
例如,我们有以下内存布局:
01234567
...XXXX.

并且

int *data = (int*)3;

当我们尝试读取*data时,组成该值的字节被分散在2个int大小的块中,其中1个字节位于块0-3中,而3个字节位于块4-7中。现在,仅仅因为这些块在逻辑上是相邻的,并不意味着它们在物理上也是相邻的。例如,块0-3可能位于CPU缓存行的末尾,而块3-7则位于页面文件中。当CPU尝试访问块3-7以获取所需的3个字节时,它可能会发现该块不在内存中并发出信号表示需要换入内存。这可能会阻塞调用进程,同时操作系统换入内存。
在内存被换入后,但在您的进程被唤醒之前,另一个进程可能会写入地址4处的Y。然后您的进程重新调度,并且CPU完成读取,但现在它已经读取了XYXX,而不是您预期的XXXX。

3
你的意思是对齐访问是原子操作,这取决于很多未知因素,可能是正确的,也可能不是。 - user3528438
1
@user3528438 - 在x86上,例如,只要内存位置自然对齐,32位读取就是原子性的。 - Sean
4
@FrancisStraccia - 是的,他们不需要对齐。但是,如果它们不是自然对齐的,那么它们就不是原子的,可能会发生数据分裂。在某些架构上,例如Sparc,如果您尝试读取错误对齐的数据,将会出现故障。 - Sean
1
@Sean:我指的是你在回答中提到的“...CPU对齐时,将会引发故障或读/写...”。考虑到OP问的是x86,这可能看起来有点困惑。(在其他架构上,当然也可能会发生故障。) - Francis Straccia
@FrancisStraccia 实际上,你可以在x86上打开对齐检查和硬件异常。 - Antti Haapala -- Слава Україні
显示剩余3条评论

1
回答你的第一个问题,如果变量存在于其大小的倍数的内存地址中,则该变量自然对齐。
如果我们只考虑-如您链接的文章所述-赋值指令,则对齐保证了原子性,因为MOV(赋值指令)在对齐数据上是通过设计实现原子性的。
其他类型的指令,例如INC,即使数据对齐,也需要LOCKed(x86前缀,为当前处理器提供共享内存的独占访问权限,以执行前缀操作)因为它们实际上通过多个步骤(=指令,即load,inc,store)执行。

我猜这是因为它基本上只有链接,一些“内联”解释会使其更好。 - Bathsheba
1
他问了一个非常百科全书式的问题。我的回答试图用我认为他在寻找的定义来回答。链接只是“来源”。例如,他不想知道LOCK的工作原理。 - Francis Straccia
5
不。我对这个答案进行了反对投票,因为它是错误的。编译器不会在相关操作中添加“LOCK”前缀。问题是“为什么对齐的DWORD移动操作是原子性的”。这个问题还没有被回答。添加“LOCK”前缀将使所有(可能的)指令都具有原子性,因为它会锁定(内存)总线。 - zx485
2
@Francis Straccia:我撤销了我的负评,并将其替换为正评,因为正确的短语“是原子性设计”。x86的设计保证了对齐数据上MOV操作的原子性。我不知道为什么,但这很好。能够解释一下会很棒,但可能只有少数人能够回答这个问题。 - zx485
@zx485:对于缓存内存来说,答案很简单:缓存行大小大于寄存器大小。 - EOF
显示剩余5条评论

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