【问题标题】:How compiler like GCC implement acquire/release semantics for std::mutex像 GCC 这样的编译器如何为 std::mutex 实现获取/释放语义
【发布时间】:2016-06-07 15:28:05
【问题描述】:

我的理解是 std::mutex 锁定和解锁具有获取/释放语义,这将防止它们之间的指令被移出。

因此获取/释放应该禁用编译器和 CPU 重新排序指令。

我的问题是,我查看了 GCC5.1 代码库,在 std::mutex::lock/unlock 中没有看到任何特别之处,以防止编译器重新排序代码。

我在 does-pthread-mutex-lock-have-happens-before-semantics 中找到了一个可能的答案,它表明 mail 表示外部函数调用充当编译器内存栅栏。

总是如此吗?标准在哪里?

【问题讨论】:

  • 不,函数,无论是外部的还是其他的,都与内存栅栏无关。
  • 发布一段您认为可能有问题的具体代码对您的问题有所帮助。然后我们可以使用 gcc 生成的程序集来帮助解释 gcc 是如何处理它的
  • @n.m.一个真正的外部函数(与内联相反)怎么能不充当编译器围栏?
  • @curiousguy 内存栅栏是硬件。有创建内存栅栏的汇编指令。它们与编译器是否进行重新排序无关。
  • @n.m.不,内存栅栏是 asm 级别的东西,而不是硬件。当然,它们与编译器有关。您如何相信它们是由编译器生成的?

标签: c++ multithreading c++11 gcc


【解决方案1】:

线程是一个相当复杂的低级功能。从历史上看,没有标准的 C 线程功能,而是在不同的操作系统上以不同的方式完成。今天主要是 POSIX 线程标准,它已经在 Linux 和 BSD 中实现,现在通过扩展 OS X,还有 Windows 线程,从 Win32 及更高版本开始。除了这些之外,可能还有其他系统。

GCC 不直接包含 POSIX 线程实现,相反它可能是 linux 系统上libpthread 的客户端。当您从源代码构建 GCC 时,您必须单独配置和构建许多辅助库,以支持诸如大数字和线程之类的东西。这就是您选择如何进行线程化的点。如果您在 linux 上以标准方式执行此操作,您将在 pthreads 方面实现 std::thread

在 Windows 上,从符合 MSVC C++11 开始,MSVC 开发人员在本机 Windows 线程接口方面实现了 std::thread

操作系统的工作是确保其 API 提供的并发锁确实有效——std::thread 旨在成为此类原语的跨平台接口。

对于更奇特的平台/交叉编译等,情况可能会更复杂。例如,在 MinGW 项目(Windows 的 gcc)中——从历史上看,您可以选择使用 pthread 端口构建 MinGW gcc 到 Windows ,或使用基于原生 win32 的线程模型。如果您在构建时没有配置它,您最终可能会得到一个不支持 std::threadstd::mutex 的 C++11 编译器。有关更多详细信息,请参阅此问题。 MinGW error: ‘thread’ is not a member of ‘std’

现在,更直接地回答您的问题。当使用互斥体时,在最低级别,这涉及到一些对 libpthreads 或一些 win32 API 的调用。

pthread_lock_mutex();
do_some_stuff();
pthread_unlock_mutex();

pthread_lock_mutexpthread_unlock_mutex 对应于您的平台上 std::mutexlockunlock 的实现,并且在惯用的 C++11 代码中,这些又在 @987654336 中调用@ 和 dtorstd::unique_lock,例如,如果您正在使用它。)

通常,优化器无法重新排序这些,除非确定 pthread_lock_mutex() 没有可以改变 do_some_stuff() 的可观察行为的副作用。

据我所知,编译器执行此操作的机制最终与它用于估计调用任何其他外部库的潜在副作用的机制相同。

如果有资源

int resource;

各个线程争用,表示有函数体

void compete_for_resource();

