函数调用在现代平台上是否是有效的内存屏障?

73

在我审查的代码库中,我发现了以下用法。

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),这确实不是一个问题吗?
这违反了我所学的一切,但他有着长长的胡须和非常聪明的外表!
如果您能向我展示一段代码证明他是错误的,那就更好了!

5
当然他是正确的,他有经验。他本可以提到写入或读取管道或套接字始终涉及在内核中获取锁,这意味着存在一个屏障,但要向年轻的年轻人证明这一点需要花费很多时间。 - Hans Passant
1
@HansPassant,内核不能决定在系统调用之前将其移动到另一个线程以提高性能吗?这不可能发生吗? - mikebloch
3
问题1:内存屏障是“将缓存刷新到主内存”还是仅保证“所有写入都已被写入缓存”?缓存一致性机制不能处理核心之间的缓存竞争吗?回答1:内存屏障只是保证了“所有写入已经被写入缓存”,并没有直接将缓存刷新到主内存。缓存一致性机制可以处理核心之间的缓存竞争。回答2:处理器延迟写入的时间会因处理器类型和系统配置而异。一般来说,这个时间是以几十甚至上百条机器指令的数量级进行计算。这个时间不断增加的趋势取决于特定的处理器和系统的设计。在调用notify()函数时,可能有数百甚至上千条机器指令即将执行。 - johnnycrash
2
你使用函数调用的事实实际上是一个内存屏障,编译器不会重新排列指令global.data = data。屏障不是为了编译器,而是为了硬件。 - brettwhiteman
1
我在三年半前向你承诺会给你一个赞,当时Stackoverflow每天的投票数还有限制(或者其他原因)。现在,1261天后,我回来了,履行这个承诺。请收下我的赞。+1。 - Mahmoud Al-Qudsi
显示剩余6条评论
4个回答

13

内存屏障不仅用于防止指令重排序。即使指令没有被重新排序,它仍可能导致缓存一致性问题。至于重排序-这取决于您的编译器和设置。ICC 在重排序方面非常激进。启用整个程序优化的 MSVC 也可能会重排序。

如果您的共享数据变量被声明为 volatile,尽管这并未在规范中说明,大多数编译器仍会在对变量进行读写时生成一个内存变量,并防止重排序。这不是使用 volatile 的正确方式,也不是其预期用途。

(如果我还有投票权,我会为您的问题点赞。)


2
但在x86/x64中,这真的是一个问题吗?我可以编写一个简短的程序来证明它失败吗?(感谢您的美言,技术讨论应该是有趣的)。 - mikebloch
1
x86对于缓存一致性做出了一些保证。x64则没有,但实际上英特尔意识到开发人员为x86编写了糟糕、不安全的代码,因此即使他们没有义务这样做,也不在规范中,仍会执行许多操作以原子方式执行,并执行缓存同步。然而,在ARM中,所有的赌注都是未知的。请参阅此帖子(虽然它不是针对x86的),了解更多信息和一些更加幽默的叙述:http://ridiculousfish.com/blog/posts/barrier.html - Mahmoud Al-Qudsi
1
你刚才说英特尔会奖励那些写糟糕代码、忽略文档的开发者,通过投入研发资源来解决他们自己造成的问题?可能会牺牲我的和你的CPU效率?唉,有时候我真的在想为什么我还要这么努力。 - mikebloch
2
@mike,他们试图在Itanic上这样做,我们都知道他们在那里多么失败。然后,AMD出现了,并说“这是一个64位平台,它将运行您的x86二进制文件并且运行您的糟糕代码,只需重新编译为x64而不纠正错误”,因此x86_64诞生了。 - Mahmoud Al-Qudsi
虽然volatile关键字不能保证内存屏障或线程安全,但它可以保护多线程应用程序免受编译器执行不正确的优化所导致的错误,因为它会注意到您的线程回调函数在代码中没有被调用。这在现代x86编译器上可能不太可能发生,但在低级嵌入式编译器上更有可能发生。 - Lundin
所有的ISA都保证所有的CPU共享内存的一致视图(至少对于我们想要在多个线程之间运行的核心来说)。x86和x86-64具有相同的汇编/硬件内存排序模型:程序顺序+带有存储转发的存储缓冲区。https://preshing.com/20120930/weak-vs-strong-memory-models/。英特尔的手册甚至没有区分:“x86”(32位模式)只是x86-64的子模式(称为“遗留模式”)。当前的x86-64 CPU与最后的32位CPU(如Pentium III和Pentium-M)一样具有运行时内存重排序的能力。 - Peter Cordes

