锁定未锁定的互斥体的效率如何?互斥锁的成本是多少?

Posted

技术标签:

【中文标题】锁定未锁定的互斥体的效率如何?互斥锁的成本是多少?【英文标题】:How efficient is locking an unlocked mutex? What is the cost of a mutex? 【发布时间】:2011-04-08 19:11:33 【问题描述】:

在低级语言(C、C++ 或其他语言)中:我可以选择使用一堆互斥锁(例如 pthread 给我的或本地系统库提供的任何东西)或一个对象的单个互斥锁。

锁定互斥体的效率如何? IE。可能有多少条汇编指令,它们需要多少时间(在互斥锁解锁的情况下)?

互斥锁的成本是多少?真的有很多互斥量是个问题吗?或者我可以在我的代码中抛出与int 变量一样多的互斥变量,这并不重要?

(我不确定不同硬件之间有多少差异。如果有,我也想了解它们。但我主要对通用硬件感兴趣。)

关键是,通过使用许多互斥锁,每个互斥锁只覆盖对象的一部分,而不是整个对象的单个互斥锁,我可以保护许多块。我想知道我应该走多远。 IE。我是否应该尽可能地保护任何可能的块,无论这意味着多少更复杂和多少互斥锁?


WebKits blog post (2016) about locking 与这个问题非常相关,并解释了自旋锁、自适应锁、futex 等之间的区别。

【问题讨论】:

这将是特定于实现和架构的。如果有本地硬件支持,一些互斥锁几乎不会花费任何成本,而另一些则会花费很多。没有更多信息是不可能回答的。 @Gian:嗯,当然我在我的问题中暗示了这个子问题。我想了解常见的硬件,但如果有的话,我也想知道值得注意的例外情况。 我真的没有在任何地方看到这种暗示。您询问“汇编指令” - 答案可能是 1 条指令到 1 万条指令,具体取决于您所谈论的架构。 @Gian:那么请给出这个答案。请说出它在 x86 和 amd64 上的实际情况,请举例说明它是 1 条指令的架构,并给出一个 10k 指令的架构示例。我想从我的问题中知道这一点不是很清楚吗? 【参考方案1】:

我想知道同样的事情,所以我测量了它。 在我的盒子上(AMD FX(tm)-8150 八核处理器,3.612361 GHz), 锁定和解锁位于其自己的缓存行中且已缓存的未锁定互斥体需要 47 个时钟(13 ns)。