并且指向 this 的函数指针在较早的某个时间点传递给程序中的pthread_create... 以启动另一个线程。 (这可能是在std::threadctor 的实现中。)此时,编译器可以看到对libpthread 的任何调用都可能调用compete_for_resource 并触及该函数触及的任何内存。 (从编译器的角度来看,libpthread 是一个黑匣子——它是一些 .dll / .so,它无法假设它究竟做了什么。)

特别是,调用pthread_lock_mutex(); 可能对resource 有副作用,因此不能针对do_some_stuff() 重新排序。

如果您实际上从未产生任何其他线程,那么据我所知,do_some_stuff(); 可以在互斥锁之外重新排序。既然,那么libpthreadresource 没有任何访问权限,它只是您源代码中的一个私有变量,甚至不会间接与外部库共享,编译器可以看到这一点。

【讨论】:

  • 没有标准的 C 线程功能 那是no longer true
  • 链接时间优化怎么样?不能 gcc 然后偷看libpthread
  • (续)即使假设所有内容都不是不透明的,可达性分析也是保守的。在任何顺序计算模型中,基本上的想法是,对任何给定操作读取/写入的所有内存进行保守估计。如果一个操作读取另一个操作写入的内存,则它们无法重新排序。如果没有,那么通常他们可以。 (如果您查看例如单个芯片,您可能需要让“内存”更普遍地包括处理器状态,但这是一些细节。)在 pthreads 中,应该有一个明确的可达性链。 (续)
  • (续)。线程可能会触及资源——它也可能会触及/读取互斥体。因此,触摸互斥锁可能会隐式触摸资源。调用lock_mutex 也会触及互斥体。因此,如果可达性分析得出结论,这些可以重新排序,我会说它是一个错误。我的意思是,显然这是一个错误,因为重新排序它们实际上会产生不同的结果。不管编译器如何理解操作系统原语,要么它们对它基本上是不透明的,因此它会假设最坏的情况并找到可达性,或者它们不是不透明的,它会看到清晰的链。
  • @TavianBarnes 当然,一般支持 LTO,但不支持 LTO 进入 C 库(libpthread 算作“C 库”)还支持。 sourceware.org/ml/libc-alpha/2016-02/msg00052.html
【解决方案2】:

所有这些问题都源于编译器重新排序的规则。重新排序的基本规则之一是编译器必须证明重新排序不会改变程序的结果。在std::mutex 的情况下,该短语的确切含义在大约 10 的法律术语块中指定,但一般直观意义上的“不会改变程序的结果”持有。如果你有一个关于哪个操作先出现的保证,根据规范,任何编译器都不允许以违反该保证的方式重新排序。

这就是为什么人们经常声称“函数调用充当内存屏障”的原因。如果编译器无法深入检查函数,则无法证明该函数内部没有隐藏屏障或原子操作,因此必须将该函数视为屏障。

当然,编译器可以检查函数的情况,例如内联函数或链接时间优化的情况。在这些情况下,不能依赖函数调用来充当屏障,因为编译器可能确实有足够的信息来证明重写的行为与原来的行为相同。

在互斥体的情况下,即使是这样的高级优化也不能发生。围绕互斥锁/解锁函数调用重新排序的唯一方法是深入检查函数并证明没有障碍或原子操作需要处理。如果它无法检查该锁定/解锁功能的每个子调用和子子调用,则无法证明重新排序是安全的。如果它确实可以进行此检查,它将看到每个互斥锁实现都包含无法重新排序的东西(实际上,这是有效互斥锁实现定义的一部分)。因此,即使在那种极端情况下,编译器仍然被禁止优化。

编辑:为了完整起见,我想指出这些规则是在 C++11 中引入的。 C++98 和 C++03 重新排序规则仅禁止影响当前线程结果的更改。这样的保证不足以开发像互斥锁这样的多线程原语。

为了解决这个问题,像 pthreads 这样的多线程 API 开发了自己的规则。来自Pthreads specification section 4.11

