【问题标题】:Why is integer assignment on a naturally aligned variable atomic on x86?为什么在 x86 上对自然对齐的变量进行整数赋值是原子的?
【发布时间】:2016-08-06 02:41:41
【问题描述】:

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

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

【问题讨论】:

  • 这个问题是第一个结果:-)
  • 骗子没有解释为什么这能保证原子性。
  • @user3528438:这不是特别有用。
  • @Bathsheba 这是高度依赖于实现的行为,在 ISA 和芯片级别上。如果您向 GCC 开发人员询问这个问题,他们也会将您重定向到芯片供应商,Ibelieve。
  • 这个问题的全部答案是“因为 CPU 文档是这样说的”。为什么要让它更复杂?

标签: c++ concurrency x86 atomic memory-alignment


【解决方案1】:

要回答您的第一个问题,如果变量存在于其大小的倍数的内存地址处,则该变量自然会对齐。

如果我们只考虑 - 正如您链接的文章那样 - 赋值指令,那么对齐保证原子性,因为 MOV(赋值指令)在对齐数据上的设计是原子的。

其他类型的指令,例如 INC,需要被 LOCKed(一个 x86 前缀,它在前缀操作期间为当前处理器提供对共享内存的独占访问权限),即使数据是对齐的,因为它们实际上是通过多个步骤执行的(=指令,即 load、inc、store)。

【讨论】:

  • 我猜这是因为它本质上是仅链接的。一些“一致”的解释会改善它。
  • 他问了一些非常百科全书的问题。答案是试图用我认为他正在寻找的定义来回答。链接仅用于“来源”。例如他不想知道 LOCK 是如何工作的。
  • 没有。我否决了这个答案,因为它是错误的。编译器不会为涉及的操作添加LOCK 前缀。问题是“为什么对齐 DWORD MOVs 原子。这个问题没有得到回答。添加 LOCK 前缀将使所有(可能的)指令原子,因为它锁定(内存)总线。
  • @zx485:有点。实际上只有少数指令甚至可以加锁前缀。 mov 不在其中。
  • @Francis Straccia:由于正确的短语 'is atomic by design',我撤回了我的反对票并将其替换为赞成票。 x86 的设计保证了MOVs 在对齐数据上的原子性。我不知道为什么 - 但它很好。一个解释会很棒,但世界上可能只有少数人能回答这个问题。
【解决方案2】:

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

CPU 经常以 2 次方大小的块执行缓存访问或内核之间的缓存线传输等操作,因此小于缓存线的对齐边界确实很重要。 (请参阅下面的@BeeOnRope 的 cmets)。有关 CPU 如何在内部实现原子加载或存储的更多详细信息,另请参阅Atomicity on x86,有关如何在内部实现 atomic<int>::fetch_add() / lock xadd 等原子 RMW 操作的更多信息,请参见 Can num++ be atomic for 'int 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。否则编译器只会在寄存器instead of reloading every time your read it中保留一个值,就像volatile一样,但有语言标准的实际保证和官方支持。

在 C++11 之前,原子操作通常使用 volatile 或其他东西完成,并且“在我们关心的编译器上工作”的健康剂量,因此 C++11 向前迈出了一大步。现在你不再需要关心编译器对普通的int 做了什么;只需使用atomic<int>。如果您发现旧指南谈论 int 的原子性,它们可能早于 C++11。 When to use volatile with multi threading? 解释了为什么它在实践中有效,atomic<T>memory_order_relaxed 是获得相同功能的现代方式。

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

旁注:atomic&lt;T&gt; 大于 CPU 可以自动执行的操作(所以.is_lock_free() 为假),请参阅Where is the lock for a std::atomic?。不过,intint64_t / uint64_t 在所有主要的 x86 编译器上都是无锁的。


因此,我们只需要讨论像mov [shared], eax 这样的指令的行为。


TL;DR:x86 ISA 保证自然对齐的存储和加载是原子的,最多 64 位宽。因此编译器可以使用普通的存储/加载,只要它们确保 std::atomic&lt;T&gt;自然对齐。

