consume
比acquire
更便宜。除DEC Alpha AXP以外的所有CPU(因其著名的弱内存模型1)都可以免费执行,不像acquire
。 (除了x86和SPARC-TSO之外,在这些硬件上具有acq / rel内存排序,无需额外的屏障或特殊指令。)
在ARM / AArch64 / PowerPC / MIPS等弱顺序ISA上,consume
和relaxed
是唯一不需要任何额外屏障的排序,只需要普通的廉价加载指令。也就是说,除了Alpha之外,所有汇编加载指令都是(至少)consume
加载。acquire
需要LoadStore和LoadLoad排序,这是一种比完整屏障更便宜的屏障指令,但仍然比没有要贵。
mo_consume
类似于acquire
,只适用于与消耗负载存在数据依赖关系的负载。例如,float *array = atomic_ld(&shared, mo_consume);
,然后访问任何array[i]
如果生产者存储了缓冲区,然后使用mo_release
存储将指针写入共享变量,则是安全的。但是,独立的加载/存储不必等待consume
负载完成,即使它们在程序顺序中出现得更晚,它们也可以在其之前发生。因此,consume
仅对最少量进行排序,不影响其他加载或存储。
实现对CPU设计中支持“consume”语义基本上是免费的,因为乱序执行无法打破真正的依赖关系,而加载指针具有数据依赖性,因此加载指针然后对其进行解引用本质上通过因果关系对这两个加载进行排序。除非CPU进行值预测或其他一些疯狂的操作。值预测类似于分支预测,但是猜测将加载哪个值,而不是分支走向。
Alpha必须进行一些疯狂的操作,以使CPU能够从真正加载存储顺序足够的屏障之前加载数据。
与存储器不同,存储缓冲区可以在存储器执行和提交到L1d高速缓存之间引入重新排序,
加载通过在执行时从L1d高速缓存中获取数据来“可见”, 而不是在退役时+最终提交。因此,相对于彼此对2个加载进行排序确实意味着按顺序执行这2个加载。由于一个加载对另一个加载存在数据依赖性,因果关系要求在没有值预测的CPU上,大多数体系结构的ISA规则确实需要这样做。
因此,在汇编程序中加载+使用指针(例如遍历链接列表)之间不必使用屏障。
另请参见
CPU中依赖加载的重排序。
但是当前的编译器只能放弃并加强consume
到acquire
…而不是试图将C依赖关系映射到asm data依赖关系(不会意外破坏仅具有控制依赖性的分支预测+推测执行)。 显然,编译器很难跟踪它并使其安全。
将C映射到asm并不是微不足道的,因为如果依赖关系仅以条件分支的形式存在,则asm规则不适用。 因此,很难定义C规则,以便mo_consume
仅以与asm ISA规则中的“携带依赖项”相一致的方式传播依赖项。
因此,是的,您正确地指出consume
可以安全地替换为acquire
,但您完全错过了重点。
ISAs具有弱内存排序规则,确实有关于哪些指令携带依赖关系的规则。因此,即使是像ARM的
eor r0,r0
这样无条件将
r0
清零的指令,在架构上仍需要携带旧值的数据依赖关系,而不像x86那样将
xor eax,eax
惯用语视为特殊的依赖项破坏方式
2。
另请参见
http://preshing.com/20140709/the-purpose-of-memory_order_consume-in-cpp11/。
我还在
Atomic operations, std::atomic<> and ordering of writes的答案中提到了
mo_consume
。
注1:理论上可能“违反因果关系”的少数Alpha模型并没有进行价值预测,它们有一个不同的机制来管理缓存。我想我曾看到过更详细的解释,但Linus关于它实际上是多么罕见的评论很有趣。
Linus Torvalds(Linux首席开发人员),在RealWorldTech论坛主题中
我想知道你是否亲眼在Alpha上看到了非因果关系,还是只在手册上看到的?
我自己从未见过它,我认为我接触过的任何模型都没有做到这一点。这实际上使得(缓慢的)RMB指令变得特别恼人,因为它只是纯粹的劣势。
即使在实际上可以重新排序负载的CPU上,它似乎基本上不可能被击中。这实际上相当恶劣。它导致“哎呀,我忘记了屏障,但是十年来一切正常,现场报告了三个奇怪的“那不可能发生”的错误”之类的事情。弄清楚发生了什么就像地狱一样痛苦。
哪些模型实际上有它?它们是如何到达这里的?
我认为这是21264,我有一个模糊的记忆,认为这是由于分区高速缓存:即使原始CPU按顺序执行了两次写入(中间有wmb),读取CPU也可能延迟第一次写入(因为缓存分区正在更新其他内容),并且将首先读取第二次写入。如果第二次写入是第一次写入的地址,则可以跟随该指针,并且在没有读取屏障来同步缓存分区的情况下,它可能会看到旧的陈旧值。
但请注意“模糊的记忆”。我可能把它和其他东西混淆了。我现在已经接近20年没有使用过alpha了。您可以从值预测中获得非常相似的效果,但我认为任何Alpha微架构都没有这样做。
无论如何,肯定有Alpha的版本可以做到这一点,它不仅仅是纯理论。
(RMB = Read Memory Barrier汇编指令,或者Linux内核函数
rmb()
的名称,该函数包装了必要的内联汇编代码来实现这一点。例如,在x86上,只是一个编译时重排序的障碍,
asm("":::"memory")
。我认为现代Linux在只需要数据依赖性时可以避免获取屏障,不像C11/C++11,但我忘了。Linux只可移植到少数编译器,并且这些编译器确实会注意支持Linux所依赖的内容,因此它们比ISO C11标准更容易在实际ISA上烹制出可行的东西。)
(另请参见
https://lkml.org/lkml/2012/2/1/521关于Linux的
smp_read_barrier_depends()
,这仅在Alpha上才是必需的。 (但来自
Hans Boehm的回复指出“
编译器可以并且有时会删除依赖项”,这就是为什么C11
memory_order_consume
支持需要如此复杂以避免风险破裂。因此,
smp_read_barrier_depends
可能很脆弱。))
注脚2:x86会对所有的加载进行排序,无论它们是否携带指针上的数据依赖关系,因此不需要保留“错误”的依赖关系。由于指令集长度可变,使用
xor eax,eax
(2字节)代替
mov eax,0
(5字节)实际上可以节省代码大小。
因此,自从早期8086时代以来,xor reg,reg
已成为标准惯用语,并且现在被认为像mov
一样处理,与旧值或RAX无关。 (事实上,比mov reg,0
更有效率,除了代码大小之外:在x86汇编中将寄存器设置为零的最佳方法是什么:xor,mov还是and?)
但这对于ARM或大多数其他弱有序的ISA来说是不可能的,就像我说的那样,它们根本不允许这样做。
ldr r3, [something]
eor r0, r3,r3
ldr r4, [r1, r0]
需要注入对r0的依赖,并在加载r4之前加载r3,即使加载地址r1+r0始终只是r1,因为r3^r3 = 0。但仅限于那个加载,不包括所有其他后续加载;它不是获取屏障或获取加载。
consume
负载数据依赖关系的负载数量都将遵守因果关系。例如,float *array = atomic_load(&shared, mo_consume);
允许您循环访问array[i]
并查看另一个线程在存储指向shared
的指针之前存储的数据。或者使用ptr->a
、ptr->b
等的结构体指针。但是,如果您的意思是链接负载(如链表),那么每个步骤中的链都必须是消耗负载,而不是其他负载。(我还没有仔细研究C标准中的消耗负载措辞。) - Peter Cordesconsume
的整个意义就在于不需要阻塞其他操作来等待此操作完成。 - Peter Cordes