为什么在 x86 上对自然对齐的变量原子进行整数赋值?
Why is integer assignment on a naturally aligned variable atomic on x86?
我一直在阅读 this article 有关原子操作的内容,它提到 32 位整数赋值在 x86 上是原子的,只要变量自然对齐。
为什么自然对齐可以保证原子性?
自然对齐是指类型的地址是类型大小的倍数
例如,字节可以在任何地址,short(假设 16 位)必须是 2 的倍数,int(假设 32 位)必须是 4 的倍数,long (假设 64 位)必须是 8 的倍数。
如果您访问的数据不是自然对齐的,CPU 将引发错误或 read/write 内存,但不是原子操作。 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 个字节时,它可能会看到该块不在内存中并发出信号表明它需要调入内存。这可能会阻止调用在 OS 将内存分页返回的同时进行处理。
在内存被调入后,但在您的进程被唤醒之前,另一个进程可能会出现并写入 Y
到地址 4。然后您的进程被重新安排,并且 CPU完成读取,但现在读取的是 XYXX,而不是您预期的 XXXX。
要回答您的第一个问题,如果变量存在于其大小的倍数的内存地址中,则该变量自然对齐。
如果我们只考虑 - 正如您链接的文章那样 - 赋值指令,那么对齐可以保证原子性,因为 MOV(赋值指令)在对齐数据上的设计是原子的。
其他类型的指令,例如 INC,需要 LOCKed(一个 x86 前缀,在前缀操作)即使数据是对齐的,因为它们实际上是通过多个步骤执行的(=指令,即 load、inc、store)。
如果 32 位或更小的对象在内存的 "normal" 部分内自然对齐,则除了
80386sx 在一次操作中读取或写入对象的所有 32 位。虽然一个平台能够以快速有用的方式做某事并不一定意味着该平台有时不会出于某种原因以其他方式做某事,而且我相信即使不是所有 x86 处理器,也有可能在许多 x86 处理器上具有一次只能访问 8 位或 16 位的内存区域,我认为英特尔从未定义过任何条件,要求对 "normal" 内存区域进行对齐的 32 位访问会导致系统在不读取或写入整个值的情况下读取或写入部分值,我认为英特尔无意为 "normal" 内存区域定义任何此类内容。
“自然”对齐意味着对齐到它自己的类型宽度。因此,load/store 永远不会跨越任何比自身更宽的边界(例如页面,cache-line,或者用于不同缓存之间数据传输的更窄的块大小)。
CPUs 经常做 cache-access 或 cache-line 内核之间的传输,在 power-of-2 大小的块中,因此对齐边界小于缓存行做事。 (请参阅下面@BeeOnRope 的评论)。另请参阅 Atomicity on x86 for more details on how CPUs implement atomic loads or stores internally, and Can num++ be atomic for 'int num'? 以了解有关如何在内部实现 atomic<int>::fetch_add()
/ lock xadd
等原子 RMW 操作的更多信息。
首先,这假设 int
是用单个存储指令更新的,而不是分别写入不同的字节。这是 std::atomic
保证的一部分,但普通 C 或 C++ 不能保证。不过,通常 会是这种情况。 x86-64 System V ABI 不禁止编译器访问 int
变量 non-atomic,即使它确实要求 int
为 4B,默认对齐方式为 4B。例如,如果编译器需要,x = a<<16 | b
可以编译成两个独立的 16 位存储。
数据竞争在 C 和 C++ 中都是未定义的行为,因此编译器可以并且确实假定内存未被异步修改。 对于保证不会中断的代码,请使用 C11 stdatomic or C++11 std::atomic. Otherwise the compiler will just keep a value in a register 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
Side-note:对于比CPU大的atomic<T>
可以原子地做(所以.is_lock_free()
是假的),见Where is the lock for a std::atomic?。不过,int
和 int64_t
/ uint64_t
在所有主要的 x86 编译器上都是 lock-free。
因此,我们只需要讨论像mov [shared], eax
这样的指令的行为。
TL;DR: x86 ISA 保证 naturally-aligned 存储和加载是原子的,最多 64 位宽。 所以编译器可以使用普通的 stores/loads只要他们确保 std::atomic<T>
具有自然对齐。
(但请注意,i386 gcc -m32
无法为结构内部的 C11 _Atomic
64 位类型执行此操作,仅将它们对齐到 4B,因此 atomic_llong
可以是 non-atomic 在某些情况下。https://gcc.gnu.org/bugzilla/show_bug.cgi?id=65146#c4). g++ -m32
with std::atomic
is fine, at least in g++5 because https://gcc.gnu.org/bugzilla/show_bug.cgi?id=65147 已于 2015 年通过更改 <atomic>
header 得到修复。不过,这并没有改变 C11 的行为。)
IIRC,有 SMP 386 系统,但当前的内存语义直到 486 才建立。这就是手册上说“486 和更新版本”的原因。
来自“英特尔® 64 位和 IA-32 架构软件开发人员手册,第 3 卷”,我的笔记以斜体显示。 (另见 x86 tag wiki for links: current versions of all volumes, or direct link to page 256 of the vol3 pdf from Dec 2015)
在 x86 术语中,一个“字”是两个 8 位字节。 32 位是 double-word 或 DWORD。
###Section 8.1.1 Guaranteed Atomic Operations
The Intel486 processor (and newer processors since) guarantees that the following basic memory
operations will always be carried out atomically:
- Reading or writing a byte
- Reading or writing a word aligned on a 16-bit boundary
- Reading or writing a doubleword aligned on a 32-bit boundary (This is another way of saying "natural alignment")
我加粗的最后一点是您问题的答案:此行为是处理器成为 x86 CPU(即 ISA 的实现)所要求的一部分。
本节的其余部分为较新的 Intel CPUs 提供了进一步的保证:Pentium 将此保证扩大到 64 位。
The
Pentium processor (and newer processors since) guarantees that the
following additional memory operations will always be carried out
atomically:
- Reading or writing a quadword aligned on a 64-bit boundary
(e.g. x87 load/store of a
double
, or cmpxchg8b
(which was new in Pentium P5))
- 16-bit accesses to uncached memory locations that fit within a 32-bit data bus.
该部分继续指出跨高速缓存行(和页面边界)拆分的访问不能保证是原子的,并且:
"An x87 instruction or an SSE instructions that accesses data larger than a quadword may be implemented using
multiple memory accesses."
AMD 的手册同意英特尔关于对齐 64 位和更窄 loads/stores 是原子的
因此整数、x87 和 MMX/SSE loads/stores 高达 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 之间,以及在不同缓存之间)的 CPUs 上,128b 甚至 256b 向量 loads/stores 是原子的,但这是 不 由任何标准保证或可在 run-time、unfortunately for compilers implementing std::atomic<__int128>
or 16B structs.
轻松查询
(更新:x86厂商have decided that the AVX feature bit also indicates atomic 128-bit aligned loads/stores. Before that we only had https://rigtorp.se/isatomic/实验测试验证。)
如果你想在所有 x86 系统上使用原子 128b,你必须使用 lock cmpxchg16b
(仅在 64 位模式下可用)。 (并且它在 first-gen x86-64 CPU 中不可用。您需要将 -mcx16
与 GCC/Clang for them to emit it 一起使用。)
即使是在内部执行原子 128b loads/stores 的 CPU 也可以在 multi-socket 系统中表现出 non-atomic 行为,该系统具有在较小块中运行的一致性协议:例如AMD Opteron 2435 (K10) with threads running on separate sockets, connected with HyperTransport.
英特尔和 AMD 的手册对于未对齐的交流电存在分歧访问可缓存内存。所有 x86 CPUs 的公共子集是 AMD 规则。可缓存意味着 write-back 或 write-through 内存区域,而不是不可缓存或 write-combining,如 PAT 或 MTRR 区域所设置的。它们并不意味着 cache-line 必须已经在 L1 缓存中处于热状态。
- Intel P6 及更高版本保证可缓存 loads/stores 最多 64 位的原子性,只要它们在单个 cache-line 内(64B,或在非常旧的 CPUs 上为 32B像奔腾 III)。
- AMD 保证适合单个 8B 对齐块的可缓存 loads/stores 的原子性。这是有道理的,因为我们从 multi-socket Opteron 上的 16B 存储测试中知道,HyperTransport 仅以 8B 块传输,并且在传输时不锁定以防止撕裂。 (往上看)。我想
lock cmpxchg16b
必须特殊处理。
可能相关:AMD 使用 MOESI 直接在不同内核的缓存之间共享脏 cache-lines,因此一个内核可以从缓存行的有效副本中读取,同时对其进行更新来自另一个缓存。
英特尔使用 MESIF,这需要脏数据传播到大型共享包容性 L3 缓存,充当一致性流量的后盾。 L3 是 tag-inclusive 的 per-core L2/L1 缓存,即使对于在 L3 中必须处于无效状态的行也是如此,因为在 per-core L1 缓存中是 M 或 E。 L3 和 per-core 缓存之间的数据路径在 Haswell/Skylake 中只有 32B 宽,因此它必须缓冲或其他东西以避免在读取缓存行的两半之间发生从一个内核写入 L3,这可能导致 32B 边界撕裂。
手册的相关部分:
The P6 family processors (and newer Intel processors
since) guarantee that the following additional memory operation will
always be carried out atomically:
- Unaligned 16-, 32-, and 64-bit accesses to cached memory that fit within a cache line.
AMD64 Manual 7.3.2 Access Atomicity
Cacheable, naturally-aligned single loads or stores of up to a quadword are atomic on any processor
model, as are misaligned loads or stores of less than a quadword that
are contained entirely within a naturally-aligned quadword
请注意,AMD 保证任何小于 qword 的负载的原子性,但 Intel 只保证 power-of-2 大小。 32位保护模式和64位长模式可以用far-call
或far-jmp
加载一个48位的m16:32
作为内存操作数到cs:eip
中。 (并且 far-call 将内容压入堆栈。)IDK 如果这算作单个 48 位访问或单独的 16 位和 32 位访问。
有人尝试将 x86 内存模型形式化,最新的尝试是 the x86-TSO (extended version) paper from 2009 (link from the memory-ordering section of the x86 tag wiki)。它不是有用的略读,因为他们定义了一些符号来用他们自己的符号来表达事物,而且我还没有尝试真正阅读它。 IDK 如果它描述了原子性规则,或者如果它只与内存有关 ordering.
原子Read-Modify-Write
我提到了 cmpxchg8b
,但我只是在谈论负载和存储分别是原子的(即没有“撕裂”,其中一半负载来自一个存储,另一半来自另一个存储负载来自不同的商店)。
为了防止该内存位置的内容被修改在加载和存储之间,您需要lock
cmpxchg8b
,就像你需要 lock inc [mem]
整个 read-modify-write 是原子的一样。另请注意,即使没有 lock
的 cmpxchg8b
执行单个原子加载(以及可选的存储),将其用作具有 expected=desired 的 64b 加载通常也不安全。如果内存中的值恰好符合您的预期,您将获得该位置的 non-atomic read-modify-write。
lock
前缀甚至可以使跨越 cache-line 或页面边界的未对齐访问成为原子操作,但您不能将它与 mov
一起使用来使未对齐存储或加载成为原子操作。它仅适用于 memory-destination read-modify-write 指令,例如 add [mem], eax
.
(lock
隐含在xchg reg, [mem]
中,所以不要将xchg
与mem一起使用来保存code-size或指令计数,除非性能无关紧要。只使用它当你想要内存屏障and/or原子交换时,或者当code-size是唯一重要的事情时,例如在引导扇区中。)
另请参阅:Can num++ be atomic for 'int num'?
为什么原子未对齐存储不存在 lock mov [mem], reg
来自指令参考手册(Intel x86手册vol2),cmpxchg
:
This instruction can be used with a LOCK
prefix to allow the
instruction to be executed atomically. To simplify the interface to
the processor’s bus, the destination operand receives a write cycle
without regard to the result of the comparison. The destination
operand is written back if the comparison fails; otherwise, the source
operand is written into the destination. (The processor never produces
a locked read without also producing a locked write.)
此设计决策在内存控制器内置到 CPU 之前降低了芯片组的复杂性。对于命中 PCI-express 总线而非 DRAM 的 MMIO 区域上的 lock
ed 指令,它可能仍会这样做。 lock mov reg, [MMIO_PORT]
对 memory-mapped I/O 寄存器产生写入和读取只会让人感到困惑。
另一种解释是,确保您的数据自然对齐并不难,与仅确保数据对齐相比,lock store
的表现会很糟糕。将晶体管花在速度太慢以至于不值得使用的东西上是愚蠢的。如果你真的需要它(并且也不介意读取内存),你可以使用 xchg [mem], reg
(XCHG 有一个隐式的 LOCK 前缀),它甚至比假设的 lock mov
.
使用 lock
前缀也是一个完整的内存屏障,因此它会带来超出原子 RMW 的性能开销。即 x86 不能做宽松的原子 RMW(不刷新存储缓冲区)。其他 ISA 可以,所以使用.fetch_add(1, memory_order_relaxed)
在非 x86 上可以更快。
有趣的事实:在 mfence
存在之前,一个常见的习语是 lock add dword [esp], 0
,这是一个 no-op,而不是破坏标志和进行锁定操作。 [esp]
在 L1 缓存中几乎总是热的,不会引起与任何其他核心的争用。这个习惯用法作为 stand-alone 内存屏障可能仍然比 MFENCE 更有效,尤其是在 AMD CPUs 上。
xchg [mem], reg
可能是在 Intel 和 AMD 上实现 sequential-consistency 存储的最有效方式,与 mov
+mfence
相比。 mfence
on Skylake at least blocks out-of-order execution of non-memory instructions, but xchg
and other lock
ed ops don't. gcc 以外的编译器确实使用 xchg
进行存储,即使它们不关心读取旧值也是如此。
做出此设计决定的动机:
没有它,软件将不得不使用 1 字节锁(或某种可用的原子类型)来保护对 32 位整数的访问,与全局时间戳变量之类的共享原子读取访问相比,这是非常低效的由定时器中断更新。它在硅片中可能基本上是免费的,以保证 bus-width 或更小的对齐访问。
要使锁定成为可能,需要某种原子访问。 (实际上,我猜硬件可以提供某种完全不同的 hardware-assisted 锁定机制。)对于在其外部数据总线上进行 32 位传输的 CPU,将它作为单元是有意义的原子性。
既然你提供了赏金,我想你正在寻找一个涉及所有有趣的边话题的长答案。如果您认为我没有涵盖的内容会使此问答对未来的读者更有价值,请告诉我。
既然你 linked one in the question,我强烈建议阅读更多 Jeff Preshing 的博文。它们非常出色,帮助我将我所知道的各个部分组合起来,理解 C/C++ 源代码与不同硬件架构的 asm 中的内存顺序,以及如何/何时告诉编译器你想要什么如果你不是直接写 asm。
如果你问为什么这样设计,我会说这是 CPU 架构设计的一个很好的副产品。
回到 486 时代,没有多核 CPU 或 QPI link,所以当时原子性并不是一个严格的要求(DMA 可能需要它?)。
在 x86 上,数据宽度为 32 位(或 x86_64 为 64 位),这意味着 CPU 一次可以读取和写入数据宽度。内存数据总线通常与这个数字相同或更宽。结合对齐地址上的 reading/writing 是一次完成的事实,自然没有什么可以阻止 read/write 成为非原子的。你同时获得speed/atomic。
我一直在阅读 this article 有关原子操作的内容,它提到 32 位整数赋值在 x86 上是原子的,只要变量自然对齐。
为什么自然对齐可以保证原子性?
自然对齐是指类型的地址是类型大小的倍数
例如,字节可以在任何地址,short(假设 16 位)必须是 2 的倍数,int(假设 32 位)必须是 4 的倍数,long (假设 64 位)必须是 8 的倍数。
如果您访问的数据不是自然对齐的,CPU 将引发错误或 read/write 内存,但不是原子操作。 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 个字节时,它可能会看到该块不在内存中并发出信号表明它需要调入内存。这可能会阻止调用在 OS 将内存分页返回的同时进行处理。
在内存被调入后,但在您的进程被唤醒之前,另一个进程可能会出现并写入 Y
到地址 4。然后您的进程被重新安排,并且 CPU完成读取,但现在读取的是 XYXX,而不是您预期的 XXXX。
要回答您的第一个问题,如果变量存在于其大小的倍数的内存地址中,则该变量自然对齐。
如果我们只考虑 - 正如您链接的文章那样 - 赋值指令,那么对齐可以保证原子性,因为 MOV(赋值指令)在对齐数据上的设计是原子的。
其他类型的指令,例如 INC,需要 LOCKed(一个 x86 前缀,在前缀操作)即使数据是对齐的,因为它们实际上是通过多个步骤执行的(=指令,即 load、inc、store)。
如果 32 位或更小的对象在内存的 "normal" 部分内自然对齐,则除了 80386sx 在一次操作中读取或写入对象的所有 32 位。虽然一个平台能够以快速有用的方式做某事并不一定意味着该平台有时不会出于某种原因以其他方式做某事,而且我相信即使不是所有 x86 处理器,也有可能在许多 x86 处理器上具有一次只能访问 8 位或 16 位的内存区域,我认为英特尔从未定义过任何条件,要求对 "normal" 内存区域进行对齐的 32 位访问会导致系统在不读取或写入整个值的情况下读取或写入部分值,我认为英特尔无意为 "normal" 内存区域定义任何此类内容。
“自然”对齐意味着对齐到它自己的类型宽度。因此,load/store 永远不会跨越任何比自身更宽的边界(例如页面,cache-line,或者用于不同缓存之间数据传输的更窄的块大小)。
CPUs 经常做 cache-access 或 cache-line 内核之间的传输,在 power-of-2 大小的块中,因此对齐边界小于缓存行做事。 (请参阅下面@BeeOnRope 的评论)。另请参阅 Atomicity on x86 for more details on how CPUs implement atomic loads or stores internally, and Can num++ be atomic for 'int num'? 以了解有关如何在内部实现 atomic<int>::fetch_add()
/ lock xadd
等原子 RMW 操作的更多信息。
首先,这假设 int
是用单个存储指令更新的,而不是分别写入不同的字节。这是 std::atomic
保证的一部分,但普通 C 或 C++ 不能保证。不过,通常 会是这种情况。 x86-64 System V ABI 不禁止编译器访问 int
变量 non-atomic,即使它确实要求 int
为 4B,默认对齐方式为 4B。例如,如果编译器需要,x = a<<16 | b
可以编译成两个独立的 16 位存储。
数据竞争在 C 和 C++ 中都是未定义的行为,因此编译器可以并且确实假定内存未被异步修改。 对于保证不会中断的代码,请使用 C11 stdatomic or C++11 std::atomic. Otherwise the compiler will just keep a value in a register 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
Side-note:对于比CPU大的atomic<T>
可以原子地做(所以.is_lock_free()
是假的),见Where is the lock for a std::atomic?。不过,int
和 int64_t
/ uint64_t
在所有主要的 x86 编译器上都是 lock-free。
因此,我们只需要讨论像mov [shared], eax
这样的指令的行为。
TL;DR: x86 ISA 保证 naturally-aligned 存储和加载是原子的,最多 64 位宽。 所以编译器可以使用普通的 stores/loads只要他们确保 std::atomic<T>
具有自然对齐。
(但请注意,i386 gcc -m32
无法为结构内部的 C11 _Atomic
64 位类型执行此操作,仅将它们对齐到 4B,因此 atomic_llong
可以是 non-atomic 在某些情况下。https://gcc.gnu.org/bugzilla/show_bug.cgi?id=65146#c4). g++ -m32
with std::atomic
is fine, at least in g++5 because https://gcc.gnu.org/bugzilla/show_bug.cgi?id=65147 已于 2015 年通过更改 <atomic>
header 得到修复。不过,这并没有改变 C11 的行为。)
IIRC,有 SMP 386 系统,但当前的内存语义直到 486 才建立。这就是手册上说“486 和更新版本”的原因。
来自“英特尔® 64 位和 IA-32 架构软件开发人员手册,第 3 卷”,我的笔记以斜体显示。 (另见 x86 tag wiki for links: current versions of all volumes, or direct link to page 256 of the vol3 pdf from Dec 2015)
在 x86 术语中,一个“字”是两个 8 位字节。 32 位是 double-word 或 DWORD。
###Section 8.1.1 Guaranteed Atomic Operations
The Intel486 processor (and newer processors since) guarantees that the following basic memory operations will always be carried out atomically:
- Reading or writing a byte
- Reading or writing a word aligned on a 16-bit boundary
- Reading or writing a doubleword aligned on a 32-bit boundary (This is another way of saying "natural alignment")
我加粗的最后一点是您问题的答案:此行为是处理器成为 x86 CPU(即 ISA 的实现)所要求的一部分。
本节的其余部分为较新的 Intel CPUs 提供了进一步的保证:Pentium 将此保证扩大到 64 位。
The Pentium processor (and newer processors since) guarantees that the following additional memory operations will always be carried out atomically:
- Reading or writing a quadword aligned on a 64-bit boundary (e.g. x87 load/store of a
double
, orcmpxchg8b
(which was new in Pentium P5))- 16-bit accesses to uncached memory locations that fit within a 32-bit data bus.
该部分继续指出跨高速缓存行(和页面边界)拆分的访问不能保证是原子的,并且:
"An x87 instruction or an SSE instructions that accesses data larger than a quadword may be implemented using multiple memory accesses."
AMD 的手册同意英特尔关于对齐 64 位和更窄 loads/stores 是原子的
因此整数、x87 和 MMX/SSE loads/stores 高达 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 之间,以及在不同缓存之间)的 CPUs 上,128b 甚至 256b 向量 loads/stores 是原子的,但这是 不 由任何标准保证或可在 run-time、unfortunately for compilers implementing std::atomic<__int128>
or 16B structs.
(更新:x86厂商have decided that the AVX feature bit also indicates atomic 128-bit aligned loads/stores. Before that we only had https://rigtorp.se/isatomic/实验测试验证。)
如果你想在所有 x86 系统上使用原子 128b,你必须使用 lock cmpxchg16b
(仅在 64 位模式下可用)。 (并且它在 first-gen x86-64 CPU 中不可用。您需要将 -mcx16
与 GCC/Clang for them to emit it 一起使用。)
即使是在内部执行原子 128b loads/stores 的 CPU 也可以在 multi-socket 系统中表现出 non-atomic 行为,该系统具有在较小块中运行的一致性协议:例如AMD Opteron 2435 (K10) with threads running on separate sockets, connected with HyperTransport.
英特尔和 AMD 的手册对于未对齐的交流电存在分歧访问可缓存内存。所有 x86 CPUs 的公共子集是 AMD 规则。可缓存意味着 write-back 或 write-through 内存区域,而不是不可缓存或 write-combining,如 PAT 或 MTRR 区域所设置的。它们并不意味着 cache-line 必须已经在 L1 缓存中处于热状态。
- Intel P6 及更高版本保证可缓存 loads/stores 最多 64 位的原子性,只要它们在单个 cache-line 内(64B,或在非常旧的 CPUs 上为 32B像奔腾 III)。
- AMD 保证适合单个 8B 对齐块的可缓存 loads/stores 的原子性。这是有道理的,因为我们从 multi-socket Opteron 上的 16B 存储测试中知道,HyperTransport 仅以 8B 块传输,并且在传输时不锁定以防止撕裂。 (往上看)。我想
lock cmpxchg16b
必须特殊处理。
可能相关:AMD 使用 MOESI 直接在不同内核的缓存之间共享脏 cache-lines,因此一个内核可以从缓存行的有效副本中读取,同时对其进行更新来自另一个缓存。
英特尔使用 MESIF,这需要脏数据传播到大型共享包容性 L3 缓存,充当一致性流量的后盾。 L3 是 tag-inclusive 的 per-core L2/L1 缓存,即使对于在 L3 中必须处于无效状态的行也是如此,因为在 per-core L1 缓存中是 M 或 E。 L3 和 per-core 缓存之间的数据路径在 Haswell/Skylake 中只有 32B 宽,因此它必须缓冲或其他东西以避免在读取缓存行的两半之间发生从一个内核写入 L3,这可能导致 32B 边界撕裂。
手册的相关部分:
The P6 family processors (and newer Intel processors since) guarantee that the following additional memory operation will always be carried out atomically:
- Unaligned 16-, 32-, and 64-bit accesses to cached memory that fit within a cache line.
AMD64 Manual 7.3.2 Access Atomicity
Cacheable, naturally-aligned single loads or stores of up to a quadword are atomic on any processor model, as are misaligned loads or stores of less than a quadword that are contained entirely within a naturally-aligned quadword
请注意,AMD 保证任何小于 qword 的负载的原子性,但 Intel 只保证 power-of-2 大小。 32位保护模式和64位长模式可以用far-call
或far-jmp
加载一个48位的m16:32
作为内存操作数到cs:eip
中。 (并且 far-call 将内容压入堆栈。)IDK 如果这算作单个 48 位访问或单独的 16 位和 32 位访问。
有人尝试将 x86 内存模型形式化,最新的尝试是 the x86-TSO (extended version) paper from 2009 (link from the memory-ordering section of the x86 tag wiki)。它不是有用的略读,因为他们定义了一些符号来用他们自己的符号来表达事物,而且我还没有尝试真正阅读它。 IDK 如果它描述了原子性规则,或者如果它只与内存有关 ordering.
原子Read-Modify-Write
我提到了 cmpxchg8b
,但我只是在谈论负载和存储分别是原子的(即没有“撕裂”,其中一半负载来自一个存储,另一半来自另一个存储负载来自不同的商店)。
为了防止该内存位置的内容被修改在加载和存储之间,您需要lock
cmpxchg8b
,就像你需要 lock inc [mem]
整个 read-modify-write 是原子的一样。另请注意,即使没有 lock
的 cmpxchg8b
执行单个原子加载(以及可选的存储),将其用作具有 expected=desired 的 64b 加载通常也不安全。如果内存中的值恰好符合您的预期,您将获得该位置的 non-atomic read-modify-write。
lock
前缀甚至可以使跨越 cache-line 或页面边界的未对齐访问成为原子操作,但您不能将它与 mov
一起使用来使未对齐存储或加载成为原子操作。它仅适用于 memory-destination read-modify-write 指令,例如 add [mem], eax
.
(lock
隐含在xchg reg, [mem]
中,所以不要将xchg
与mem一起使用来保存code-size或指令计数,除非性能无关紧要。只使用它当你想要内存屏障and/or原子交换时,或者当code-size是唯一重要的事情时,例如在引导扇区中。)
另请参阅:Can num++ be atomic for 'int num'?
为什么原子未对齐存储不存在 lock mov [mem], reg
来自指令参考手册(Intel x86手册vol2),cmpxchg
:
This instruction can be used with a
LOCK
prefix to allow the instruction to be executed atomically. To simplify the interface to the processor’s bus, the destination operand receives a write cycle without regard to the result of the comparison. The destination operand is written back if the comparison fails; otherwise, the source operand is written into the destination. (The processor never produces a locked read without also producing a locked write.)
此设计决策在内存控制器内置到 CPU 之前降低了芯片组的复杂性。对于命中 PCI-express 总线而非 DRAM 的 MMIO 区域上的 lock
ed 指令,它可能仍会这样做。 lock mov reg, [MMIO_PORT]
对 memory-mapped I/O 寄存器产生写入和读取只会让人感到困惑。
另一种解释是,确保您的数据自然对齐并不难,与仅确保数据对齐相比,lock store
的表现会很糟糕。将晶体管花在速度太慢以至于不值得使用的东西上是愚蠢的。如果你真的需要它(并且也不介意读取内存),你可以使用 xchg [mem], reg
(XCHG 有一个隐式的 LOCK 前缀),它甚至比假设的 lock mov
.
使用 lock
前缀也是一个完整的内存屏障,因此它会带来超出原子 RMW 的性能开销。即 x86 不能做宽松的原子 RMW(不刷新存储缓冲区)。其他 ISA 可以,所以使用.fetch_add(1, memory_order_relaxed)
在非 x86 上可以更快。
有趣的事实:在 mfence
存在之前,一个常见的习语是 lock add dword [esp], 0
,这是一个 no-op,而不是破坏标志和进行锁定操作。 [esp]
在 L1 缓存中几乎总是热的,不会引起与任何其他核心的争用。这个习惯用法作为 stand-alone 内存屏障可能仍然比 MFENCE 更有效,尤其是在 AMD CPUs 上。
xchg [mem], reg
可能是在 Intel 和 AMD 上实现 sequential-consistency 存储的最有效方式,与 mov
+mfence
相比。 mfence
on Skylake at least blocks out-of-order execution of non-memory instructions, but xchg
and other lock
ed ops don't. gcc 以外的编译器确实使用 xchg
进行存储,即使它们不关心读取旧值也是如此。
做出此设计决定的动机:
没有它,软件将不得不使用 1 字节锁(或某种可用的原子类型)来保护对 32 位整数的访问,与全局时间戳变量之类的共享原子读取访问相比,这是非常低效的由定时器中断更新。它在硅片中可能基本上是免费的,以保证 bus-width 或更小的对齐访问。
要使锁定成为可能,需要某种原子访问。 (实际上,我猜硬件可以提供某种完全不同的 hardware-assisted 锁定机制。)对于在其外部数据总线上进行 32 位传输的 CPU,将它作为单元是有意义的原子性。
既然你提供了赏金,我想你正在寻找一个涉及所有有趣的边话题的长答案。如果您认为我没有涵盖的内容会使此问答对未来的读者更有价值,请告诉我。
既然你 linked one in the question,我强烈建议阅读更多 Jeff Preshing 的博文。它们非常出色,帮助我将我所知道的各个部分组合起来,理解 C/C++ 源代码与不同硬件架构的 asm 中的内存顺序,以及如何/何时告诉编译器你想要什么如果你不是直接写 asm。
如果你问为什么这样设计,我会说这是 CPU 架构设计的一个很好的副产品。
回到 486 时代,没有多核 CPU 或 QPI link,所以当时原子性并不是一个严格的要求(DMA 可能需要它?)。
在 x86 上,数据宽度为 32 位(或 x86_64 为 64 位),这意味着 CPU 一次可以读取和写入数据宽度。内存数据总线通常与这个数字相同或更宽。结合对齐地址上的 reading/writing 是一次完成的事实,自然没有什么可以阻止 read/write 成为非原子的。你同时获得speed/atomic。