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

Are mutex lock functions sufficient without volatile?

本文关键字:情况下 是否 volatile 函数      更新时间:2023-10-16

我和一位同事为运行在x86、x64、安腾、PowerPC和其他已有10年历史的服务器CPU上的各种平台编写软件。

我们刚刚讨论了诸如pthread_mutex_lock()之类的互斥函数是否。。。pthread_mutex_unlock()本身就足够了,或者受保护的变量是否需要是可变的。

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

长期的答案是,像关键部分这样的序列点是依赖于平台的,就像你使用的任何线程解决方案一样,所以你的大部分线程安全性也依赖于平台。

C++0x有线程和线程安全的概念,但当前的标准没有,因此volatile有时会被错误地识别为防止多线程编程的操作和内存访问重新排序的东西,而这从来都不是有意的,也不能可靠地使用。

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

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

附言:许多混淆是由于Java和.NET实际上使用关键字volatileC++强制执行多线程语义而引起的,但在C中却不是这样。

您的线程库应该在互斥锁和解锁上包含适当的CPU和编译器屏障。对于GCC,asm语句上的memoryclobber充当编译器屏障。

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

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

如果上面的代码是正确的,它如何不受缓存的影响问题

直到C++0x,它都不是。它并没有在C中指定。所以,它实际上取决于编译器。通常,如果编译器不能保证对涉及多个线程的函数或操作的内存访问遵守排序约束,则您将无法使用该编译器编写多线程安全代码。参见Hans J Boehm的线程不能作为库来实现。

至于编译器应该支持线程安全代码的抽象,MemoryBarriers上的维基百科条目是一个很好的起点。

(至于为什么人们建议使用volatile,一些编译器将volatile视为编译器的内存屏障。这绝对不是标准的。)

volatile关键字向编译器提示变量可能在程序逻辑之外发生更改,例如内存映射硬件寄存器,该寄存器可能作为中断服务例程的一部分发生更改。这可以防止编译器假设缓存的值总是正确的,并且通常会强制进行内存读取以检索该值。这种用法比线程提前了几十年左右。我也见过它与由信号操纵的变量一起使用,但我不确定这种用法是否正确。

由互斥锁保护的变量在由不同线程读取或写入时保证是正确的。线程API是必需的,以确保这些变量的视图是一致的。这种访问都是程序逻辑的一部分,volatile关键字在这里无关紧要。

除了最简单的旋转锁算法外,互斥锁代码非常复杂:一个好的优化互斥锁/解锁代码包含即使是优秀的程序员也难以理解的代码。它使用特殊的比较和设置指令,不仅管理解锁/锁定状态,还管理等待队列,可选地使用系统调用进入等待状态(用于锁定)或唤醒其他线程(用于解锁)。

无论怎样,普通编译器都不可能解码和"理解"所有复杂的代码(同样,除了简单的旋转锁),因此,即使编译器不知道互斥是什么,以及它与同步的关系,在实践中,编译器也不可能优化此类代码的任何内容。

如果代码是"内联"的,或者可用于跨模块优化的分析,或者全局优化可用。

我认为编译器实际上并不理解pthread_mutex_lock()是一个特殊的函数,所以我们只是受到保护吗按序列点?

编译器不知道它做什么,所以不会试图围绕它进行优化。

它是如何"特别"的?它是不透明的,并被视为不透明的它在不透明函数中并不特殊

与可以访问任何其他对象的任意不透明函数没有语义差异。

我关心的是缓存。编译器是否可以放置一个_受保护的副本在堆栈或寄存器中,并在分配

是的,在透明和直接作用于对象的代码中,通过使用变量名或指针,编译器可以遵循。不在可能使用任意指针来间接使用变量的代码中。

因此,在对不透明函数的调用之间是。不是对面。

对于只能在函数中使用的变量,也,按名称:对于没有获取地址或绑定引用的局部变量(这样编译器就无法跟踪所有进一步的使用)。这些确实可以在任意调用(包括锁定/解锁)之间"缓存"。

如果没有,是什么阻止了这种情况的发生?这是变化的吗模式易受攻击?

函数的不透明度。非内联。程序集代码。系统调用。代码复杂性。所有让编译器摆脱困境并认为"这是复杂的东西,只是调用它"的东西。

编译器的默认位置总是"让我们愚蠢地执行吧,我不明白到底在做什么">而不是"我会优化它/让我们重写我更了解的算法"。大多数代码并没有以复杂的非本地方式进行优化。

现在让我们假设绝对更差(从编译器应该放弃的角度来看,从优化算法的角度来看是绝对最好的):

  • 函数是"内联"的(=可用于内联)(或者全局优化开始,或者所有函数在道德上都是"内联的")
  • 在同步原语(锁定或解锁)中不需要内存屏障(如在单处理器时间共享系统中和在多处理器强序系统中),因此它不包含这样的东西
  • 没有使用特殊指令(如比较和设置)(例如,对于旋转锁定,解锁操作是简单的写入)
  • 没有系统调用来暂停或唤醒线程(在旋转锁中不需要)

那么我们可能会遇到问题,因为编译器可能会围绕函数调用进行优化这是通过插入编译器屏障来修复的,例如为其他可访问变量插入一个带有"clobber"的空asm语句这意味着编译器只是假设被调用函数可能访问的任何内容都"被破坏"了。

或者受保护的变量是否需要是可变的。

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

使其易失性实际上甚至无法解决上述问题,因为易失性本质上是抽象机器中的一种内存操作,具有I/O操作的语义,因此仅针对排序

  • 类似iostream的真实I/O
  • 系统调用
  • 其他易失性操作
  • asm记忆力受损(但随后不会对记忆力副作用进行重新排序)
  • 对外部函数的调用(正如它们可能执行的那样)

对于非易失性存储器的副作用,未对易失性进行排序这使得volatile在编写线程安全代码时实际上是无用的(对于实际用途来说是无用的),即使是在volatile会先验帮助的最特定情况下,在不需要内存围栏的情况下:在单CPU上的时间共享系统上编程线程基元时。(这可能是对C或C++最不了解的方面之一。)

因此,尽管volatile确实阻止了"缓存",但volatile甚至不会阻止编译器对锁定/解锁操作的重新排序,除非所有共享变量都是volatile

锁/同步原语确保数据没有缓存在寄存器/cpu缓存中,这意味着数据会传播到内存。如果两个线程使用in锁访问/修改数据,则可以保证从内存读取数据并将数据写入内存。在这个用例中,我们不需要volatile。

但在代码经过双重检查的情况下,编译器可以优化代码并删除多余的代码,以防止我们需要volatile。

示例:请参阅singleton模式示例
https://en.m.wikipedia.org/wiki/Singleton_pattern#Lazy_initialization

为什么有人写这种代码?答:不起诉锁有一个性能优势。

附言:这是我第一篇关于堆栈溢出的文章。

如果您锁定的对象是易失性的,则不适用,例如:如果它所代表的值取决于程序的外部内容(硬件状态)。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的一部分。

相关文章: