"acquire" 和"consume"内存顺序有何不同,何时"consume"可取?
How do "acquire" and "consume" memory orders differ, and when is "consume" preferable?
C++11标准定义了一个内存模型(1.7,1.10),其中包含内存顺序,大致上是"顺序一致","获取","消耗","释放"和"放松"。同样粗略地说,一个程序只有在没有种族的情况下才是正确的,如果所有动作都可以按照某种顺序排列,其中一个动作发生在另一个动作之前,就会发生这种情况。动作 X 在动作 Y 之前发生的方式是,X 在 Y 之前(在一个线程内)之前排序,或者 X 线程间在 Y 之前发生。后一个条件是在以下情况下给出的:
- X 与 Y 同步,或
- X 在 Y 之前按依赖顺序排序。
而 Y 是在同一变量上具有"获取"排序的原子负载时,就会发生同步。在类似情况下,Y以"消耗"排序(和适当的内存访问)加载时,会发生依赖排序。syncs-with 的概念在线程中相互排序的操作之间传递地扩展了发生前关系,但之前进行依赖排序只能通过称为携带依赖关系的严格排序之前子集进行传递扩展,该子集遵循一组庞大的规则,并且可以通过std::kill_dependency
中断。
那么,"依赖排序"概念的目的是什么呢?与更简单的排序前/同步排序相比,它有什么优势?由于它的规则更严格,我认为可以更有效地实施。
您能否举一个程序的示例,其中从发布/获取切换到发布/消费既正确又具有不平凡的优势?std::kill_dependency
什么时候会提供改进?高级参数会很好,但特定于硬件的差异会加分。
负载-消耗与负载获取非常相似,只是它仅将发生之前的关系诱导到依赖于负载消耗的数据的表达式计算。用 kill_dependency
包装表达式会导致值不再承载负载消耗的依赖项。
关键用例是编写器按顺序构造数据结构,然后将共享指针指向新结构(使用 release
或acq_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 则不是这样。请注意,线程间的定义之一发生在之前:
A
与X
同步,X
在B
之前排序
但是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
之前并不是真的。事件A2
和D2
不是相对于彼此排序的,除非它实际上认为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
之前,因此C2
和B2
也是如此,因此A2
发生在F2
之前。但请注意,A2
和 D2
之间仍然没有排序 — 排序仅在 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
在做什么,因为没有依赖关系。
- 何时在引用或唯一指针上使用移动语义
- 何时提供默认参数作为模板参数
- C++-明确何时以及如何调用析构函数
- 在以唯一ptr为值的C++映射中,动态内存何时会被销毁
- 何时应通过引用传递矢量参数而不是按值传递矢量参数?
- 如果非动态变量被指针引用,何时超出范围?
- 类作用域的类型别名"using":[何时]方法中的用法可以先于类型别名?
- 何时定义QT_NO_CONTEXTMENU?
- 何时为派生类初始化 vptr?
- 如何知道何时调用删除以及何时调用 delete[] C++?
- 指针的 C++ 动态数组 - 何时需要使用它?
- 我应该在 C++ 中何时/为什么使用 STATIC?
- 变量的值何时可以在C++中意外更改?
- 调用方如何知道 VARIANT 中何时有十进制?
- 何时应在构造函数参数中使用 const C++?
- 我可以有一个 ELI5 作为参考和指针以及何时使用它们吗?
- async_write完成处理程序最早何时完成?
- 何时返回指针与返回对象的一般经验法则?
- 为什么我的编译器无法弄清楚这种转换,它何时存在?
- "acquire" 和"consume"内存顺序有何不同,何时"consume"可取?