(但请注意,i386 gcc -m32 无法对结构内的 C11 _Atomic 64 位类型执行此操作,仅将它们与 4B 对齐,因此 atomic_llong 在某些情况下可以是非原子的。https://gcc.gnu.org/bugzilla/show_bug.cgi?id=65146#c4) . g++ -m32std::atomic 很好,至少在 g++5 中是这样,因为 https://gcc.gnu.org/bugzilla/show_bug.cgi?id=65147 在 2015 年通过更改 &lt;atomic&gt; 标头而得到修复。不过,这并没有改变 C11 的行为。)


IIRC,有 SMP 386 系统,但当前的内存语义直到 486 才建立。这就是手册说“486 及更新版本”的原因。

来自“英特尔® 64 和 IA-32 架构软件开发人员手册,第 3 卷”,我的笔记以斜体显示。 (另请参阅 标签维基链接:所有卷的current versions,或直接链接到page 256 of the vol3 pdf from Dec 2015

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

###Section 8.1.1 保证原子操作

Intel486 处理器(以及之后的更新处理器)保证以下基本内存 操作将始终以原子方式执行:

  • 读取或写入一个字节
  • 读取或写入在 16 位边界上对齐的字
  • 读取或写入在 32 位边界上对齐的双字 (这是“自然对齐”的另一种说法)

我加粗的最后一点是对您问题的回答:这种行为是处理器成为 x86 CPU(即 ISA 的实现)所需的一部分。


本节的其余部分为较新的 Intel CPU 提供了进一步的保证:Pentium 将此保证扩展到 64 位

奔腾处理器(以及之后的更新处理器)保证 将始终执行以下额外的内存操作 原子地:

  • 读取或写入在 64 位边界上对齐的四字 (例如,doublecmpxchg8b(这是 Pentium P5 中的新功能)的 x87 加载/存储)
  • 对适合 32 位数据总线的未缓存内存位置进行 16 位访问。

该部分继续指出,跨缓存行(和页面边界)拆分的访问不能保证是原子的,并且:

"访问大于四字的数据的 x87 指令或 SSE 指令可以使用 多个内存访问。”


AMD 的手册同意 Intel 关于对齐 64 位和更窄的负载/商店是原子的

所以整数、x87 和 MMX/SSE 加载/存储高达 64b,即使在 32 位或 16 位模式下(例如 movqmovsdmovhpspinsrqextractps等)如果数据对齐,原子的。 gcc -m32 使用 movq xmm, [mem]std::atomic&lt;int64_t&gt; 之类的东西实现原子 64 位加载。 Clang4.0 -m32 不幸使用了lock cmpxchg8b bug 33109

在一些具有 128b 或 256b 内部数据路径(在执行单元和 L1 之间,以及在不同缓存之间)的 CPU 上,128b 甚至 256b 向量加载/存储是原子的,但这由任何标准的或在运行时易于查询的,unfortunately for compilers implementing std::atomic&lt;__int128&gt; or 16B structs

如果您想在所有 x86 系统上使用 atomic 128b,则必须使用 lock cmpxchg16b(仅在 64 位模式下可用)。 (而且它在第一代 x86-64 CPU 中不可用。您需要使用 -mcx16 和 GCC/Clang for them to emit it。)

即使是在内部执行原子 128b 加载/存储的 CPU 也可以在具有以较小块运行的一致性协议的多套接字系统中表现出非原子行为:例如AMD Opteron 2435 (K10) with threads running on separate sockets, connected with HyperTransport.


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 缓存的标记,即使对于由于在每核 L1 缓存中为 M 或 E 而必须在 L3 中处于无效状态的行也是如此。在 Haswell/Skylake 中,L3 和 per-core 缓存之间的数据路径只有 32B 宽,因此它必须缓冲或避免在读取缓存线的两半之间发生从一个核心写入 L3,这可能导致撕裂32B 边界。

手册的相关章节:

P6 系列处理器(以及更新的 Intel 处理器 因为)保证以下额外的内存操作将 始终以原子方式执行:

  • 未对齐的 16 位、32 位和 64 位访问适合缓存行的缓存内存。

AMD64 手册 7.3.2 访问原子性
可缓存、自然对齐的单个加载或存储多达四字在任何处理器上都是原子的 模型,以及小于四字的未对齐加载或存储 完全包含在一个自然对齐的四字中

请注意,AMD 保证任何小于 qword 的负载的原子性,但 Intel 仅适用于 2 的幂大小。 32 位保护模式和 64 位长模式可以使用far-call 或远-jmp 将 48 位 m16:32 作为内存操作数加载到 cs:eip 中。 (并且远调用将东西压入堆栈。)IDK 如果这算作单个 48 位访问或单独的 16 位和 32 位访问。

已经尝试将 x86 内存模型形式化,最新的是 the x86-TSO (extended version) paper from 2009(来自 标签 wiki 的内存排序部分的链接)。由于他们定义了一些符号来用自己的符号表达事物,因此它没有用处可略读,而且我还没有尝试真正阅读它。 IDK 如果它描述了原子性规则,或者它只关心内存排序


原子读取-修改-写入

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

为了防止该内存位置的内容在加载和存储之间被修改,您需要 lock cmpxchg8b,就像您需要 @987654400 @ 表示整个 read-modify-write 都是原子的。另请注意,即使没有lockcmpxchg8b 执行单个原子加载(以及可选的存储),通常将其用作预期=期望的64b 加载也是不安全的。如果内存中的值恰好符合您的预期,您将获得该位置的非原子读取-修改-写入。

lock 前缀甚至可以使跨缓存行或页面边界的未对齐访问成为原子访问,但您不能将它与 mov 一起使用以使未对齐的存储或加载成为原子操作。它仅适用于内存目标读取-修改-写入指令,例如add [mem], eax

(lock 隐含在 xchg reg, [mem] 中,所以不要将xchg 与 mem 一起使用以节省代码大小或指令数,除非性能无关紧要。仅在您需要时使用它内存屏障和/或原子交换,或者当代码大小是唯一重要的事情时,例如在引导扇区中。)

另请参阅:Can num++ be atomic for 'int num'?


为什么 lock mov [mem], reg 不存在用于原子未对齐存储

来自指令参考手册(Intel x86 manual vol2),cmpxchg

此指令可以与 LOCK 前缀一起使用,以允许 指令以原子方式执行。为了简化界面 处理器的总线,目标操作数接收一个写周期 不考虑比较结果。目的地 如果比较失败,则写回操作数;否则,源 操作数被写入目标。 (处理器从不产生 锁定读取而不会产生锁定写入。)

在内存控制器内置到 CPU 之前,这一设计决定降低了芯片组的复杂性。对于命中 PCI-express 总线而不是 DRAM 的 MMIO 区域上的locked 指令,它仍然可能这样做。 lock mov reg, [MMIO_PORT] 产生对内存映射 I/O 寄存器的写入和读取只会令人困惑。

另一种解释是,确保您的数据自然对齐并不难,与仅确保您的数据对齐相比,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+mfencemfence on Skylake at least blocks out-of-order execution of non-memory instructions, but xchg and other locked ops don't. gcc 以外的编译器确实使用 xchg 存储,即使他们不关心读取旧值。


此设计决策的动机:

如果没有它,软件将不得不使用 1 字节锁(或某种可用的原子类型)来保护对 32 位整数的访问,与共享原子读取访问相比,对于诸如由一个定时器中断。它可能在硅片中基本上是免费的,以保证总线宽度或更小的对齐访问。

为了使锁定成为可能,需要某种原子访问。 (实际上,我猜硬件可以提供某种完全不同的硬件辅助锁定机制。)对于在其外部数据总线上进行 32 位传输的 CPU,将其作为原子性单元是有意义的。


由于您提供了赏金,我假设您正在寻找一个长长的答案,其中涉及所有有趣的附带主题。如果您认为有哪些我没有涵盖的内容可以使本问答对未来的读者更有价值,请告诉我。

既然您linked one in the question我强烈建议您阅读更多 Jeff Preshing 的博客文章。它们非常棒,帮助我将我所知道的知识整合在一起,以理解 C/C++ 源代码与 asm 中不同硬件架构的内存排序,以及如何/何时告诉编译器你想要什么。 t 直接写 asm。

【讨论】:

  • AMD64 Manual 7.3.2 访问原子性:“可缓存、自然对齐的单个加载或最多存储一个四字在任何处理器模型上都是原子的,未对齐的加载也是如此或少于一个四字的存储完全包含在一个自然对齐的四字中”
  • @bartolo-otrit: 嗯,所以 AMD 对可缓存加载/存储的原子性要求比 Intel 更严格?这与HyperTransport between sockets transfers cache lines in aligned chunks as small as 8B 的事实相符。我希望英特尔或其他人能够记录 CPU 所需的通用功能子集,称为 x86。
  • 您提到了进行原子访问的动机(即,它使 CPU 作为编程目标更具功能性),但我认为这个问题暗示了理解另一半的愿望:什么只有自然对齐的访问是原子的限制背后的动机是什么?即,根据您的答案类型仍然存在 AMD 的限制(实际上它是“在一个四字内”,但它有点接近)并且过去存在于英特尔上。一个简单的答案是它简化了硬件的设计:超传输示例是一个很好的示例,但也...
  • ... 诸如缓存之类的东西可能被设计为提供对齐的块,并且某些未对齐的情况将涉及读取两个对齐的块并将它们组合起来(类似于跨缓存行读取)。可能值得在其中写一两行,您在回答中讨论的所有内容主要适用于“排队”的访问,也许还包括完全包含在较早的访问中的访问(尽管“较早”意味着您' d 需要通过排序使一切复杂化)——但不适用于部分重叠的访问,至少不是以简单的方式,因为我们知道......
  • ... 这样的访问可以将来自两个不同时间点的字节组合成一个 franken-word 结果(是否真的违反原子性需要一些更正式的定义)。顺便说一句,您链接的论文是一篇好论文:我认为它们并没有真正解决诸如原子性或缓存行拆分之类的问题(至少早期版本没有),但我认为您可以说它们假设原子性(不撕裂)因为没有原子性,您就无法真正对排序进行有意义的讨论(因为之前/之后现在将具有“一半之前/一半之后”状态)。
【解决方案3】:

如果你问为什么要这样设计,我会说它是 CPU 架构设计的一个很好的副产品。

在 486 时代,没有多核 CPU 或 QPI 链路,所以原子性在那个时候并不是真正的严格要求(DMA 可能需要它?)。

在 x86 上,数据宽度为 32 位(或 x86_64 为 64 位),这意味着 CPU 可以一次读取和写入数据宽度。并且内存数据总线通常与这个数字相同或更宽。结合在对齐地址上的读/写是一次性完成的事实,自然没有什么可以阻止读/写是非原子的。您同时获得速度/原子。

【讨论】:

    【解决方案4】:

    如果 32 位或更小的对象在内存的“正常”部分中自然对齐,则任何 80386 或兼容处理器都可以,但 80386sx 在一次操作中读取或写入对象的所有 32 位。虽然一个平台能够以快速和有用的方式做某事并不一定意味着该平台有时不会出于某种原因以其他方式做某事,虽然我相信在许多(如果不是全部)x86 处理器上都有可能具有一次只能访问 8 位或 16 位的内存区域,我认为英特尔从未定义过任何条件,即请求对“正常”内存区域进行对齐的 32 位访问会导致系统读取或写入部分值而不读取或写入整个内容,我认为英特尔无意为“正常”内存区域定义任何此类内容。

    【讨论】:

    • 我不认为有任何 x86 内存类型可以分割更广泛的访问,或者不允许它们。 “不可缓存”和“写入组合”内存区域仅意味着每个 N 字节加载指令在缓存层次结构之外产生该宽度的单独请求。我忘记了普通商店是否可以合并到“写入组合”区域中,或者它是否适用于弱排序movnt 商店。不过,我可能忘记了一种模糊的记忆类型。除了正常的回写,还有直写。
    • @PeterCordes:英特尔的处理器至少 80486 和我认为以后能够以不同的速度和总线宽度寻址到 RAM,如果没有保留这种支持,我会有些惊讶。在较旧的处理器上,当处理器发出 16 位内存请求时,它会寻找一个表明内存只能支持 8 位请求的信号。如果处理器连接到 8 位内存系统,硬件会将 16 位请求视为读取或写入字的下半部分的请求,但会要求处理器跟进......跨度>
    • ...另一半的 8 位访问。内存的一部分连接到 8 位总线而不是 16 位总线这一事实对于代码来说是不可见的(除了内存访问的执行时间更长),但是如果硬件不能作用于超过 8 位处理器不可能一步完成。如果所有英特尔处理器都在 8 位访问之间保持总线,我不会感到惊讶(以便使 32 位序列有效地原子化,除非在使用双端口 RAM 的系统中)但是操作 must 在硬件级别进行拆分。
    【解决方案5】:

    自然对齐意味着类型的地址是类型大小的倍数。

    例如,一个字节可以在任何地址,一个short(假设16位)必须是2的倍数,一个int(假设32位)必须是4的倍数,一个long(假设64 bits) 必须是 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 个字节时,它可能会看到该块不在内存中并发出它需要内存分页的信号。这可能会阻塞调用进程,而操作系统将内存分页。

    在内存被分页之后,但在你的进程被唤醒之前,另一个进程可能会出现并将Y 写入地址 4。然后你的进程被重新调度并且 CPU 完成读取,但现在它已经读取 XXXX,而不是您预期的 XXXX。

    【讨论】:

    • 你暗示对齐访问是原子的,这可能是也可能不是,这取决于很多未知数。
    • @user3528438 - 例如,在 x86 上,只要内存位置自然对齐,32 位读取就是原子的。
    • 实际上常规的 x86 指令(不是 SIMD)不需要数据对齐。所以没有错。
    • @FrancisStraccia - 没错,他们不需要它。但是,如果它们不是自然对齐的,那么它们就不是原子的,你可以得到数据剪切。在某些架构上,例如 Sparc,如果您尝试读取对齐不正确的数据,您会遇到错误。
    • @Sean:我在您的回答中指的是“...对齐的 CPU 将引发故障或将读/写...”。鉴于 OP 询问了 x86,它可能看起来令人困惑。 (在其他拱门上是的,当然可能会发生故障。)
    猜你喜欢
    • 1970-01-01
    • 2011-01-18
    • 1970-01-01
    • 2012-10-08
    • 2023-03-18
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多