48

我和一位同事为在 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 的情况下受到保护。如果上面的代码是正确的,它怎么能不受缓存问题的影响呢?

4

7 回答 7

15

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

长答案是关键部分之类的序列点与您使用的任何线程解决方案一样依赖于平台,因此您的大部分线程安全性也依赖于平台。

C++0x 有线程和线程安全的概念,但当前标准没有,因此volatile有时被错误识别为防止多线程编程的操作重新排序和内存访问的东西那样。

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

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

PS 很多混淆是由 Java 和 .NET 实际上使用关键字volatileC++ 强制执行多线程语义引起的,但是在并非如此的情况下,C 也会如此。

于 2011-07-26T23:15:15.063 回答
13

您的线程库应该包括适当的 CPU 和编译器对互斥锁和解锁的障碍。对于 GCC,memoryasm 语句上的破坏器充当编译器屏障。

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

  • 您正在调用一个非纯外部函数 ( pthread_mutex_*()),这意味着编译器不知道该函数不会修改您的全局变量,因此它必须重新加载它们。
  • 正如我所说,pthread_mutex_*()包括一个编译器障碍,例如:在 glibc/x86 上pthread_mutex_lock()最终调用lll_lock()具有一个memoryclobber 的宏,强制编译器重新加载变量。
于 2011-07-26T23:35:37.977 回答
10

如果上面的代码是正确的,它怎么能不受缓存问题的影响呢?

在 C++0x 之前,它不是。而且它没有在 C 中指定。所以,它真的取决于编译器。一般来说,如果编译器不保证它会尊重涉及多线程的函数或操作的内存访问的顺序约束,您将无法使用该编译器编写多线程安全代码。请参阅 Hans J Boehm 的Threads Cannot be Implemented as a Library

至于你的编译器应该支持哪些抽象来支持线程安全代码,内存屏障上的维基百科条目是一个很好的起点。

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

于 2011-07-26T23:54:46.127 回答
3

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

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

于 2011-07-26T23:29:45.833 回答
2

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

无论如何,一般的编译器都无法解码和“理解”所有复杂的代码(同样,除了简单的自旋锁),所以即使对于不知道互斥锁是什么以及它如何相关的编译器来说对于同步,实际上编译器无法围绕此类代码优化任何内容

那是如果代码是“内联的”,或者可用于分析以进行跨模块优化,或者是否可以使用全局优化。

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

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

怎么“特别”?它是不透明的并被这样对待。在不透明函数中并不特殊

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

我关心的是缓存。编译器能否将 _protected 的副本放在堆栈或寄存器中,并在分配中使用该陈旧值?

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

所以在对不透明函数的调用之间是的。不跨越。

并且对于只能在函数中使用的变量,按名称:对于没有获取地址或绑定到它们的引用的局部变量(这样编译器就无法遵循所有进一步的用途)。这些确实可以跨任意调用“缓存”,包括锁定/解锁。

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

函数的不透明度。非内联。汇编代码。系统调用。代码复杂度。使编译器摆脱困境并认为“这是复杂的东西只需调用它”的一切。

编译器的默认位置始终是“让我们愚蠢地执行我不明白正在做什么”而不是“我将优化它/让我们重写我更了解的算法”。大多数代码没有以复杂的非本地方式进行优化。

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

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

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

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

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

使其 volatile 实际上甚至无法解决上述问题,因为volatile 本质上是抽象机器中的内存操作,具有 I/O 操作的语义,因此仅针对以下内容进行排序

  • 像 iostream 这样的真实 I/O
  • 系统调用
  • 其他不稳定的操作
  • asm memory clobbers(但是没有围绕这些重新排序的内存副作用)
  • 调用外部函数(因为它们可能会执行上述操作之一)

就非易失性内存副作用而言,易失性没有排序。这使得 volatile对于编写线程安全代码实际上是无用的(对于实际用途无用),即使是在 volatile 会先验帮助的最具体情况下,也就是不需要内存围栏的情况:在时间共享系统上编写线程原语时单CPU。(这可能是 C 或 C++ 中最不被理解的方面之一。)

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

于 2019-11-22T20:55:57.497 回答
1

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

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

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

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

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

于 2021-07-14T16:55:50.877 回答
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 一部分的变量的线程时要注意这一点。

于 2021-07-16T22:59:03.690 回答