应用程序应确保访问任何内存位置的更多 一个控制线程(线程或进程)受到限制,例如 没有控制线程可以读取或修改内存位置,同时 另一个控制线程可能正在修改它。这种访问是 限制使用同步线程执行的函数,以及 与其他线程同步内存。以下 函数相对于其他线程同步内存

然后列出了几十个同步内存的函数,包括pthread_mutex_lockpthread_mutex_unlock

希望支持 pthreads 库的编译器必须实现一些东西来支持这种跨线程内存同步,即使 C++ 规范没有说明它。幸运的是,任何您想要进行多线程的编译器都是在开发时认识到这样的保证是所有多线程的基本,所以每个支持多线程的编译器都有它!

在 gcc 的情况下,它在 pthreads 函数调用上没有任何特殊说明,因为 gcc 会有效地在每个外部函数调用周围创建一个屏障(因为它无法证明没有同步存在于该函数调用中)。如果 gcc 要改变这一点,他们还必须更改其 pthreads 标头以包含将 pthreads 函数标记为同步内存所需的任何额外措辞。

当然,所有这些都是特定于编译器的。在 C++11 出现新的内存模型之前,这个问题没有标准答案。

【讨论】:

  • 查看 pthread 代码库,我没有看到任何编译器障碍,并且 __pthread_mutex_lock 被声明为普通的外部函数。所以我猜你是对的。
  • One of the fundamental rules for reordering is that the compiler must prove that the reorder does not change the result of the program.. 这只是部分正确。编译器做出的唯一保证是程序在从single thread 运行时能够正确运行。
  • @Arunmu屏障有编译器和cpu对应两种。您指的原子屏障是CPU指令级。对?我的问题是 mutex.lock 如何告诉编译器禁用重新排序。我认为 mutex.lock 和原子增量/减量都必须有 CPU 指令内存屏障。
  • @Arunmu 你所说的对于 C++11 之前的 C++ 来说是正确的。然而,Kane 标记了 C++11 并引用了 std::mutex,它只存在于 C++11 及之后的版本中。在 C++11 中,有关重新排序的规则被大幅修改以支持多线程。在此之前,多线程设置中的任何排序保证都是编译器特定的。像 pthreads 这样的库确实依赖于编译器特定保证的存在。 Pthreads 有一个措辞,即某些函数“同步”,这限制了重新排序,如果编译器希望使用 pthreads,则必须支持这一点。
  • @Kane 编译器必须保守。如果它不能证明,按照 C++ 的规则,在函数调用中没有没有 CPU 屏障,它有义务将该函数调用视为编译器屏障,以防万一。因此,如果您先验地知道 mutex.lock 操作确实具有 CPU 内存屏障,那么您也可以先验地知道编译器无法围绕它重新排序,因为您知道编译器无法证明您知道的语句是假的。
【解决方案3】:

注意:我不是这方面的专家,我对它的了解就像意大利面条一样。因此,对答案持保留态度。

注意 2:这可能不是 OP 所期望的答案。但是,如果有帮助的话,这里还是我的 2 美分:

我的问题是我看了一下 GCC5.1 代码库并没有看到 std::mutex::lock/unlock 中的任何特殊内容以防止编译器 重新排序代码。

g++ 使用 pthread 库。 std::mutex 只是 pthread_mutex 的一个薄包装器。因此,您将不得不实际去看看 pthread 的互斥锁实现。
如果您深入了解 pthread 实现(您可以找到 here),您会发现它使用原子指令以及 futex 调用。

这里要记住两件小事:
1. 原子指令确实使用了屏障。
2.任何函数调用都相当于full barrier。不记得从哪里读到的了。
3. mutex 调用可能会使线程休眠并导致上下文切换。

现在,就重新排序而言,需要保证的一件事是,lock 之后和unlock 之前的任何指令都不应重新排序到lock 之前或unlock 之后。我认为这不是一个完整的障碍,而只是分别获取和释放障碍。但是,这又取决于平台,x86 默认提供顺序一致性,而 ARM 提供较弱的排序保证。

我强烈推荐这个博客系列: http://preshing.com/archives/ 它以易于理解的语言解释了许多较低级别的内容。猜猜,我必须再读一遍:)

更新:: 由于篇幅原因,无法对 @Cort Ammons 的回答发表评论

@Kane 我不确定这一点,但人们通常会为处理器级别编写屏障,它也会处理编译器级别的屏障。编译器内置屏障并非如此。

现在,由于 pthread_*lock* 函数定义在您使用它的翻译单元中不存在(这是值得怀疑的),调用 lock - unlock 应该为您提供完整的内存屏障。该平台的 pthread 实现使用原子指令来阻止任何其他线程在锁定之后或解锁之前访问内存位置。现在,由于只有一个线程正在执行代码的关键部分,因此可以确保其中的任何重新排序都不会改变上述评论中提到的预期行为。

Atomics 很难理解和正确,所以,我上面写的都是我的理解。很高兴知道我的理解是否有误。

【讨论】:

  • "任何函数调用都相当于完全屏障。不记得从哪里读到的了。"这不一定是真的。只要代码的行为符合“as-if”规则,内联函数就可以针对外围代码进行重新组织——对于标记为内联的函数和编译器和/或链接器 (LTO) 启发式内联的函数都是如此。即使函数没有内联,这实际上是编译器屏障而不是处理器屏障,因此即使在具有强内存模型(如 x86)的处理器上,仍可能存在一些重新排序。
  • @MaciejPiechotka 是的,内联函数并非如此。是的,我认为它实际上是一个编译器屏障,而不是处理器强制执行的。
  • "任何函数调用都相当于完全屏障。不记得我从哪里读到的。" 任何对单独编译器函数的调用本质上都是一个屏障编译器重新排序,至少是对共享对象的任何访问。这确实是函数调用定义的直接应用。
【解决方案4】:

因此获取/释放应该禁用编译器和 CPU 重新排序指令。

根据定义,任何通过推测执行阻止 CPU 重新排序的东西都会阻止编译器重新排序。这就是语言语义的定义,即使语言中没有 MT(多线程),所以您可以安全地避免在不支持 MT 的旧编译器上重新排序。

但是这些编译器对 MT 来说并不安全,原因有很多,从静态变量的运行时初始化到隐式修改的全局变量(如 errno 等)缺乏线程保护。

此外,在 C/C++ 中,对纯外部函数的任何调用(即:非内联,可在任何时候内联),没有注释解释它的作用(如某些函数的“纯函数”属性)流行的编译器),必须假定可以做任何合法的 C/C++ 代码可以做的事情。不可能进行重要的重新排序(任何可见的重新排序都是重要的)。

在具有多个执行单元且不模拟汇编指令的全局顺序的系统上,任何正确的锁实现都需要内存屏障,并且会阻止重新排序。

在一个线性执行的 CPU 上实现锁,只有一个执行单元(或者所有线程都绑定在同一个执行单元上),可能只使用 volatile 变量进行同步,这不安全,因为 volatile 分别读取。写入不提供任何获取响应的保证。发布任何其他数据(对比 Java)。需要某种编译器屏障,例如强外部函数调用,或一些 asm (""/*nothing*/)(特定于编译器,甚至特定于编译器版本)。

【讨论】:

  • "asm (""/*nothing*/)" 令我惊讶的是,我读到事实上并不能保证旧样式 asm("some asm"); 在 GCC 中有任何“clobber”。这对我来说毫无意义。所以在这里假设一个明确的"memory" clobber。
猜你喜欢
  • 1970-01-01
  • 2014-12-08
  • 1970-01-01
  • 1970-01-01
  • 2012-07-21
  • 1970-01-01
  • 2016-11-13
  • 2010-12-09
  • 2019-04-06
相关资源
最近更新 更多