互斥锁函数在没有volatile的情况下是否足够?

52

我和同事开发了适用于各种平台的软件,这些平台运行在x86、x64、Itanium、PowerPC和其他10年前的服务器CPU上。

我们刚刚讨论了诸如pthread_mutex_lock() ... pthread_mutex_unlock()这样的互斥函数是否足够,或者是否需要使用volatile来保护变量。

int foo::bar()
{
 //...
 //code which may or may not access _protected.
 pthread_mutex_lock(m);
 int ret = _protected;
 pthread_mutex_unlock(m);
 return ret;
}

我关心的是缓存。编译器是否可以将_protected的副本放置在堆栈或寄存器中,并在赋值中使用该过时的值?如果不是,是什么阻止了这种情况的发生?这种模式的变化是否脆弱?

我假设编译器实际上并不理解pthread_mutex_lock()是一个特殊的函数,因此我们只受到序列点的保护吗?

非常感谢。

更新:好吧,我能看到回答中解释为什么volatile是不好的趋势。 我尊重这些答案,但是有关该主题的文章很容易在网上找到。 我找不到在线上的东西,也就是我问这个问题的原因,是如何在没有volatile的情况下得到保护。如果上面的代码是正确的,它是如何不受缓存问题的影响的?


3
可能是使用C/Pthreads:共享变量需要声明为volatile吗?的重复问题。 - Emile Cormier
2
参见: https://dev59.com/lXA75IYBdhLWcg3wm6Qj - Emile Cormier
7个回答

16

最简单的答案是,在多线程编程中根本不需要使用volatile

更长的答案是,像关键部分这样的序列点是与平台相关的,您使用的任何线程解决方案也是如此,因此大部分线程安全性也取决于平台。

C++0x具有线程和线程安全性的概念,但当前标准没有,因此volatile有时被误认为是用于防止重排序操作和内存访问的多线程编程的某种东西,但它从未打算过且无法可靠地以这种方式使用。

C++中唯一应该使用volatile的是允许访问内存映射设备,允许在setjmplongjmp之间使用变量,并允许在信号处理程序中使用sig_atomic_t变量。关键字本身并不使变量成为原子变量。

好消息是,在C++0x中,我们将拥有STL构造std::atomic,可以用于保证变量的原子操作和线程安全构造。直到您选择的编译器支持它,您可能需要转向boost库或使用汇编代码创建自己的对象来提供原子变量。

P.S. 许多混乱是由Java和.NET实际上使用关键字volatile强制执行多线程语义造成的,但C++遵循C的做法,不是这种情况。


谢谢,我在网上已经读了好几遍了。但是我真的想知道如果没有使用volatile关键字,我该如何保护我的代码。 - David
2
我自己直到阅读了这篇文章才明白它的含义:http://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf - AJG85

16

您的线程库应在互斥锁的锁定和解锁操作中包含适当的CPU和编译器屏障。对于GCC,asm语句上的memory修饰符作为编译器屏障。

实际上,有两个因素可以保护您的代码免受(编译器)缓存:

  • 您正在调用非纯外部函数(pthread_mutex_*()),这意味着编译器不知道该函数是否修改全局变量,因此必须重新加载它们。
  • 正如我所说,pthread_mutex_*()包括一个编译器屏障,例如:在glibc / x86上,pthread_mutex_lock()最终会调用宏lll_lock(),其中包含memory修饰符,强制编译器重新加载变量。

你的第一个观点是C规范的一部分吗?还是这取决于编译器? - Samuel
1
@Samuel:工具链相关。在C11之前(我记得是2011年12月发布),标准中没有线程的概念。 - ninjalj
@Samuel "或者这取决于编译器?" 调用未知函数体的函数在哪里做未知的事情?这是知识依赖性的。如果你有心灵阅读能力,你可以尝试了解外部函数是否需要访问变量,否则,你只能假设它需要。 - curiousguy
1
通常的机制是避免提供pthread_mutex_*的内联定义,因此它是一个不透明的函数调用,优化器必须假定读取和写入任何/所有全局可访问的内存。互斥锁定和解锁函数如何防止CPU重新排序?(与asm("" :::“memory”)完全相同的行为)。所以是的,编译器不能在调用之间保留共享变量值的寄存器。(我不喜欢称之为“缓存”,因为它会导致与CPU缓存和一致性混淆,但这种用法在技术上并不错误) - Peter Cordes

