"acquire" 和"consume"内存顺序有何不同,何时"consume"可取?

How do "acquire" and "consume" memory orders differ, and when is "consume" preferable?

本文关键字:consume 何时 何不同 可取 顺序 acquire 内存      更新时间:2023-10-16

C++11标准定义了一个内存模型(1.7,1.10),其中包含内存顺序,大致上是"顺序一致","获取","消耗","释放"和"放松"。同样粗略地说,一个程序只有在没有种族的情况下才是正确的,如果所有动作都可以按照某种顺序排列,其中一个动作发生在另一个动作之前,就会发生这种情况。动作 X 在动作 Y 之前发生的方式是,X 在 Y 之前(在一个线程内)之前排序,或者 X 线程间在 Y 之前发生后一个条件是在以下情况下给出的:

  • XY 同步,或
  • XY 之前按依赖顺序排序。
当 X 是原子存储,对某个原子变量具有"释放"排序,

Y 是在同一变量上具有"获取"排序的原子负载时,就会发生同步在类似情况下,Y以"消耗"排序(和适当的内存访问)加载时,会发生依赖排序。syncs-with 的概念在线程中相互排序的操作之间传递地扩展了发生前关系,但之前进行依赖排序只能通过称为携带依赖关系的严格排序之前子集进行传递扩展,该子集遵循一组庞大的规则,并且可以通过std::kill_dependency中断。

那么,"依赖排序"概念的目的是什么呢?与更简单的排序/同步排序相比,它有什么优势?由于它的规则更严格,我认为可以更有效地实施。

您能否举一个程序的示例,其中从发布/获取切换到发布/消费既正确又具有不平凡的优势?std::kill_dependency什么时候会提供改进?高级参数会很好,但特定于硬件的差异会加分。

负载-消耗与负载获取非常相似,只是它仅将发生之前的关系诱导到依赖于负载消耗的数据的表达式计算。用 kill_dependency 包装表达式会导致值不再承载负载消耗的依赖项。

关键用例是编写器按顺序构造数据结构,然后将共享指针指向新结构(使用 releaseacq_rel原子)。读取器使用负载消耗来读取指针,并取消引用它以获取数据结构。取消引用会创建数据依赖项,因此可以保证读取器看到初始化的数据。

std::atomic<int *> foo {nullptr};
std::atomic<int> bar;
void thread1()
{
    bar = 7;
    int * x = new int {51};
    foo.store(x, std::memory_order_release);
}
void thread2()
{
    int *y = foo.load(std::memory_order_consume)
    if (y)
    {
        assert(*y == 51); //succeeds
        // assert(bar == 7); //undefined behavior - could race with the store to bar 
        // assert(kill_dependency(*y) + bar == 58) // undefined behavior (same reason)
        assert(*y + bar == 58); // succeeds - evaluation of bar pulled into the dependency 
    }
}

