“自然”对齐意味着与其自身的字体宽度对齐。因此,加载/存储永远不会跨越任何比自身更宽的边界(例如,页面、缓存行,或者用于不同缓存之间数据传输的更窄的块大小)。
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<T> 大于 CPU 可以自动执行的操作(所以.is_lock_free() 为假),请参阅Where is the lock for a std::atomic?。不过,int 和 int64_t / uint64_t 在所有主要的 x86 编译器上都是无锁的。
因此,我们只需要讨论像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) . g++ -m32 和 std::atomic 很好,至少在 g++5 中是这样,因为 https://gcc.gnu.org/bugzilla/show_bug.cgi?id=65147 在 2015 年通过更改 <atomic> 标头而得到修复。不过,这并没有改变 C11 的行为。)
IIRC,有 SMP 386 系统,但当前的内存语义直到 486 才建立。这就是手册说“486 及更新版本”的原因。
来自“英特尔® 64 和 IA-32 架构软件开发人员手册,第 3 卷”,我的笔记以斜体显示。 (另请参阅x86 标签维基链接:所有卷的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 位边界上对齐的四字
(例如,
double 或 cmpxchg8b(这是 Pentium P5 中的新功能)的 x87 加载/存储)
- 对适合 32 位数据总线的未缓存内存位置进行 16 位访问。
该部分继续指出,跨缓存行(和页面边界)拆分的访问不能保证是原子的,并且:
"访问大于四字的数据的 x87 指令或 SSE 指令可以使用
多个内存访问。”
AMD 的手册同意 Intel 关于对齐 64 位和更窄的负载/商店是原子的
所以整数、x87 和 MMX/SSE 加载/存储高达 64b,即使在 32 位或 16 位模式下(例如 movq、movsd、movhps、pinsrq、extractps等)如果数据对齐,是原子的。 gcc -m32 使用 movq xmm, [mem] 为 std::atomic<int64_t> 之类的东西实现原子 64 位加载。 Clang4.0 -m32 不幸使用了lock cmpxchg8b bug 33109。
在一些具有 128b 或 256b 内部数据路径(在执行单元和 L1 之间,以及在不同缓存之间)的 CPU 上,128b 甚至 256b 向量加载/存储是原子的,但这不由任何标准的或在运行时易于查询的,unfortunately for compilers implementing std::atomic<__int128> 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(来自x86 标签 wiki 的内存排序部分的链接)。由于他们定义了一些符号来用自己的符号表达事物,因此它没有用处可略读,而且我还没有尝试真正阅读它。 IDK 如果它描述了原子性规则,或者它只关心内存排序。
原子读取-修改-写入
我提到了cmpxchg8b,但我只是在谈论负载和存储各自是原子的(即没有“撕裂”,其中一半负载来自一个存储,另一半负载来自一个不同的商店)。
为了防止该内存位置的内容在加载和存储之间被修改,您需要 lock cmpxchg8b,就像您需要 @987654400 @ 表示整个 read-modify-write 都是原子的。另请注意,即使没有lock 的cmpxchg8b 执行单个原子加载(以及可选的存储),通常将其用作预期=期望的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+mfence。 mfence 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。