tim*_*lyo 28 c c++ concurrency x86 atomic
我一直在读这篇关于原子操作的文章,它提到了32位整数赋值在x86上是原子的,只要该变量是自然对齐的.
为什么自然对齐确保原子性?
Pet*_*des 40
"自然"对齐意味着与其自身的类型宽度对齐.因此,加载/存储将永远不会被划分为比其自身更宽的任何类型的边界(例如,页面,缓存行,或者甚至更窄的块大小,用于不同缓存之间的数据传输).
CPU经常执行诸如高速缓存访问或内核之间的高速缓存行传输之类的功能,以2个2的幂大小的块,因此小于高速缓存行的对齐边界确实很重要.(参见下面的@ BeeOnRope评论).有关CPU如何在内部实现原子加载或存储的更多详细信息,请参阅x86上的Atomicity,并且"num num"中的num num是原子的吗?更多有关如何原子像RMW操作atomic<int>::fetch_add()/ lock xadd在内部实现.
首先,假设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向前迈出了一大步.现在你不再需要关心编译器为plain做什么了int; 只是用atomic<int>.如果你发现旧指南谈论的原子性int,它们可能早于C++ 11.
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
Run Code Online (Sandbox Code Playgroud)
侧注:因为atomic<T>大于CPU可以原子方式做(所以.is_lock_free()是假的),请参阅std :: atomic的锁在哪里?. int但是int64_t//uint64_t在所有主要的x86编译器上都是无锁的.
因此,我们只需谈谈像insn这样的行为mov [shared], eax.
TL; DR:x86 ISA保证自然对齐的存储和加载是原子的,高达64位宽. 因此编译器可以使用普通的存储/加载,只要它们确保std::atomic<T>具有自然对齐.
(但请注意,i386 gcc -m32无法为C11 _Atomic64位类型执行此操作,只将它们与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标签wiki以获取链接:所有卷的当前版本,或直接链接到2015年12月vol3 pdf的第256页)
在x86术语中,"字"是两个8位字节.32位是双字或DWORD.
第8.1.1节保证原子操作
Intel486处理器(以及之后的新处理器)保证始终以原子方式执行以下基本内存操作:
- 读或写一个字节
- 读取或写入在16位边界上对齐的字
- 读取或写入在32位边界上对齐的双字 (这是另一种说"自然对齐"的方式)
我加粗的最后一点是您的问题的答案:此行为是处理器成为x86 CPU(即ISA的实现)所需的一部分.
本节的其余部分为较新的Intel CPU提供了进一步的保证:Pentium将此保证扩展到64位.
奔腾处理器(以及更新的处理器)保证以下额外的内存操作将始终以原子方式执行:
- 读取或写入在64位边界上对齐的四字 (例如x87的加载/存储
double,或者cmpxchg8b(在Pentium P5中是新的))- 16位访问非缓存内存位置,适合32位数据总线.
本节继续指出跨越缓存行(和页面边界)的访问分割不保证是原子的,并且:
"可以使用多个存储器访问来实现访问大于四字的数据的x87指令或SSE指令."
所以整数,的x87和MMX/SSE加载/存储到64B,即使在32位或16位模式(例如movq,movsd,movhps,pinsrq,extractps,等等)是如果数据被对准原子. gcc -m32用于movq xmm, [mem]实现原子64位负载std::atomic<int64_t>.-m32不幸的是,Clang4.0 使用了lock cmpxchg8b 错误33109.
在一些具有128b或256b内部数据路径(在执行单元和L1之间以及不同高速缓存之间)的CPU上,128b甚至256b向量加载/存储都是原子的,但这在任何标准中都不能保证,或者在运行时很容易查询,不幸的是编译器实现std::atomic<__int128>或16B结构.
如果您想在所有x86系统中使用原子128b,则必须使用lock cmpxchg16b(仅在64位模式下可用).(它在第一代x86-64 CPU中不可用.你需要使用-mcx16gcc/clang 来发出它.)
甚至内部执行原子128b加载/存储的CPU也可以在具有以较小块运行的一致性协议的多插槽系统中表现出非原子行为:例如AMD Opteron 2435(K10),线程在单独的套接字上运行,与HyperTransport连接.
英特尔和AMD的手册因未对齐可访问可缓存内存而分歧.所有x86 CPU的通用子集都是AMD规则.可缓存意味着回写或直写存储器区域,而不是不可缓存或写入组合,如PAT或MTRR区域所设置.它们并不意味着缓存行必须在L1缓存中已经很热.
AMD保证适合单个8B对齐块的可缓存加载/存储的原子性.这是有道理的,因为我们从多插座Opteron的16B商店测试中知道HyperTransport仅以8B块传输,并且在传输时不会锁定以防止撕裂.(往上看).我想lock cmpxchg16b一定要特别处理.
可能相关:AMD使用MOESI直接在不同内核中的缓存之间共享脏缓存行,因此一个内核可以从其缓存行的有效副本读取,而对其的更新则来自另一个缓存.
英特尔使用MESIF,它需要将脏数据传播到大型共享包含L3缓存,该缓存充当一致性流量的后盾.L3是包含每个核心L2/L1高速缓存的标记,即使对于必须在L3中处于无效状态的行,因为在每个核心的L1高速缓存中是M或E. Haswell/Skylake中L3和每核心高速缓存之间的数据路径仅为32B宽,因此它必须缓冲或某些东西以避免在读取两半高速缓存行之间发生从一个核心写入L3,这可能导致撕裂32B边界.
手册的相关部分:
P6系列处理器(以及更新的英特尔处理器)保证以下额外的内存操作将始终以原子方式执行:
- 未对齐的16位,32位和64位访问缓存内存,适合缓存行.
AMD64手册7.3.2访问原子性可
缓存,自然对齐的单个加载或最多四字的存储在任何处理器模型上都是原子的,未对齐的加载或小于四字的存储完全包含在自然对齐的四字中
请注意,AMD保证任何小于qword的负载的原子性,但Intel仅保证2的2的幂.32位保护模式和64位长模式可以48比特加载m16:32作为存储器操作数到cs:eip与远call或远jmp.(并且远程调用会在堆栈上推送内容.)IDK如果计为单个48位访问或单独的16位和32位.
已经尝试将x86内存模型形式化,最新的一个是2009年的x86-TSO(扩展版)论文(链接来自x86标签wiki 的内存排序部分).它没有用,因为它们定义了一些用他们自己的符号来表达事物的符号,我没有试过真正读过它.IDK,如果它描述了原子性规则,或者它只涉及内存排序.
我提到过cmpxchg8b,但我只讨论了加载和存储,每个都是原子的(即没有"撕裂",其中一半的负载来自一个商店,另一半的负载来自不同的商店).
为了防止在加载和存储之间修改该内存位置的内容,您需要,就像您需要整个读取 - 修改 - 写入是原子的一样.还要注意,即使没有单个原子负载(以及可选的存储),一般来说将它用作具有预期=期望的64b负载也是不安全的.如果内存中的值恰好符合您的预期,那么您将获得该位置的非原子读取 - 修改 - 写入.lock cmpxchg8block inc [mem]cmpxchg8block
该lock前缀甚至使得对齐高速缓存行或页面边界的未对齐访问成为原子,但您无法使用它mov来创建未对齐的存储或加载原子.它只适用于内存目的地读 - 修改 - 写指令add [mem], eax.
(lock隐含于xchg reg, [mem],所以不要使用xchgmem来保存代码大小或指令数,除非性能无关紧要.只在你想要内存屏障和/或原子交换时使用它,或者当代码大小是唯一的东西时使用它这很重要,例如在引导扇区.)
lock mov [mem], reg不存在原子未对齐的商店从一个insn REF手册(英特尔86手动VOL2) cmpxchg:
该指令可以与
LOCK前缀一起使用,以允许指令以原子方式执行.为了简化处理器总线的接口,目标操作数接收写周期而不考虑比较结果.如果比较失败,则写回目标操作数; 否则,源操作数将写入目标.(处理器从不产生锁定读取而不产生锁定写入.)
在将内存控制器内置到CPU之前,此设计决策降低了芯片组的复杂性.它仍然可以用于lockMMIO区域上的指令,这些指令击中PCI-express总线而不是DRAM.lock mov reg, [MMIO_PORT]对于产生写入以及对存储器映射的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,除了破坏标志和执行锁定操作之外,这是一个无操作. [esp]在L1缓存中几乎总是很热,并且不会引起与任何其他核心的争用.这个成语可能仍然比MFENCE作为独立的内存屏障更有效,特别是在AMD CPU上.
xchg [mem], reg在英特尔和AMD上,可能是实现顺序一致性存储的最有效方式,而不是mov+ mfence. mfence在Skylake上至少阻止非内存指令的无序执行,但xchg其他locked操作则不然. gcc以外的编译器确实xchg用于商店,即使他们不关心阅读旧值.
没有它,软件将不得不使用1字节锁(或某种可用的原子类型)来保护对32位整数的访问,与共享原子读访问相比,这对于像定时器中断更新的全局时间戳变量一样极其低效. .它可能基本上是免费的硅片,以保证总线宽度或更小的对齐访问.
为了使锁定成为可能,需要某种原子访问.(实际上,我猜硬件可以提供某种完全不同的硬件辅助锁定机制.)对于在外部数据总线上进行32位传输的CPU,将其作为原子性单位是有意义的.
既然你提供了赏金,我认为你正在寻找一个长期回答所有有趣的话题.让我知道,如果有一些我没有涵盖的内容,您认为这将使这个Q&A对未来的读者更有价值.
由于您在问题中链接了一个,我强烈建议您阅读更多Jeff Preshing的博文.它们非常出色,并帮助我将我所知道的部分组合在一起,了解C/C++源代码中的内存排序与不同硬件体系结构的asm,以及如何/何时告诉编译器你想要的内容如果你不是直接写asm.
如果32位或更小的对象在内存的"正常"部分内自然对齐,则除了80386sx之外的任何80386或兼容处理器都可以在单个操作中读取或写入对象的所有32位.虽然平台以快速和有用的方式做某事的能力并不一定意味着平台有时不会出于某种原因以某种其他方式做到这一点,而我相信它可能在很多(如果不是全部)x86处理器上有一个内存区域,一次只能访问8位或16位,我不认为英特尔曾经定义过任何条件,要求对"内存"的"正常"区域进行对齐的32位访问会导致系统读取或者写一部分价值而不读或写整件事,我不认为英特尔有意为"正常"的记忆区域定义任何这样的东西.
| 归档时间: |
|
| 查看次数: |
5386 次 |
| 最近记录: |