我一直在阅读关于原子操作的文章,这篇文章提到在x86架构上32位整数赋值是原子性的,只要变量自然对齐。
为什么自然对齐可以确保原子性?
我一直在阅读关于原子操作的文章,这篇文章提到在x86架构上32位整数赋值是原子性的,只要变量自然对齐。
为什么自然对齐可以确保原子性?
“自然”对齐意味着按照其自身类型的宽度进行对齐。因此,加载/存储永远不会跨越任何比它本身更宽的边界(例如页面、缓存行或用于不同缓存之间数据传输的甚至更窄的块大小)。
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位存储。volatile
一样,但具有实际保证,并得到了语言标准的官方支持。
在C++11之前,原子操作通常使用volatile
或其他方式完成,并且需要考虑“适用于我们关心的编译器”,因此C++11是一个巨大的进步。现在,您不再需要关心编译器对普通int
的操作;只需使用atomic<int>
即可。如果您发现旧的指南讨论了int
的原子性,则它们可能是C++11之前的版本。When to use volatile with multi threading?解释了为什么这在实践中有效,并且memory_order_relaxed
与atomic<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编译器上,int
和int64_t
/uint64_t
都是无锁的。
mov [shared], eax
这样的指令的行为。
std::atomic<T>
具有自然对齐,它就可以使用普通的存储/加载。gcc -m32
无法为结构体内的C11 _Atomic
64位类型执行此操作,仅将其对齐到4B,因此在某些情况下atomic_llong
可能不是原子性的。https://gcc.gnu.org/bugzilla/show_bug.cgi?id=65146#c4)。使用std::atomic
的g++ -m32
是可以的,至少在g++5中,因为https://gcc.gnu.org/bugzilla/show_bug.cgi?id=65147通过对<atomic>
头文件进行更改于2015年修复了该问题。但这并没有改变C11的行为。)
来自“Intel® 64和IA-32体系结构软件开发人员手册,第3卷”,附有我的斜体注释。(另请参见x86标签wiki获取所有卷的当前版本链接,或直接链接到2015年12月vol3 pdf的第256页)
在x86术语中,“字”是两个8位字节。32位是双字,或DWORD。
英特尔486处理器(以及之后的处理器)保证始终以原子方式执行以下基本内存操作:###第8.1.1节保证原子操作
因此,整数、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。
std::atomic<__int128>
或16B结构的编译器来说是不幸的。更新:x86供应商已经决定AVX特性位也指示了128位对齐加载/存储的原子性。在此之前,我们只有实验性测试来验证它。lock cmpxchg16b
(仅在64位模式下可用)。 (它在第一代x86-64 CPU中不可用。您需要与GCC / Clang一起使用-mcx16
以便它们发出。)Intel和AMD的手册在访问不对齐的可缓存内存时有所不同。所有x86 CPU的共同子集是AMD规则。可缓存意味着写回或写透传内存区域,而不是通过PAT或MTRR区域设置为不可缓存或写组合。它们并不意味着缓存行必须已经在L1高速缓存中。
lock cmpxchg16b
必须特殊处理。P6系列处理器以及新的英特尔处理器保证以下额外的内存操作始终是原子性的:
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(扩展版)论文(来自x86标签维基的内存排序部分的链接)。由于他们定义了一些符号来表达自己的记号,所以它不太容易浏览,我也没有尝试真正阅读它。我不知道它是否描述了原子性规则,还是仅关注内存排序。
我提到了cmpxchg8b
,但我只是在讨论单独的加载和存储各自是原子的(即没有“撕裂”,其中一半的加载来自一个存储器,另一半的加载来自不同的存储器)。
为了防止在加载和存储之间修改该内存位置的内容,您需要使用lock
cmpxchg8b
,就像您需要使用lock inc [mem]
一样,以使整个读-改-写过程成为原子操作。此外,请注意,即使cmpxchg8b
没有lock
执行单个原子加载(和可选存储),通常也不能将其用作带有期望=所需的64位加载。如果内存中的值恰好与您的期望匹配,则会对该位置进行非原子读-改-写。
lock
前缀使得即使是跨越缓存行或页面边界的未对齐访问也是原子性的,但你不能使用它与 mov
一起使未对齐的存储或加载变为原子性。它只能用于内存目标读-修改-写指令,如 add [mem], eax
。lock
在 xchg reg, [mem]
中是隐含的,因此除非性能无关紧要,否则不要使用 xchg
来保存代码大小或指令计数。只有在需要内存屏障和/或原子交换时,或者当代码大小是唯一重要的事情时,例如在引导扇区中,才使用它。)lock mov [mem], reg
根据指令参考手册(Intel x86 manual vol2)中的cmpxchg
:
该指令可使用
LOCK
前缀使其具有原子性。为简化处理器总线的接口,目标操作数将接收一个写周期,而不考虑比较结果。如果比较失败,则将目标操作数写回;否则,将源操作数写入目标操作数。(处理器永远不会产生仅有锁定读取而没有锁定写入的情况。)
这种设计决策减少了芯片组复杂度,在内存控制器集成到CPU之前很有用。在命中PCI-express总线而不是DRAM的MMIO区域上进行lock
ed指令仍然可能如此。但是对于lock mov reg,[MMIO_PORT]
来说,它既产生读取又产生写入,这将会令人困惑。
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的情况下如何/何时告诉编译器您想要什么。
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如果32位或更小的对象在“常规”内存部分自然对齐,那么除了80386sx处理器外,任何80386或兼容处理器都可以在单个操作中读取或写入该对象的所有32位。虽然平台能够以快速和有用的方式执行某项任务并不一定意味着该平台不会因为某些原因以其他方式执行该任务,并且虽然我相信在许多x86处理器中甚至所有处理器中都可能存在只能以8位或16位逐位访问的内存区域,但我认为英特尔从未定义请求对“常规”内存区域进行对齐的32位访问会导致系统在读取或写入整个值之前读取或写入部分值的任何条件,并且我认为英特尔没有任何意图为“常规”内存区域定义任何此类条件。
movnt
存储器。虽然可能有我忘记的晦涩的内存类型。除了常规的写回,还有写穿透。 - Peter Cordes01234567
...XXXX.
并且
int *data = (int*)3;
*data
时,组成该值的字节被分散在2个int大小的块中,其中1个字节位于块0-3中,而3个字节位于块4-7中。现在,仅仅因为这些块在逻辑上是相邻的,并不意味着它们在物理上也是相邻的。例如,块0-3可能位于CPU缓存行的末尾,而块3-7则位于页面文件中。当CPU尝试访问块3-7以获取所需的3个字节时,它可能会发现该块不在内存中并发出信号表示需要换入内存。这可能会阻塞调用进程,同时操作系统换入内存。Y
。然后您的进程重新调度,并且CPU完成读取,但现在它已经读取了XYXX,而不是您预期的XXXX。MOV
操作的原子性。我不知道为什么,但这很好。能够解释一下会很棒,但可能只有少数人能够回答这个问题。 - zx485