互斥锁的lock和unlock函数如何防止CPU重排序?

4
据我所知,函数调用充当编译器屏障,但不是CPU屏障。
这个tutorial说:
“获取锁意味着获取语义,而释放锁意味着释放语义!在两者之间的所有内存操作都包含在一个漂亮的屏障夹心中,防止跨边界的任何不良内存重排序。”
我认为上面的引用是在谈论CPU重排序而不是编译器重排序。
但我不明白互斥锁和解锁如何导致CPU给这些函数提供获取和释放语义。
例如,如果我们有以下C代码:
pthread_mutex_lock(&lock);
i = 10;
j = 20;
pthread_mutex_unlock(&lock);

上述C代码被翻译成以下(伪)汇编指令:
push the address of lock into the stack
call pthread_mutex_lock()
mov 10 into i
mov 20 into j
push the address of lock into the stack
call pthread_mutex_unlock()

现在,是什么阻止了CPU将mov 10 into imov 20 into j重新排序到call pthread_mutex_lock()上面或call pthread_mutex_unlock()下面?如果是call指令阻止了CPU进行重新排序,那么为什么我引用的教程让它看起来像是互斥锁函数阻止了CPU重新排序?为什么我引用的教程没有说任何函数调用都会阻止CPU重新排序?我的问题与x86架构有关。

1
我认为上述引用所说的是CPU重排序而不是编译器重排序。当然,锁定/解锁pthread互斥锁必须确保编译器不会重新排列它所保护的指令。因此,该引用同时涉及编译器和CPU重排序——从pthread互斥锁的角度来看,它们同样重要。 - nos
1
我已经为你的答案点赞了,@PeterCordes,并且我拒绝写出自己的答案。但是你的答案包含了如此多的信息,即使有着突出显示,人们仍然很容易忽略其中的关键点——我认为这是问题的关键所在。 - John Bollinger
1
我写下了我的答案,因为尽管@PeterCordes的回答很好,但我认为这个问题也值得一个“小”答案,重点只关注我认为OP在问什么:编译器/运行时/实现如何防止CPU重排序(而不是编译器重排序)。其答案很简单:实现包括阻止CPU重排序的屏障。OP评论了我的答案,揭示了他们最初的误解:重排序可能发生在“call”周围,就像其他任何指令一样。它不会。重排序发生在“动态跟踪”中。 - BeeOnRope
首先,一般来说,pthread_mutex_lock()只是一系列指令,所以再次一般地说,CPU并不知道它是一个特殊的函数,处理器内存重排序是被禁止的。因此,第三次一般地说,没有什么可以阻止CPU将mov 10 into i重新排序到call pthread_mutex_lock()之上。因此,自然而然地会问这个问题。现在让我从互斥锁的含义开始回答以下问题... - zzzhhh
我们都知道,互斥锁的含义在所有操作系统/并行编程教材中都有所描述,即关键区域被定义为最多只能由一个线程执行。因此,如果在线程1的mov 10 into i代码被重新排序到call pthread_mutex_lock()之前时,线程2可能会在那一点上运行关键部分,因为线程1尚未获取互斥锁,这是违反互斥锁含义的。你可以找到很多由于两个线程同时运行关键部分代码而导致灾难的例子。 - zzzhhh
显示剩余7条评论
2个回答

9
简短的回答是,在调用pthread_mutex_lockpthread_mutex_unlock时,函数体内将包含必要的特定于平台的内存屏障,这将防止CPU在临界区域之外移动内存访问。指令流程将通过call指令从调用代码移动到lockunlock函数中,并且就重新排序而言,你需要考虑的是此动态指令跟踪,而不是在汇编清单中看到的静态序列。
特别是在x86上,你可能不会在这些方法中找到显式的、独立的内存屏障,因为为了执行实际的原子锁定和解锁操作,您将已经有了lock-prefixed指令,并且这些指令意味着完全的内存屏障,这可以防止你所关心的CPU重新排序。
例如,在我的Ubuntu 16.04系统上,使用glibc 2.23实现pthread_mutex_lock使用一个lock cmpxchg(比较和交换),而pthread_mutex_unlock则使用lock dec(递减),这两者都具有完整的屏障语义。

