提问者:小点点

“获取”和“消耗”内存顺序如何不同,什么时候“消耗”更可取?


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

  • X与Y同步,或
  • X在Y之前是依赖排序的。

当X是一个原子存储,在某个原子变量上具有“释放”排序,而Y是一个原子加载,在同一变量上具有“获取”排序时,就会发生同步。依赖排序先于发生在类似的情况下,即Y以“消费”排序加载(以及合适的内存访问)。同步的概念在线程中以传递方式将发生关系扩展到被排序的操作之前,但依赖排序先于仅通过被称为进位依赖的排序先于的严格子集进行传递扩展,该子集遵循大量规则,特别是可以用std::kill_dependency中断。

那么,“依赖排序”概念的目的是什么?与更简单的排序前/同步排序相比,它提供了什么优势?由于它的规则更严格,我假设它可以更有效地实现。

你能举一个从发布/获取切换到发布/消费的程序的例子吗?什么时候std::kill_dependency会提供改进?高级参数会很好,但是硬件特定差异的加分。


共3个答案

匿名用户

加载-消费很像加载-获取,只是它只将发生在关系引入到依赖于加载-消费的数据的表达式计算中。用kill_dependency包装表达式会导致一个值不再携带加载-消费的依赖项。

关键用例是编写者顺序构造一个数据结构,然后摆动一个指向新结构的共享指针(使用releaseacq_rel原子)。阅读器使用load-消费读取指针,并取消引用以获取数据结构。取消引用创建了数据依赖关系,因此保证阅读器可以看到初始化的数据。

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和Power加载保证会消耗,但需要额外的防护才能将它们转换为获取。(在x86上,所有加载都被获取,因此在朴素编译下,消耗没有提供直接的性能优势。)第二个原因是编译器可以在不依赖数据的情况下将稍后的操作移动到消耗之前,这对于获取是做不到的。(启用此类优化是将所有这些内存排序构建到语言中的主要原因。)

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

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

匿名用户

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

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

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

很少写入的并发数据结构非常常见,无论是在操作系统内核还是在服务器风格的应用程序中。例子包括代表外部状态的数据结构(如路由表)、软件配置(当前加载的模块)、硬件配置(当前使用的存储设备)和安全策略(权限改造权限、防火墙规则)。读写比远远超过十亿比一是非常常见的。

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

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

在这种情况下,使用线程间数据依赖排序已经在支持线程间数据依赖排序的机器上导致了数量级的加速和类似的可扩展性改进。这种加速是可能的,因为这样的机器可以避免昂贵的锁获取、原子指令或内存栅栏,否则这些都是必需的。

强调地雷

这里展示的激励用例是来自Linux内核的rcu_dereference()

匿名用户

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

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真正将使用语义学与获取不同,这可能是因为bug。尽管如此,它证明了如果编译器编写者能够弄清楚如何利用它,加速是可用的。