在多线程的 C 代码中,我需要编写显式的内存屏障吗?

3

我正在使用pthread多线程库在Linux上编写一些代码,现在我想知道当使用-Ofast -lto -pthread编译时,下面的代码是否安全。

// shared global
long shared_event_count = 0;
// ...
pthread_mutex_lock(mutex);
while (shared_event_count <= *last_seen_event_count)
    pthread_cond_wait(cond, mutex);
*last_seen_event_count = shared_event_count;
pthread_mutex_unlock(mutex);

调用pthread_*函数是否足够,还是应该包含内存屏障以确保在循环期间全局变量shared_event_count的更改实际上已被更新?如果没有内存屏障,编译器可能自由地将变量优化为寄存器整数,对吗?当然,我可以将共享整数声明为volatile,这将防止在循环期间仅将变量内容保留在寄存器中,但如果我在循环中多次使用该变量,则仅检查循环条件的新状态可能更有意义,因为这可以允许更多的编译器优化。
经过测试,以上代码似乎正常工作并且另一个线程所做的更改也能被生成的代码看到。然而,是否有任何规范或文档实际上保证了这一点?
通常的解决方案似乎是“不要过度优化多线程代码”,但这似乎是一个贫民的解决方法,而不是真正解决问题。我宁愿编写正确的代码,并让编译器在规范范围内尽可能多地进行优化(任何被优化破坏的代码实际上都是在使用例如C标准的未定义行为等假定稳定行为,除了一些罕见的情况外,其中编译器实际上输出无效代码,但这在今天似乎非常非常罕见)。
我更喜欢编写适用于任何优化编译器的代码-因此,它应该仅使用C标准和pthread库文档中指定的功能。
我在https://www.alibabacloud.com/blog/597460找到了一篇有趣的文章,其中包含如下技巧:
#define ACCESS_ONCE(x) (*(volatile typeof(x) *)&(x))

这实际上是在Linux内核中首次使用,它触发了旧版GCC编译器的一个错误: https://lwn.net/Articles/624126/

因此,让我们假设编译器实际上遵循规范,并且不包含错误,但实现了规范允许的所有可能的优化。在此假设下,上述代码是否安全?

pthread_mutex_lock()是否根据规范包括内存屏障,或者编译器是否可以重新排列其周围的语句?


