函数是现代平台的有效内存屏障吗?

在我查看的一个代码库中,我发现了以下习语。

void notify(struct actor_t act) {
write(act.pipe, "M", 1);
}
// thread A sending data to thread B
void send(byte *data) {
global.data = data;
notify(threadB);
}
// in thread B event loop
read(this.sock, &cmd, 1);
switch (cmd) {
case 'M': use_data(global.data);break;
...
}

“等等,”我对作者说,他是我团队的一位资深成员,“这里没有记忆障碍!您不能保证将 global.data从缓存刷新到主内存。如果线程 A 和线程 B 在两个不同的处理器上运行——这个方案可能会失败”。

高级程序员咧嘴一笑,慢慢地解释,好像在向他五岁的儿子解释如何系鞋带: “听着,年轻人,我们在这里看到了许多与线程相关的 bug,在高负载测试中,以及在真正的客户端中,”他停下来抓了抓自己长长的胡子,“但是我们从来没有遇到过这个习惯用法的 bug。”。

“但是,书上说...”

“安静!”,他马上让我安静下来,“也许从理论上讲,这是不能保证的,但在实践中,你使用函数调用的事实实际上是一个记忆障碍。编译器不会重新排序指令 global.data = data,因为它不知道是否有人在函数调用中使用它,而 x86体系结构将确保其他 CPU 在线程 B 从管道读取命令时看到这段全局数据。请放心,我们有足够的现实世界的问题要担心。我们不需要在虚假的理论问题上投入额外的精力。

“放心吧,我的孩子,假以时日,你就会明白,把真正的问题与我需要获得博士学位的无关问题区分开来。”

他是正确的吗? 在实践中(比如 x86、 x64和 ARM) ,这真的不是问题吗?

这和我学到的一切都不一样,但是他确实留着长胡子,而且长得很帅!

如果你能给我一段代码证明他是错的,那就加分!

6172 次浏览

内存屏障不仅仅是为了防止指令重新排序。即使指令没有重新排序,它仍然会导致缓存一致性问题。至于重新排序-它取决于你的编译器和设置。ICC 在重新排序方面特别积极。全部程序优化也可以。

如果共享数据变量声明为 volatile,大多数 尽管这不在规格之内编译器将围绕该变量的读写生成一个内存变量,并防止重新排序。这不是使用 volatile的正确方法,也不是它的用途。

(如果我还有票的话,我会在旁白中加一个你的问题。)

实际上,函数调用是一个 编译器屏障,这意味着编译器不会将全局内存访问移动到调用之后。对此需要注意的是编译器对函数有所了解,例如内置函数、内联函数(请记住 IPO!)等等。

因此,理论上需要一个处理器内存屏障(除了编译器屏障)来实现这一点。但是,由于您调用的读和写是改变全局状态的系统调用,所以我很确定内核在实现这些调用时会在某个地方发出内存障碍。然而,没有这样的保证,因此理论上你需要这些障碍。

基本规则是: 编译器必须使全局状态 出现完全符合您的编码,但是如果它能证明给定的函数不使用全局变量,那么它可以以任何方式实现算法。

结果是,传统的编译器总是将函数 在另一个编译单元视为内存屏障,因为它们看不到这些函数的内部。越来越多的现代编译器正在发展“整体程序”或“链接时间”优化策略,这些策略打破了这些障碍,威尔导致编写不好的代码失败,尽管它已经工作了很多年。

如果这个函数在一个共享库中,那么它就不能看到它的内部,如果这个函数是由 C 标准定义的,那么它就不需要——它已经知道这个函数是做什么的——所以你也必须小心这些。请注意,编译器会识别内核调用的内容,但是插入编译器无法识别的内容(内联汇编,或者对汇编文件的函数调用)本身就会造成内存障碍。

在这种情况下,notify要么是编译器看不到内部的黑盒(一个库函数) ,要么包含一个可识别的内存屏障,因此您很可能是安全的。

实际上,您必须编写 非常错误代码才能避免这种情况。

在实践中,他是正确的,并且在这个特定的案例中暗示了一个记忆障碍。

但问题是,如果它的存在是“有争议的”,那么代码已经太复杂和不清楚了。

真的,伙计们,使用一个互斥体或其他合适的构造。这是处理线程和编写可维护代码的唯一安全方法。

也许您还会看到其他错误,比如,如果 send ()被多次调用,代码是不可预测的。