由于两个内核之间的同步(我使用 CPU #0 和 #1), 我只能在两个线程上每 102 ns 调用一次锁定/解锁对, 所以每 51 ns 一次,从中可以得出结论,在一个线程解锁之后,下一个线程可以再次锁定它之前,大约需要 38 ns 才能恢复。

我用来调查这个问题的程序可以在这里找到: https://github.com/CarloWood/ai-statefultask-testsuite/blob/b69b112e2e91d35b56a39f41809d3e3de2f9e4b8/src/mutex_test.cxx

请注意,它有一些特定于我的盒子的硬编码值(xrange、yrange 和 rdtsc 开销),因此您可能必须先对其进行试验,然后才能使用它。

它在该状态下生成的图形是:

这显示了在以下代码上运行基准测试的结果:

uint64_t do_Ndec(int thread, int loop_count)

  uint64_t start;
  uint64_t end;
  int __d0;

  asm volatile ("rdtsc\n\tshl $32, %%rdx\n\tor %%rdx, %0" : "=a" (start) : : "%rdx");
  mutex.lock();
  mutex.unlock();
  asm volatile ("rdtsc\n\tshl $32, %%rdx\n\tor %%rdx, %0" : "=a" (end) : : "%rdx");
  asm volatile ("\n1:\n\tdecl %%ecx\n\tjnz 1b" : "=c" (__d0) : "c" (loop_count - thread) : "cc");
  return end - start;

两个 rdtsc 调用测量锁定和解锁“互斥锁”所需的时钟数(我的盒子上的 rdtsc 调用开销为 39 个时钟)。第三个 asm 是延迟循环。线程 1 的延迟循环大小比线程 0 小 1 个计数,因此线程 1 稍快。

上述函数在大小为 100,000 的紧密循环中调用。尽管线程 1 的函数稍快一些,但由于调用了互斥锁,两个循环都同步了。从图中可以看出这一点,即线程 1 的锁定/解锁对测量的时钟数略大,这是因为它下面的循环中的延迟较短。

在上图中,右下角的点是延迟 loop_count 为 150 的测量值,然后跟随底部的点,向左,每个测量值将 loop_count 减一。当它变为 77 时,该函数在两个线程中每 102 ns 调用一次。如果随后 loop_count 进一步减少,则不再可能同步线程,并且互斥体在大多数情况下实际上开始被锁定,从而导致执行锁定/解锁所需的时钟量增加。函数调用的平均时间也因此增加;所以情节点现在又向上并向右移动了。

由此我们可以得出结论,每 50 ns 锁定和解锁一个互斥锁对我的机器来说不是问题。

总而言之,我的结论是,对 OP 问题的回答是,只要减少争用,添加更多互斥锁会更好。

尝试尽可能短地锁定互斥锁。将它们放在循环之外的唯一原因是,如果该循环的循环速度超过每 100 ns 一次(或者更确切地说,想要同时运行该循环的线程数乘以 50 ns)或 13 ns 时间循环大小比您通过争用获得的延迟更多。

编辑:我现在对这个主题有了更多的了解,并开始怀疑我在这里提出的结论。首先,CPU 0 和 1 是超线程的;尽管 AMD 声称拥有 8 个真正的核心,但肯定有一些非常可疑的东西,因为其他两个核心之间的延迟要大得多(即 0 和 1 形成一对,2 和 3、4 和 5、6 和 7 也是如此) )。其次,std::mutex 的实现方式是,当它无法立即获得互斥锁上的锁(这无疑会非常慢)时,它会在实际执行系统调用之前先旋转一点锁。所以我在这里测量的是绝对最理想的情况,实际上锁定和解锁每次锁定/解锁可能需要更多的时间。

归根结底,互斥锁是用原子实现的。为了在内核之间同步原子,必须锁定内部总线,这会将相应的高速缓存行冻结数百个时钟周期。在无法获得锁的情况下,必须执行系统调用使线程进入休眠状态;这显然非常慢(系统调用大约为 10 微秒)。通常这不是一个真正的问题,因为该线程无论如何都必须休眠 - 但它可能是一个高争用的问题,其中一个线程无法在它正常旋转的时间内获得锁,系统调用也是如此,但 CAN不久之后拿锁。例如,如果多个线程在一个紧密的循环中锁定和解锁一个互斥体,并且每个线程都保持锁定 1 微秒左右,那么它们可能会因为它们不断地进入睡眠状态并再次被唤醒而大大减慢。此外,一旦一个线程休眠并且另一个线程必须唤醒它,该线程必须进行系统调用并延迟约 10 微秒;因此,当另一个线程在内核中等待该互斥体时(在旋转花费了太长时间之后),解锁互斥体时会发生这种延迟。

【讨论】:

Bulldozer 系列 CPU 有 2 个整数内核,每个模块一个 FPU。尽管浮点数并不快,但每个模块的整数性能几乎是双倍的,但不是完全双倍的。使用模块的两个部分的 IIRC 比使用 2 个单独的内核慢约 8%。是否是 SMT 存在争议。 SMT 通常会带来大约 5-20% 的提升,而对于非浮点操作,模块会接近 90-95%(后来的版本更好)。【参考方案2】:

我对 pthread 和互斥锁完全陌生,但我可以从实验中确认,在没有争用时锁定/解锁互斥锁的成本几乎是 zilch,但是当有争用时,阻塞的成本非常高.我用线程池运行了一个简单的代码,其中的任务只是计算一个受互斥锁保护的全局变量的总和:

y = exp(-j*0.0001);
pthread_mutex_lock(&lock);
x += y ;
pthread_mutex_unlock(&lock);