2
因为指令的动态流程(大部分)才是重新排序的重点。CPU 不会把 call 作为一个单独的指令执行,然后继续执行源代码/汇编顺序中的下一条指令,它会进入 pthread 函数的主体。因此,为了进行分析,您可以想象在 call 指令出现的位置上,整个 lockunlock 调用的主体都被“内联”到汇编中。如果不是这样,互斥锁和许多其他同步机制将无法作为普通函数调用实现! - BeeOnRope
1
@user8426277 - 我更新了我的问题以使其更清晰。底线是,在汇编级别,你无法讨论在calljmp或任何其他控制流更改之间可能发生的重新排序类型,因为你需要知道跳转到的位置会发生什么。 - BeeOnRope
1
@PeterCordes 我从未说过 call 没有释放效果,我是说你需要知道 call 内部的内容才能了解 call 对指令跟踪的整体影响。当然,call 本身可能具有一些排序语义,例如与存储到堆栈的相关语义,但这实际上是无用的,而且在我看来是一个完全的红鱼(另一个答案很好地涵盖了它 :-)。请注意,release-acquire 谈论的是位置:互斥锁的 lock 部分不会与在另一个线程上将返回地址推入堆栈的 push 同步,因此该 release 并不是非常有用。 - BeeOnRope
1
@PeterCordes - 嗯,是的,我想知道是否有人会说出这样的话,我曾经考虑过让它更加明确,比如指出call本身可能会阻止一些重排,但是函数体可能会阻止更多的重排。话虽如此,我实际上认为说call具有“释放语义”并没有意义(或许暗示着你不需要在unlock实现中使用存储器):当谈论特定位置时,“释放”才有意义,在call的情况下,该位置是堆栈上的匿名位置。 - BeeOnRope
1
@user8426277 - 对,我明白了。但是我想说的是,在汇编级别的列表中,CPU不会重新排序。你想象汇编像call mutex_lock; store 10 to I;,然后问“如果CPU在call之前重新排序存储,会发生什么?”答案是这不是正确的看待方式:CPU不会针对磁盘上二进制文件中找到的静态汇编重新排序,它针对的是指令的动态流,因此您需要在脑海中跟踪执行并记录该流。 - BeeOnRope
显示剩余9条评论

6
如果ij是局部变量,那么没有什么影响。如果编译器能够证明在当前函数之外没有使用它们的地址,那么可以将它们保存在寄存器中,跨越函数调用。
但是任何全局变量或者那些可能被存储在全局变量中的局部变量,在非内联函数调用时都需要在内存中“同步”。编译器必须假设任何不能内联的函数调用都会修改它可能引用的任何/每个变量。
例如,如果int i;是一个局部变量,在sscanf("0", "%d", &i);之后,它的地址将逃逸函数,编译器将不得不在函数调用周围溢出/重新加载它,而不是将其保留在可调用的保留寄存器中。

请查看我在了解volatile asm与volatile变量上的答案,其中提供了一个例子,asm volatile("":::"memory")是一个屏障,用于一个地址逃逸到函数外部的局部变量(sscanf("0", "%d", &i);),但对于仍然纯粹局部的局部变量则不是。这完全是由于相同的原因导致的相同行为。


我认为上述引用所讨论的是CPU重排序,而不是编译器重排序。
它涉及到两者,因为两者都对正确性至关重要。
这就是为什么编译器不能将对共享变量的更新与任何函数调用重新排序。 (这非常重要:弱C11内存模型允许大量编译时重新排序。强x86内存模型仅允许StoreLoad重新排序和本地store-forwarding。) pthread_mutex_lock作为一个非内联函数调用来处理编译时重新排序,而它执行了一个锁定操作,即原子RMW,这也意味着在x86上包含一个完整的运行时内存屏障。(不过,只有函数体中的代码,而不是call指令本身。)这赋予了它获取语义。

解锁自旋锁只需要一个release-store,而不需要RMW,因此根据实现细节,解锁函数可能不是一个StoreLoad屏障。(这仍然可以:它使关键部分中的所有内容都不会泄漏出去。不必阻止后续操作在解锁之前出现。请参见Jeff Preshing的文章解释获取和发布语义)

在弱有序ISA上,这些互斥函数将运行屏障指令,例如ARM dmb(数据存储器屏障)。普通函数则不会,因此该指南的作者指出这些函数是特殊的是正确的。


现在,是什么阻止了CPU将mov 10移动到i中并将mov 20移动到j中,在call pthread_mutex_lock()之上重新排序? 这不是重要的原因(因为在弱序ISA上,pthread_mutex_unlock将运行一个barrier指令),但实际上,在x86上,存储甚至不能与call指令重新排序,更不用说函数体在返回之前所做的实际锁定/解锁互斥体。 x86具有强大的内存排序语义(存储不会与其他存储器重新排序),而call是一个存储器(推送返回地址)。 因此,mov [i],10必须出现在call指令执行的存储之间。 当然,在正常程序中,没有人观察其他线程的调用堆栈,只有使用xchg获取互斥锁或在pthread_mutex_unlock中释放它的release-store。

1
@ArchD.Robison:编译器只能证明如果仅接触“static”变量的函数本身是“static”,并且不从任何非“static”函数调用,并且没有将函数地址传递给任何黑盒函数。否则,它必须假定未知函数可以回调到此翻译单元以最终达到读取或写入静态变量的函数。线程创建和信号处理程序函数是黑盒,因此没有办法启动运行“static”函数的另一个线程,而不必让静态变量访问“逃脱”翻译单元。 - Peter Cordes
1
如果您有一组仅由函数F1操纵的变量V,这些变量仅被函数集F2调用,并且不能在翻译单元之外命名任何一个变量,而且在翻译单元内没有其他函数采取地址以将其放置在其他地方可访问的对象中......最终,V,F1,F2 ... 就毫无意义了。 - curiousguy
1
@ArchD.Robison 假定在一个实际的程序中,静态变量可以通过间接方式从主函数中访问。 - curiousguy
1
@curiousguy:说得好。除非这是包含“main”的TU,否则如果编译器基于在程序内再次调用“main”是UB,则可能证明这些变量只能由主线程使用。我不认为有任何情况下非内联函数调用不能对实际共享的任何变量进行排序。 - Peter Cordes
1
我同意@PeterCordes的分析。我忘记考虑“回调”可能性了。 - Arch D. Robison
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接