11
如果上面的代码正确,那么它如何不受缓存问题的影响呢?直到C++0x,它并没有解决。而且在C中也没有规定。所以,这实际上取决于编译器。一般来说,如果编译器不能保证对于涉及多个线程的函数或操作的内存访问的排序约束将被遵守,那么你将无法使用该编译器编写多线程安全代码。参见Hans J Boehm的Threads Cannot be Implemented as a Library。至于你的编译器应该支持哪些抽象来支持线程安全代码,维基百科上Memory Barriers的条目是一个相当不错的起点。(至于为什么人们建议使用volatile,是因为一些编译器将volatile视为编译器的内存屏障。这绝对不是标准做法。)

1
有趣。我相信在我们支持的许多平台之前会死亡,然而在5-10年内期待C++0x是一件令人兴奋的事情。我会阅读那份文档的,谢谢。 - David
那篇Boehm的文章很棒,也是一个来自过去的美好回忆:“多处理器终于成为主流了。”哇,我不想再回到2004年了! - sage
1
你能解释一下编译器可能如何无法正确编译Q中呈现的代码吗? - curiousguy
3
直到C++0x,这个回答是不正确的。pthread_mutex_lock()pthread_mutex_unlock()POSIX函数,它们保证了内存访问顺序得到正确维护 - Andrew Henle
2
应用程序应确保对于任何内存位置的访问都受到限制,以便多个控制线程(线程或进程)不能同时读取或修改内存位置。使用同步线程执行和与其他线程同步内存的函数来限制此类访问。以下函数可与其他线程同步内存:... pthread_mutex_lock() ... pthread_mutex_unlock() ... - Andrew Henle
1
@AndrewHenle:确实,通常的机制是它们是非内联(不透明)函数,因此优化器必须假定它们修改任何全局可访问变量,如“_protected”。互斥锁和解锁函数如何防止CPU重新排序?。例如,它们不会阻止像循环计数器这样的局部变量留在寄存器中,除非它们的地址已经(可能)逃逸出函数。为了正确性,这具有相同的效果作用:其他线程可能已经修改了任何共享变量。 - Peter Cordes

3
除了最简单的自旋锁算法之外,互斥锁代码非常复杂:一个优化良好的互斥锁/解锁代码包含那种即使是优秀程序员也难以理解的代码。它使用特殊的比较和设置指令,管理的不仅是未锁定/已锁定状态,还包括等待队列,并且可以选择使用系统调用进入等待状态(用于锁定)或唤醒其他线程(用于解锁)。
没有办法让普通编译器解码和“理解”所有这些复杂的代码(再次强调,除了简单的自旋锁之外),因此即使对于不知道互斥锁是什么以及它与同步的关系的编译器,在实践中也无法优化任何与此类代码相关的内容。
如果代码是“内联”的,或可供分析以进行跨模块优化,或者可用全局优化。

我假设编译器实际上不知道pthread_mutex_lock()是一个特殊函数,那么我们只是受到序列点的保护吗?

编译器不知道它是什么,因此不会尝试在其周围进行优化。
它有什么“特殊”之处?它是不透明的,也被视为如此。在不透明函数中,它并没有什么特别之处
与可以访问任何其他对象的任意不透明函数没有语义差异。

我的担忧是缓存。编译器能否将_protected的副本放置在堆栈或寄存器中,并在赋值中使用该过时值?

是的,在直接透明地操作对象的代码中,通过变量名或指针以编译器可以跟踪的方式使用。在可能使用任意指针间接使用变量的代码中则不行。
所以是的在不透明函数调用之间。但是不能跨越调用。
并且还有只能在函数内使用的变量,按名称:对于没有地址被取出或绑定到引用(使得编译器无法跟踪所有进一步使用)的局部变量。这些变量确实可以在包括锁定/解锁在内的任意调用中“缓存”。

如果不是这样,那么是什么阻止了这种情况的发生?这个模式的变体是否容易受攻击?

函数的不透明性。不内联。汇编代码。系统调用。代码复杂性。所有这些都使编译器放弃并认为“那是复杂的东西,只需调用它即可”。大多数代码并没有以复杂的非本地方式进行优化。
现在让我们假设最坏的情况(从我们的角度来看,这是最好的情况,也就是编译器应该放弃的情况):
  • 函数是“inline”(可内联)(或全局优化启动,或所有函数在道德上都是“inline”);
  • 不需要内存屏障(例如,在单处理器分时系统和多处理器强序系统中),因此同步原语(锁定或解锁)不包含此类内容;
  • 没有使用特殊指令(如比较和设置)(例如,在自旋锁中,解锁操作是简单的写入);
  • 没有系统调用来暂停或唤醒线程(在自旋锁中不需要);