提供负载消耗有两个原因。主要原因是 ARM 和电源负载可以保证消耗,但需要额外的屏蔽才能将它们转换为采集。(在 x86 上,所有负载都是获取的,因此在朴素编译下,消耗不会提供直接的性能优势。次要原因是编译器可以将不依赖数据的后续操作移动到使用之前,而这对于获取无法做到。(启用此类优化是将所有这些内存排序构建到语言中的重要原因。

使用 kill_dependency 包装值允许计算表达式,该表达式取决于在加载使用之前要移动到的值。这很有用,例如,当值是先前读取的数组的索引时。

请注意,使用 consumption 会导致一个不再传递的先发生前关系(尽管它仍然保证是非循环的)。例如,要bar的存储发生在存储 foo 之前,这发生在 y 的取消引用之前,这发生在读取bar之前(在注释掉的断言中),但要bar的存储不会发生在读取bar之前。这导致了一个相当复杂的定义之前发生,但你可以想象它是如何工作的(从之前排序开始,然后通过任意数量的发布-消费-数据依赖关系或发布-获取-排序之前链接传播)

数据依赖排序由 N2492 引入,其基本原理如下:

有两个重要的用例,即当前工作草案 (N2461) 不支持接近某些现有硬件上可能的可扩展性。

  • 对很少写入的并发数据结构的读取访问权限

很少写入的并发数据结构在操作系统内核和服务器样式应用程序中都很常见。示例包括表示外部状态(如路由表)、软件配置(当前加载的模块)、硬件配置(当前正在使用的存储设备)和安全策略(访问控制权限、防火墙规则)的数据结构。读写比率远远超过十亿比一是很常见的。

  • 指针介导发布的发布-订阅语义

线程之间的许多通信都是指针介导的,其中生产者发布一个指针,使用者可以通过该指针访问信息。无需完全获取语义即可访问该数据。

在这种情况下,使用线程间数据依赖关系排序会导致

支持线程间数据依赖关系排序的计算机上数量级的加速和可伸缩性的类似改进。这种加速是可能的,因为这样的机器可以避免昂贵的锁采集、原子指令或内存围栏,否则需要

强调我的

那里介绍的激励用例来自 Linux 内核rcu_dereference()

Jeff Preshing 有一篇很棒的博客文章来回答这个问题。 我自己不能添加任何东西,但认为任何想知道消费与获取的人都应该阅读他的帖子:

http://preshing.com/20140709/the-purpose-of-memory_order_consume-in-cpp11/

他展示了一个特定的C++示例,其中包含跨三种不同架构的相应基准汇编代码。 与memory_order_acquire相比,memory_order_consume可能在PowerPC上提供3倍的加速,在ARM上提供1.6倍的加速,在x86上可以忽略不计的加速比,无论如何都具有很强的一致性。 问题是,在他编写它时,只有 GCC 实际上处理消费语义与获取有任何不同,可能是因为错误。 尽管如此,它表明,如果编译器编写者能够弄清楚如何利用它,则可以使用加速。

我想

记录一个部分发现,即使这不是一个真正的答案,也不意味着不会有大笔赏金来获得正确的答案。

在盯着1.10看了一会儿之后,特别是第11段中非常有用的注释,我认为这实际上并不难。同步与(以后:s/w)和依赖排序之前(dob)之间的最大区别在于,可以通过任意连接之前(s/b)和s/w来建立发生之前的关系,但对于dob 不是这样。请注意,线程间的定义之一发生在之前

AX同步,XB之前排序

但是A的类似语句是在缺少X之前进行依赖排序的!

因此,通过释放/获取(即 s/w),我们可以对任意事件进行排序:

A1    s/b    B1                                            Thread 1
                   s/w
                          C1    s/b    D1                  Thread 2

但是现在考虑一个任意的事件序列,如下所示:

A2    s/b    B2                                            Thread 1
                   dob
                          C2    s/b    D2                  Thread 2

在这个后续中,A2确实发生在C2之前(因为A2是 s/b B2并且由于 dob B2线程间发生在C2之前;但我们可以争辩说,你永远无法真正分辨!)。但是,A2发生在D2之前并不是真的。事件A2D2不是相对于彼此排序的,除非它实际上认为C2具有对D2的依赖性。这是一个更严格的要求,如果没有该要求,A2 to - D2不能"跨"发布/消费对排序。

换句话说,释放/消费对仅传播将依赖关系从一个传递到下一个的操作顺序。所有不依赖的内容都不会在发布/消费对中排序。

此外,请注意,如果我们附加一个最终的、更强的发布/获取对,则排序将恢复:

A2    s/b    B2                                                         Th 1
                   dob
                          C2    s/b    D2                               Th 2
                                             s/w
                                                    E2    s/b    F2     Th 3

现在,根据引用的规则,线程D2发生在F2之前,因此C2B2也是如此,因此A2发生在F2之前。但请注意,A2D2 之间仍然没有排序 — 排序仅在 A2以后的事件之间。

总之,依赖关系携带

是常规排序的严格子集,发布/消费对仅在携带依赖关系的操作之间提供排序。只要不需要更强的排序(例如,通过传递发布/获取对),理论上就有可能进行额外的优化,因为不在依赖链中的所有内容都可以自由地重新排序。


也许这里有一个有意义的例子?

std::atomic<int> foo(0);
int x = 0;
void thread1()
{
    x = 51;
    foo.store(10, std::memory_order_release);
}
void thread2()
{
    if (foo.load(std::memory_order_acquire) == 10)
    {
        assert(x == 51);
    }
}

如前所述,代码是无争用的,断言将成立,因为释放/获取对在断言中加载之前对存储x = 51进行排序。然而,通过将"获取"更改为"消费",这将不再是正确的,程序将在x上进行数据竞赛,因为x = 51不依赖于存储foo。优化点是,这个存储可以自由地重新排序,而不用关心foo在做什么,因为没有依赖关系。