C ++ 11标准定义了一个内存模型(1.7,1.10),其中包含内存排序,它们大致是“顺序一致”,“获取”,“消费”,“释放“,”放松“。同样粗略地说,一个程序只有在没有竞争的情况下才是正确的,如果所有动作都按某种顺序放置,其中一个动作发生在另一个动作之前,就会发生这种情况。行动 X发生在行动 Y 之前的方式是 X 在 Y 之前排序<一个线程),或 X线程间 - 发生在Y 之前。其中,后者的条件是
与同步,并且 Y 是一个原子加载对同一个变量进行“获取”排序。在 Y 加载“消耗”排序(以及合适的内存访问)的类似情况下,依赖顺序 - 在之前发生。 synchronize-with 的概念将发生在之间的关系传递到一个线程内序列 - 之前之间的传递,但是 >依赖 - 有序 - 在之前只通过在之前的进行依赖的严格子集进行传递扩展,该子集遵循大量规则,并且特别是可以用std::kill_dependency
打断。
那么,“依赖性排序”这个概念的目的是什么?与 / 同步 - 排序之前更简单的序列相比,它提供了什么优势?由于它的规则更严格,我认为可以更有效地实施。
您能举例说明一个程序,从发布/获取到发布/消费的转换是正确的,并提供了一个非平凡的优势吗?什么时候会std::kill_dependency
提供改进?高级参数会很好,但硬件特定差异的奖励点。
答案 0 :(得分:13)
N2492引入了数据依赖性排序,其基本原理如下:
有两个重要的用例,当前的工作草案(N2461)不支持某些现有硬件附近的可扩展性。
- 读取对很少编写的并发数据结构的访问
很少编写的并发数据结构在操作系统内核和服务器式应用程序中都很常见。示例包括表示外部状态的数据结构(例如路由表),软件配置(当前加载的模块),硬件配置(当前使用的存储设备)和安全策略(访问控制权限,防火墙规则)。读写比率远远超过十亿比一是很常见。
- 指针介导的发布的发布 - 订阅语义
线程之间的大量通信是指针介导的,其中生产者发布指针,消费者可以通过该指针访问信息。在没有完全获取语义的情况下,可以访问该数据。
在这种情况下,使用线程间数据依赖性排序导致了数量级的加速,并且在支持线程间数据依赖性排序的机器上的可伸缩性方面也有类似的改进。这样的加速是可能的,因为这样的机器可以避免昂贵的锁定采集,原子指令或其他需要的内存栅栏。
强调我的
来自Linux内核的rcu_dereference()
提供的激励用例
答案 1 :(得分:9)
Load-consume与load-acquire非常相似,只不过它会导致之前的关系仅发生在依赖于load-consume的数据依赖的表达式求值上。使用kill_dependency
包装表达式会导致一个值不再承载来自load-consume的依赖项。
关键用例是编写器按顺序构造数据结构,然后将共享指针摆动到新结构(使用release
或acq_rel
原子)。读者使用load-consume来读取指针,并取消引用它以获取数据结构。取消引用会创建数据依赖关系,因此读者可以保证看到初始化的数据。
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上,所有加载都是获取的,因此在初始编译时,consume不会提供直接的性能优势。) 第二个原因是编译器可以在没有数据依赖性的情况下移动以后的操作,直到消耗之前,它不能用于获取。 (启用此类优化是将所有内存排序构建到语言中的重要原因。)
使用kill_dependency
包装值允许计算表达式,该表达式取决于在加载消耗之前要移动到的值。这很有用,例如当值是先前读取的数组的索引时。
请注意,使用消耗会导致之前发生的关系不再具有传递性(尽管它仍然保证是非循环的)。例如,商店到bar
发生在foo商店之前,这发生在y
的解除引用之前,这发生在读取bar
之前(在注释掉的断言中),但是bar
的商店在阅读bar
之前没有发生。这导致了一个相当复杂的发生之前的定义,但你可以想象它是如何工作的(从之前的序列开始,然后通过任意数量的release-consume-dataDependency或release-acquire-sequencedBefore链接传播)
答案 2 :(得分:7)
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实际上处理的语义与获取有任何不同,可能是因为一个bug。尽管如此,它表明如果编译器编写者能够弄清楚如何利用它,那么可以获得加速。
答案 3 :(得分:5)
我想记录一个部分发现,即使这不是一个真正的答案,并不意味着没有一个大的赏金给予正确答案。
在盯着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
和{{}} {1}} 跨线程发生在 B2
之前由于dob;但我们可以争辩说你永远不会真正告诉!)。但是,不是真的 C2
发生在 A2
之前。事件D2
和A2
不是相互排序的,除非实际上认为D2
带有依赖关系 { {1}}。这是一个更严格的要求,如果没有该要求,则C2
- 至 - D2
不能在“发布/消费对”之间进行排序。
换句话说,释放/消费对仅传播从一个到下一个携带依赖关系的动作的顺序。所有不依赖的东西都没有在发布/消费对中排序。
此外,请注意,如果我们附加最终的,更强的释放/获取对,则会恢复排序:
A2
现在,根据引用的规则,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
正在做什么,因为没有依赖。