使用一个线程,程序几乎可以瞬间(不到一秒)对 10,000,000 个值求和;使用两个线程(在 4 核 MacBook 上),相同的程序需要 39 秒。

【讨论】:

【参考方案3】:

这取决于您实际所说的“互斥锁”、操作系统模式等。

最低是互锁内存操作的成本。这是一个相对繁重的操作(与其他原始汇编器命令相比)。

但是,这可能要高得多。如果您所说的“互斥锁”是一个内核对象(即 - 由操作系统管理的对象)并在用户模式下运行 - 对它的每个操作都会导致内核模式事务,这是 非常 繁重的。

例如在 Intel Core Duo 处理器、Windows XP 上。 联锁操作:大约需要 40 个 CPU 周期。 内核模式调用(即系统调用) - 大约 2000 个 CPU 周期。

如果是这种情况 - 您可以考虑使用临界区。它是内核互斥锁和互锁内存访问的混合体。

【讨论】:

Windows 临界区更接近于互斥锁。它们具有常规互斥体语义,但它们是进程本地的。最后一部分使它们更快,因为它们可以完全在您的进程(以及用户模式代码)中处理。 如果还提供了常用操作(例如算术/if-else/cache-miss/indirection)的 CPU 周期数进行比较,该数字会更有用。 ....如果有一些数字的参考,那就太好了。在互联网上,很难找到这样的信息。 @javaLover 操作不会循环运行;它们在算术单元上运行多个周期。这是非常不同的。任何指令的时间成本都不是一个定义的数量,只是资源使用的成本。这些资源是共享的。内存指令的影响取决于很多缓存等 @curiousguy 同意。我不清楚。我想要std::mutex 之类的答案,平均使用持续时间(以秒为单位)是int++ 的 10 倍。但是,我知道这很难回答,因为它在很大程度上取决于很多事情。【参考方案4】:

我可以选择使用一堆互斥锁或一个对象使用一个互斥锁。

如果你有很多线程并且对对象的访问经常发生,那么多个锁会增加并行度。以可维护性为代价,因为更多的锁定意味着更多的锁定调试。

锁定互斥体的效率如何? IE。可能有多少汇编指令以及它们需要多少时间(在互斥锁解锁的情况下)?

精确的汇编指令是a mutex 的最小开销 - the memory/cache coherency 保证是主要开销。并且不太经常使用特定的锁 - 更好。

互斥锁由两个主要部分组成(过于简单化):(1)指示互斥锁是否被锁定的标志和(2)等待队列。

改变标志只是几条指令,通常无需系统调用即可完成。如果互斥锁被锁定,系统调用将发生将调用线程添加到等待队列并开始等待。如果等待队列为空,则解锁成本很低,但否则需要系统调用来唤醒其中一个等待进程。 (在某些系统上,使用便宜/快速的系统调用来实现互斥锁,它们仅在发生争用时才会变成慢速(正常)系统调用。)

锁定未锁定的互斥锁真的很便宜。解锁无争用互斥锁也很便宜。

互斥锁的成本是多少?真的有很多互斥锁是个问题吗?或者我可以在我的代码中抛出与 int 变量一样多的互斥变量,这并不重要?

您可以在代码中添加任意数量的互斥变量。您只受到应用程序可以分配的内存量的限制。

总结。用户空间锁(尤其是互斥锁)很便宜,并且不受任何系统限制。但是它们中的太多会成为调试的噩梦。简单表:

    更少的锁意味着更多的争用(缓慢的系统调用、CPU 停顿)和更少的并行度 更少的锁意味着调试多线程问题的问题更少。 更多的锁意味着更少的争用和更高的并行度 更多的锁意味着更多的机会遇到不可调试的死锁。

应找到并维护一个平衡的应用程序锁定方案,通常平衡#2 和#3。


(*) 不太经常锁定互斥锁的问题是,如果您在应用程序中锁定过多,则会导致大部分 CPU/内核间流量从其他 CPU 的数据缓存中刷新互斥锁内存以保证缓存的一致性。缓存刷新类似于轻量级中断,由 CPU 透明处理 - 但它们确实引入了所谓的stalls(搜索“stall”)。

