C11中的内存顺序consume用法

5

我读到了关于“携带依赖关系(carries a dependency)”和“依赖有序前驱关系(dependency-ordered before)”的信息,其中一个定义在5.1.2.4(p16)中:

如果一个评估A对一个原子对象M执行了一个释放操作,并且在另一个线程中,B执行了M上的一个消费操作并读取了由A所引导的释放序列中的任何副作用写入的值,那么评估A就在依赖有序前驱于评估B;或者

对于某些评估XA在依赖方面有序地出现在X之前,并且X携带了一个依赖关系到B

因此,我尝试设计一个可能有用的示例。以下是示例:

static _Atomic int i;

void *produce(void *ptr){
    int int_value = *((int *) ptr);
    atomic_store_explicit(&i, int_value, memory_order_release);
    return NULL;
}

void *consume(void *ignored){
    int int_value = atomic_load_explicit(&i, memory_order_consume);
    int new_int_value = int_value + 42;
    printf("Consumed = %d\n", new_int_value);
}

int main(int args, const char *argv[]){
    int int_value = 123123;
    pthread_t t2;
    pthread_create(&t2, NULL, &produce, &int_value);

    pthread_t t1;
    pthread_create(&t1, NULL, &consume, NULL);

    sleep(1000);
}

在函数void *consume(void*)中,int_value携带了对new_int_value的依赖关系,因此如果atomic_load_explicit(&i,memory_order_consume);读取由某些atomic_store_explicit(&i,int_value,memory_order_release);写入的值,则new_int_value计算是在atomic_store_explicit(&i,int_value,memory_order_release);之前的依赖顺序。

但是,这个dependency-ordered-before有什么用处呢?

我认为目前memory_order_consume可以被memory_order_acquire替换而不会导致任何数据竞争...

2个回答

14

consumeacquire更便宜。除DEC Alpha AXP以外的所有CPU(因其著名的弱内存模型1)都可以免费执行,不像acquire (除了x86和SPARC-TSO之外,在这些硬件上具有acq / rel内存排序,无需额外的屏障或特殊指令。)

在ARM / AArch64 / PowerPC / MIPS等弱顺序ISA上,consumerelaxed是唯一不需要任何额外屏障的排序,只需要普通的廉价加载指令。也就是说,除了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中依赖加载的重排序

但是当前的编译器只能放弃并加强consumeacquire

…而不是试图将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]       ; load r3 = mem
eor r0, r3,r3             ; r0 = r3^r3 = 0
ldr r4, [r1, r0]          ; load r4 = mem[r1+r0].  Ordered after the other load

需要注入对r0的依赖,并在加载r4之前加载r3,即使加载地址r1+r0始终只是r1,因为r3^r3 = 0。但仅限于那个加载,不包括所有其他后续加载;它不是获取屏障或获取加载。

2
@某个名字:在x86上,我们免费拥有“acquire”,“consume”并不有趣或特别。但是在ARM/AArch64/PowerPC/MIPS等平台上,我们只有“consume”(和“relaxed”)是免费的,任何更多的操作都需要至少廉价的屏障。(虽然不像“seq_cst”所需的StoreLoad完整屏障那么昂贵)。x86的TSO内存模型是seq_cst +存储缓冲区(具有存储转发),因此存储只能变得更晚,而其他所有内容都是有序的。 - Peter Cordes
1
@SomeName:另外,我不确定你所说的“2个后续依赖负载”的意思。任何具有对consume负载数据依赖关系的负载数量都将遵守因果关系。例如,float *array = atomic_load(&shared, mo_consume);允许您循环访问array[i]并查看另一个线程在存储指向shared的指针之前存储的数据。或者使用ptr->aptr->b等的结构体指针。但是,如果您的意思是链接负载(如链表),那么每个步骤中的链都必须是消耗负载,而不是其他负载。(我还没有仔细研究C标准中的消耗负载措辞。) - Peter Cordes
3
@SomeName说的是,"consume"和"acquire"类似,但只针对依赖于"consume"加载的数据加载操作。独立的加载/存储操作仍然可以在它之前发生,而不需要等待"consume"加载操作完成。 - Peter Cordes
1
我重新阅读了5.1.2.4的某些部分,看到了以下关于release-acquire/release-consume差异的正式解释:release与acquire同步,但release不与consume同步。根据inter-thread happens before定义中的第一个符号_A与X同步且X在B之前排序_,我们可以断言,在执行release的线程中,所有动作都在release之前排序(即使是对不相关的内存位置),这些动作对于之后的acquire可见,但由于缺乏synchronize with,这不能应用于consume。 - Some Name
3
@SomeName说:实际上我最近没有阅读过定义"consume"的标准中的语言,但我知道它在暴露有用的汇编行为方面应该是什么。但是没错,这很有道理,“synchronize-with”是技术性语言,意味着release之前的所有内容都在release之后发生,而consume的整个意义就在于不需要阻塞其他操作来等待此操作完成。 - Peter Cordes
显示剩余6条评论

1

memory_order_consume目前规范不够明确,有正在进行的工作来解决这个问题。目前据我所知,所有实现都会将其隐式提升为memory_order_acquire


了解引入memory_order_consume背后的动机会很有趣... - Some Name
我认为这主要是为了实现RCU,可以参考Paul McKenney的工作。 - janneb
“Underspecified”,还是过于详细但毫无用处的说明? - curiousguy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接