POSIX 1-2008规定的“[pthread_mutex_lock将会与其他线程同步内存”是否符合“按照规范”的要求? - pilcrow
1
关于“我解释POSIX 1-2008措辞”一文,不,内存同步正是内存屏障(又称内存栅栏)所提供的,根据定义。“没有关联内存位置的同步操作就是一个屏障”(C规范)。 - ikegami
在我们引用的任何 POSIX 相关资料中都没有提到翻译单元。 - ikegami
还可以在这里查看讨论:https://dev59.com/5HE95IYBdhLWcg3wDpYG#2485177 - 根据该讨论,微软的C编译器将volatile实现为内存屏障,但这不是C标准所要求的。 - Mikko Rantalainen
1
正确的,如果有问题,volatile 不是解决方案,但我们已经确定没有问题。 - ikegami
显示剩余6条评论
1个回答

4
编译器不会在 pthread_mutex_lock() 之间重新排列内存访问(这是一种过度简化,不严格正确,请参见下文)。
首先,我将通过讲述编译器的工作方式来证明这一点,然后我将通过查看规范来证明这一点,最后我将通过谈论惯例来证明这一点。
我认为我不能从规范上给你一个完美的理由。通常情况下,我不希望规范给你一个完美的理由——这是无止境的(你是否有解释规范的规范?),而且规范旨在被实际理解相关背景概念的人阅读和理解。
如何运作
这是如何运作的——默认情况下,编译器假定它不知道的函数可以访问任何全局变量。因此,它必须在调用 pthread_mutex_lock() 之前发出对 shared_event_count 的存储——就编译器所知,pthread_mutex_lock() 读取了 shared_event_count 的值。
pthread_mutex_lock 中,如果需要,存在一个CPU的内存屏障。
正当性
来自 n1548:
在抽象机中,所有表达式都按照语义规定进行评估。如果实际实现可以推断出它的值未被使用且没有产生必需的副作用(包括调用函数或访问易失对象引起的任何副作用),则无需评估表达式的一部分。
是的,有LTO。 LTO可以做一些非常令人惊讶的事情。但是,事实是向shared_event_count写入确实具有副作用,并且这些副作用确实会影响pthread_mutex_lock()和pthread_mutex_unlock()的行为。
POSIX规范说明pthread_mutex_lock()提供同步。我在POSIX规范中找不到同步的解释,因此这可能已经足够了。 POSIX 4.12

应用程序应确保通过多个控制线程(线程或进程)访问任何内存位置的访问受到限制,以使没有控制线程能够在另一个控制线程可能正在修改它时读取或修改内存位置。使用同步线程执行和同步内存与其他线程有关的功能来限制这种访问。以下函数将内存与其他线程同步:

理论上,对于shared_event_count的存储可以被移动或消除,但编译器必须以某种方式证明此转换是合法的。您可以想象有各种各样的方法可以实现这一点。例如,编译器可能被配置为执行“整个程序优化”,并且它可能观察到您的程序从未读取过shared_event_count - 在这种情况下,它是一个无效的存储器,并且可以被编译器消除。

惯例

自时间开始以来,pthread_mutex_lock()就是这样使用的。如果编译器进行了这种优化,几乎每个人的代码都会出错。

易失性

我通常不会在普通代码中使用此宏:

#define ACCESS_ONCE(x) (*(volatile typeof(x) *)&(x))

这是一种奇怪的做法,在奇怪的情况下很有用。通常情况下,普通的多线程代码不足以使用此类技巧。一般而言,您希望在多线程程序中使用锁或原子操作来读取或写入共享值。ACCESS_ONCE 不使用锁,也不使用原子操作,那么您会用它来做什么呢?为什么不使用 atomic_store()atomic_load() 呢?
换句话说,您使用 volatile 意图不明。在 C 语言中,volatile 关键字是最容易被滥用的关键字。除了写内存映射 IO 寄存器时很少有用。
结论:代码可以正常运行,不要使用 volatile

话虽如此,我认为LTO应该能够看到被调用的pthread_*函数内部的内存屏障,这需要在RAM中存储shared_event_count,如果我理解正确的话。 - Mikko Rantalainen
1
你可能会问,“编译器如何知道pthread_mutex_lock()同步内存?”有两个答案。要么编译器有关于pthread_mutex_lock()如何工作的特殊信息,要么没有。如果编译器没有关于pthread_mutex_lock()的特殊信息,那么它就必须假设该函数可以执行任何操作,例如读取shared_event_count的值或同步内存。如果编译器知道pthread_mutex_lock()的作用,那么它就知道优化是无效的。 - Dietrich Epp
1
你似乎在想象一种场景,据我所知,编译器会窥视pthread_mutex_lock()的实现,并且违反标准,从而得出关于函数操作的错误假设。这将违反POSIX规范,该规范规定pthread_mutex_lock()同步内存。在POSIX系统上,C编译器不仅必须符合C规范,还必须符合POSIX规范。 - Dietrich Epp
1
请注意,C 2018 5.1.2.4中的线程语义似乎要求,在支持线程的托管实现中,所有对象更新都必须在任何外部调用和读取(使用时)之前写入内存,除了编译器可以严格推断没有多线程复杂性的对象,因为任何外部调用都可能包含对标准C锁定/释放例程的调用... 参见三天前的这个问题 - Eric Postpischil
1
5.1.2.4中的注释2清楚地指出:“……非正式地,在A上执行释放操作会强制使其他内存位置上的先前副作用对稍后在A上执行获取或消耗操作的其他线程可见……”,但我没有追踪到暗示它的规范文本。 - Eric Postpischil
显示剩余7条评论

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