互斥锁功能是否足够而没有易失性?

时间:2011-07-26 23:10:07

标签: c++ multithreading mutex volatile memory-barriers

同事和我为在x86,x64,Itanium,PowerPC和其他10年服务器CPU上运行的各种平台编写软件。

我们刚刚讨论过pute_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。 如果上面的代码是正确的,那么 对于缓存问题是否无懈可击?

7 个答案:

答案 0 :(得分:14)

最简单的答案是多线程根本不需要volatile

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

C ++ 0x有一个线程和线程安全的概念,但是当前的标准没有,因此volatile有时被误认为是防止多线程编程的操作和内存访问重新排序的东西意图并且不能以这种方式可靠地使用。

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

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

P.S。 Java和.NET实际上使用关键字std::atomic C ++强制执行多线程语义引起了很多困惑,但是在C不适用的情况下,情况并非如此。

答案 1 :(得分:8)

您的线程库应该在互斥锁和解锁时包含适当的CPU和编译器障碍。对于GCC,asm语句中的memory clobber充当编译器障碍。

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

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

答案 2 :(得分:8)

  

如果上面的代码是正确的,那么如何对缓存无懈可击   问题吗

直到C ++ 0x,它不是。并且它没有在C中指定。所以,它实际上取决于编译器。通常,如果编译器不保证它将遵守涉及多个线程的函数或操作的内存访问的排序约束,那么您将无法使用该编译器编写多线程安全代码。见Hans J Boehm的Threads Cannot be Implemented as a Library

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

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

答案 3 :(得分:3)

volatile关键字提示编译器变量可能在程序逻辑之外发生变化,例如内存映射的硬件寄存器,它可能会作为中断服务程序的一部分而发生变化。这可以防止编译器假设缓存值始终是正确的,并且通常会强制读取内存以检索该值。这种用法可以在几十年左右的时间内进行线程处理。我已经看到它用于信号操纵的变量,但我不确定用法是否正确。

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

答案 4 :(得分:1)

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

但是如果您的代码经过双重检查,编译器可以优化代码并删除冗余代码,以防止我们需要 volatile。

示例:参见单例模式示例
https://en.m.wikipedia.org/wiki/Singleton_pattern#Lazy_initialization

为什么有人写这种代码? Ans:不获取锁有性能优势。

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

答案 5 :(得分:0)

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

无论如何,普通编译器都无法解码和“理解”所有复杂代码(再次,除了简单的自旋锁),因此即使对于编译器也不知道互斥体是什么,并且 。实际上,编译器无法围绕此类代码进行任何优化

那是代码是“内联”代码,还是可用于跨模块优化目的的分析,或者是否存在全局优化。

  

我认为编译器实际上并不了解   pthread_mutex_lock()是一个特殊的函数,因此我们只是受到保护   按顺序点?

编译器不知道它的作用,因此不会尝试对其进行优化。

它如何“特殊”?它是不透明的,因此被视为。 这在不透明的函数中并不特殊

可以访问任何其他对象的任意不透明函数在语义上没有区别。

  

我关心的是缓存。编译器能否放置_protected的副本   在堆栈或寄存器中,并在   作业?

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

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

还有对于只能在函数中使用的变量,按名称:对于没有地址或未绑定引用的局部变量(这样编译器就不能遵循所有其他用途)。实际上,可以在包括锁定/解锁在内的任意调用之间“缓存”它们。

  

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

功能的不透明度。非内联。汇编代码。系统调用。代码复杂度。使编译器退出困境的所有事物都认为“那只是调用它而已”。

编译器的默认位置始终是“让我愚蠢地执行,无论如何我都不知道正在做什么” ,而不是“我会优化/让我们重写我更了解的算法”。大多数代码并未以复杂的非本地方式进行优化。

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

  • 该功能为“内联”(=可用于内联)(或启动了全局优化,或者所有功能在道德上均为“内联”);
  • 在该同步原语(锁定或解锁)中,不需要
  • 没有内存障碍(例如,在单处理器分时系统中,在多处理器强序系统中),因此它不包含此类东西;
  • 没有没有特殊指令(例如比较和设置)(例如,对于自旋锁,解锁操作是简单的写操作);
  • 没有没有系统调用来暂停或唤醒线程(自旋锁中不需要);

然后我们可能会遇到问题,因为编译器可以围绕函数调用进行优化通过插入编译器障碍(例如,空的asm语句以及其他可访问变量的“ clobber”)来解决此问题。这意味着编译器仅假设被调用函数可访问的任何内容都是“崩溃”。

  

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

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

将其设置为volatile实际上甚至无法解决上述问题,因为 volatile本质上是抽象机中具有I / O操作语义的内存操作,因此只能通过尊重

  • 像iostream一样的真实I / O
  • 系统调用
  • 其他易失性操作
  • asm记忆障碍物(但是这些记忆障碍物没有重新排序)
  • 调用外部函数(因为它们可能执行上述操作之一)

关于非易失性内存副作用,未对挥发性进行排序。这使得挥发性实际上毫无用处(对于实际用途无用),即使在大多数情况下也无法编写线程安全代码在volatile会优先帮助的特定情况下,不需要内存围墙的情况是:在单个CPU上的分时系统上编写线程原语时。 (这可能是C或C ++鲜为人知的方面之一。)

因此,尽管volatile确实可以防止“缓存”,但 volatile甚至都不能阻止编译器对锁定/解锁操作进行重新排序,除非所有共享变量都是volatile

答案 6 :(得分: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 中的某些变量一起使用时,请注意这一点。