【问题标题】:Are mutex lock functions sufficient without volatile?没有 volatile 的互斥锁功能是否足够?
【发布时间】:2011-07-26 23:10:07
【问题描述】:

我和一位同事为在 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 不好。我尊重这些答案,但关于该主题的文章很容易在网上找到。我在网上找不到的东西,以及我问这个问题的原因,是我如何受到保护没有易失性。 如果上述代码正确,如何不会出现缓存问题?

【问题讨论】:

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


【解决方案1】:

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

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

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

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

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

附:很多混淆是由于 Java 和 .NET 实际上使用关键字 volatile C++ 强制执行多线程语义引起的,但在 C++ 中却并非如此。

【讨论】:

【解决方案2】:

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

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

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

【讨论】:

  • 您的第一点是 C 规范的一部分吗?还是这个编译器依赖?
  • @Samuel:依赖于工具链。在 C11(2011 年 12 月发布,IIRC)之前,标准中没有线程的概念。
  • @Samuel "或者这个编译器是依赖的​​?" 调用带有未知主体的函数会做未知的事情吗?它依赖于知识。如果你有读心能力,你可以试着知道一个外部函数是否需要访问一个变量,否则,你就假设它确实需要。
  • 通常的机制只是为了避免提供pthread_mutex_* 的内联定义,因此它是一个不透明的函数调用,优化器必须假定读取和写入任何/所有全局可访问的内存。 How does a mutex lock and unlock functions prevents CPU reordering?。 (与asm("" ::: "memory") 的行为完全相同。)所以是的,编译器不能在调用期间将shared-var 值保存在寄存器中。 (我不喜欢称其为“缓存”,因为它会导致与 CPU 缓存和一致性混淆,但这种用法在技术上并没有错)
【解决方案3】:

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

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

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

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

【讨论】:

  • 有趣。我敢肯定,我们支持的许多平台在获得 C++0x 之前就会消亡,但这是 5 到 10 年后值得期待的事情。我会阅读那个文件,谢谢。
  • 您能否解释一下编译器如何可能无法正确编译 Q 中提供的代码?
  • 直到 C++0x,它不是 这个答案是不正确的。 pthread_mutex_lock()pthread_mutex_unlock()POSIX functions that guarantee memory access order is properly maintained:
  • (cont) "应用程序应确保多个控制线程(线程或进程)对任何内存位置的访问受到限制,这样任何控制线程都无法读取或修改内存位置,而另一个控制线程可能正在修改它。使用同步线程执行以及与其他线程同步内存的函数限制此类访问。以下函数与其他线程同步内存:...pthread_mutex_lock()...@987654330 @..."
  • @AndrewHenle:确实,通常的机制是它们是非内联(不透明)函数,因此优化器必须假设它们修改了任何全局可访问变量,如_protectedHow does a mutex lock and unlock functions prevents CPU reordering?。例如,它们不会阻止诸如循环计数器之类的局部变量留在寄存器中,除非它们的地址(可能)转义了函数。为了正确起见,这与实际行为具有相同的效果:其他线程可能已经修改了任何共享变量。
【解决方案4】:

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

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

【讨论】:

    【解决方案5】:

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

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

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

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

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

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

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

    我关心的是缓存。编译器可以放置 _protected 的副本吗 在堆栈或寄存器中,并在 任务?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    【讨论】:

      【解决方案6】:

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

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

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

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

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

      【讨论】:

      • CPU缓存一致;锁不需要刷新它。 CPU 缓存中的数据足以使其全局可见。不要将选择将变量值保存在寄存器中的编译器与硬件缓存内存的方式混淆。寄存器是每个核心私有的,CPU 缓存是一致的。另见software.rajivprab.com/2018/04/29/…
      • 您似乎建议使用volatile 进行无锁原子。 (When to use volatile with multi threading?) 这是个坏主意; C++11 std::atomic 已经十年了;改用它。 (使用std::memory_order_relaxed,如果您希望它编译为与使用volatilememory_order_acquire 相同的asm,至少对于双重检查的第二步来说可能是必需的。顺便说一句,像GCC 这样的编译器使 asm 使用廉价的只读方式进行双重检查,首先检查具有非 const 初始化程序的函数局部 static 变量。)
      【解决方案7】:

      如果您要锁定的对象是易失的,例如:如果它所代表的值取决于程序以外的某些东西(硬件状态),则不会。 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 一部分的变量的线程时要注意这一点。

      【讨论】:

        猜你喜欢
        • 2012-06-05
        • 1970-01-01
        • 1970-01-01
        • 2021-06-26
        • 2023-03-30
        • 1970-01-01
        • 2018-06-10
        • 2018-05-23
        • 1970-01-01
        相关资源
        最近更新 更多