11

实际上,函数调用是一个编译器屏障,这意味着编译器不会将全局内存访问移动到函数调用之后。需要注意的一点是,对于编译器已知一些信息的函数(例如内置函数、内联函数(请记住IPO!)等),情况可能有所不同。

因此,理论上需要处理器内存屏障(除了编译器屏障)才能使其正常工作。但是,由于您正在调用更改全局状态的系统调用 read 和 write,我非常确定内核在这些实现中某个地方发出了内存屏障。然而,并没有这样的保证,因此理论上你需要使用屏障。


3
实际上,内核模式代码等价于内存屏障吗?听起来很有道理,似乎老人是对的。ICC 不会能够在系统调用周围重新排序代码,因为它不知道内核将要做什么。 - mikebloch
1
@janneb 是的,但即使在病态情况下,那些系统调用也可能在不同的核心上运行,发出错误线程的内存屏障。 - mikebloch
1
@blaze:正如我在第二句话中试图解释的那样,编译器无法窥视函数调用,编译器必须假设可以访问全局状态。从编译器的角度来看,系统调用与共享库中的函数(除了函数原型之外没有可用信息)没有任何区别。 - janneb
1
@Lundin:编译器可以进行任何保留原始程序语义(外部可见副作用,如果你愿意)的转换。因此,如果它能够证明一个表达式是纯的(没有副作用)或者副作用不重要,它可以忽略序列点并重新排列操作。实际上,我不确定在外部函数调用之后重新排序任何东西(没有关于函数内部的信息)是否真的有什么好处,所以如果编译器不介意的话,这并不会让我感到惊讶。 - janneb
2
这实际上是对原问题唯一的直接回答。答案是:不是。函数调用始终是编译器屏障,但函数调用不能保证是内存屏障。只有在被调用函数中的代码包含内存屏障时,它才是内存屏障。 - Johannes Overmann
显示剩余6条评论

2
基本规则是:编译器必须使全局状态看起来与您编码的完全相同,但如果它可以证明给定的函数不使用全局变量,则可以按任何方式实现算法。
其结果是,传统编译器始终将另一个编译单元中的函数视为内存障碍,因为它们无法查看这些函数。越来越多的现代编译器正在增加“整个程序”或“链接时间”优化策略,这些策略会打破这些障碍,并导致编写不良代码失败,即使它已经运行良好多年。
如果所讨论的函数在共享库中,则无法查看其中的内容,但如果该函数是由C标准定义的,则不需要这样做--它已经知道函数的作用--因此您也必须小心这些内容。请注意,编译器不会识别内核调用,但插入编译器无法识别的内容(内联汇编或对汇编文件的函数调用)本身就会创建内存障碍。
在您的情况下,notify要么是编译器看不到内部的黑匣子(库函数),要么它将包含可识别的内存障碍,因此您很可能是安全的。
实际上,您必须编写非常糟糕的代码才能遇到此问题。

2

实际上,在这种特定情况下,他是正确的,内存屏障被隐含了。

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

真的,使用互斥锁或其他适当的构造。这是处理线程和编写可维护代码的唯一安全方式。

也许你会发现其他错误,比如如果调用send()多次,则代码是不可预测的。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接