然后我们可能会遇到问题,因为编译器可能会围绕函数调用进行优化。 可以通过插入编译器屏障(例如带有“clobber”的空asm语句)来轻松解决此问题。 这意味着编译器只假设任何可能被调用函数访问的东西都被“破坏”。

或者受保护的变量是否需要是易失性的。

您可以使其易失性,通常的原因是使事物易失性:确保能够在调试器中访问变量,防止浮点变量在运行时具有错误的数据类型等。

使其易失性实际上甚至不能解决上述问题,因为易失性本质上是抽象机器中的内存操作,具有I/O操作的语义,因此仅与以下相关:

  • 真正的I/O,如iostream
  • 系统调用
  • 其他易失性操作
  • asm内存clobber(但是在这些周围没有内存副作用被重新排序)
  • 对外部函数的调用(因为它们可能执行上述操作之一)

易失性不会与非易失性内存副作用排序。 这使得易失性在编写线程安全代码时实际上是无用的(对于实际用途而言)。 即使在易失性a priori可以帮助的最特定情况下,即永远不需要内存栅栏的编程线程原语在单个CPU上的分时系统中(这可能是C或C ++的最不理解的方面之一),易失性也是无用的。

因此,虽然易失性确实可以防止“缓存”,但除非所有共享变量都是易失性的,否则易失性甚至不能防止编译器重新排序锁定/解锁操作


3
volatile关键字是对编译器的提示,表明变量可能会在程序逻辑之外发生变化,例如作为中断服务例程的一部分而可能发生变化的内存映射硬件寄存器。这可以防止编译器假定缓存的值始终正确,并且通常会强制进行内存读取以检索该值。这种用法比线程还要早几十年。我也看到过它被用于由信号操作的变量,但我不确定那种用法是否正确。
由互斥锁保护的变量在被不同线程读取或写入时保证是正确的。线程API需要确保这些变量的视图是一致的。这种访问都是您的程序逻辑的一部分,volatile关键字在此处是无关紧要的。

2
锁/同步原语确保数据不会缓存在寄存器/CPU缓存中,这意味着数据传播到内存。如果两个线程在锁内访问/修改数据,则保证从内存读取数据并写入内存。在这种用例中,我们不需要volatile。
但是,在具有双重检查的代码中,编译器可以优化代码并删除冗余代码,为了防止这种情况,我们需要volatile。
例如:请参见单例模式示例 https://en.m.wikipedia.org/wiki/Singleton_pattern#Lazy_initialization 为什么有人编写这种代码? 答:不需要获取锁的性能优势。
PS:这是我在stackoverflow上的第一篇文章。

CPU缓存是一致的;锁不需要刷新它。数据在CPU缓存中就足以使其在全局范围内可见。不要将编译器选择在寄存器中保存变量值与硬件缓存内存的方式混淆。寄存器是每个核心私有的,CPU缓存是一致的。另请参见https://software.rajivprab.com/2018/04/29/myths-programmers-believe-about-cpu-caches/。 - Peter Cordes
你似乎在建议使用volatile来进行无锁原子操作。(何时在多线程中使用volatile?)这是一个不好的主意;C++11的std::atomic已经有十年历史了;请使用它。如果你想要编译出与volatile相同的汇编代码,可以使用std::memory_order_relaxed,或者对于双重检查的第二步骤,可能需要使用memory_order_acquire。顺便说一下,像GCC这样的编译器会生成使用廉价只读第一次检查的汇编代码,用于具有非const初始值的函数本地静态变量。 - Peter Cordes
这个问题是关于C++的。在Java中的volatile和C++中的volatile是不同的东西。 - ninjalj
问题是关于C++的。在Java中的volatile和C++中的volatile是不同的东西。 - undefined

0

如果你要锁定的对象是易失性的,例如:如果它所代表的值取决于程序外部的某些东西(硬件状态),那么就不需要锁定。

volatile不应用于表示执行程序的任何行为。

如果实际上是volatile,我个人会锁定指针/地址的值,而不是底层对象。

volatile int i = 0;
// ... Later in a thread
// ... Code that may not access anything without a lock
std::uintptr_t ptr_to_lock = &i;
some_lock(ptr_to_lock);
// use i
release_some_lock(ptr_to_lock);

请注意,仅当在线程中使用对象的所有代码锁定相同地址时,它才起作用。因此,在使用某个API的变量时,请谨慎使用线程。

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