停顿是导致锁定代码运行缓慢的原因,通常没有任何明显迹象表明应用程序运行缓慢。 (有些架构提供 CPU/核心间的流量统计,有些不提供。)

为了避免这个问题,人们通常会使用大量的锁来降低锁争用的概率并避免停顿。这就是为什么存在不受系统限制的廉价用户空间锁定的原因。

【讨论】:

谢谢,这主要回答了我的问题。我不知道内核(例如 Linux 内核)会处理互斥锁,而您可以通过系统调用来控制它们。但由于 Linux 本身管理调度和上下文切换,这是有道理的。但是现在我对互斥锁/解锁在内部会做什么有了一个粗略的想象。 @Albert:哦。我忘记了上下文切换......上下文切换对性能来说太耗电了。如果锁获取失败并且线程必须等待,那么这就是上下文切换的一半。 CS 本身速度很快,但由于 CPU 可能被其他进程使用,缓存中将充满外来数据。在线程最终获得锁之后,CPU 很可能不得不从 RAM 重新加载几乎所有内容。 @Dummy00001 切换到另一个进程意味着您必须更改 CPU 的内存映射。那可不便宜。 许多小锁不会使事情变得更复杂,尤其是当它们被持有的时间很短时。然而,当你不可避免地不得不嵌套它们时,拥有更少、更大的锁会使事情变得更加复杂。因此,我真的不同意“更多的锁意味着更多的机会陷入不可调试的死锁”。【参考方案5】:

成本会因实施而异,但您应该记住两点:

成本很可能是最低的,因为它既是一个相当原始的操作,而且由于它的使用模式(使用了一个很多),它会被尽可能地优化。 不管多贵,因为如果您想要安全的多线程操作,就需要使用它。如果你需要它,那么你就需要它。

在单处理器系统上,您通常可以只禁用足够长的中断来自动更改数据。多处理器系统可以使用test-and-set 策略。

在这两种情况下,指令都相对有效。

至于你是应该为海量数据结构提供一个互斥锁,还是为它的每个部分提供多个互斥锁,这是一种平衡行为。

通过使用单个互斥体,多个线程之间发生争用的风险更高。您可以通过为每个部分设置一个互斥锁来降低这种风险,但您不想陷入线程必须锁定 180 个互斥锁才能完成工作的情况:-)

【讨论】:

是的,但是如何高效?它是一条机器指令吗?还是大约10个?还是100左右? 1000?更多的?所有这些仍然是有效的,但在极端情况下会有所作为。 嗯,这完全取决于实现。您可以在大约六个机器指令的循环中关闭中断、测试/设置整数并重新激活中断。由于处理器倾向于将其作为单个指令提供,因此测试和设置可以在尽可能多的时间内完成。 总线锁定的测试和设置是 x86 上的一条(相当长的)指令。使用它的其他机器非常快(“测试成功了吗?”这是 CPU 擅长快速完成的一个问题),但是总线锁定指令的长度确实很重要,因为它是阻塞事物的部分。带有中断的解决方案要慢得多,因为操作它们通常仅限于操作系统内核来阻止琐碎的 DoS 攻击。 顺便说一句,不要使用 drop/reacquire 作为让线程让给他人的手段;这是一种对多核系统不利的策略。 (这是 CPython 出错的相对较少的事情之一。) @Donal:你所说的删除/重新获取是什么意思?这听起来很重要;你能给我更多的信息吗?

以上是关于锁定未锁定的互斥体的效率如何?互斥锁的成本是多少?的主要内容,如果未能解决你的问题,请参考以下文章

尝试锁定共享内存互斥体时出现分段错误

为啥互斥锁没有被锁定

为什么我们想在具有互斥锁的情况下使函数递归?

go sync包分析

一个用户崩溃时共享内存中的互斥锁?

如果互斥锁被锁定,安全地跳过任务