自旋锁,它们有多大用处?

43

你在代码中实际使用自旋锁的频率有多高? 在使用繁忙循环的情况下优于锁的情况有多常见?
个人而言,在编写需要线程安全的某种代码时,我倾向于使用不同的同步原语进行基准测试,就目前而言,使用锁比使用自旋锁性能更好。无论我实际持有锁的时间多短,使用自旋锁时我获得的争用量要比使用锁时获得的争用量大得多(当然,我在多处理器机器上运行我的测试)。

我认识到在“低级”代码中更有可能遇到自旋锁,但我想知道即使在更高级别的编程中,你是否发现它有用?


5
总的来说,自旋锁可以完成“工作”(同步)。我正在观察这个问题,看看在C#中是否有适合使用自旋锁的同步方法。 - Sam Harwell
10个回答

44

这取决于你要做什么。一般在应用程序代码中,你会想避免自旋锁。

在低级别的东西中,你只需要保持锁定几个指令,并且延迟很重要时,自旋锁可能比锁更好。但这种情况很少见,特别是在通常使用C#的应用程序中。


33
特别是在您拥有多个处理器核心的情况下,让一个核心等待另一个核心可能比触发重新调度要快得多。如果存在大量争用,上下文切换会累积。在单核心情况下,自旋锁不太具有吸引力,因为这相当于在不允许下一个线程进入直到过期之前放弃了其余时间片,因此它会导致非常缓慢的产出... - Steve Jessop

26
在C#中,根据我的经验,“自旋锁”几乎总是比使用锁更糟糕 - 只有在极少数情况下,自旋锁才能胜过锁。
然而,并非总是如此。在.NET 4中,添加了一个System.Threading.SpinLock结构。这提供了在锁被持续很短时间并且重复抓取时的益处。根据Data Structures for Parallel Programming中的MSDN文档:

在等待锁的时间较短的场景下,自旋锁的性能优于其他形式的锁。

如果您正在通过树进行锁定,自旋锁可以在某些情况下优于其他锁定机制 - 如果您只需要对每个节点进行非常短的锁定,它们可以胜过传统锁。我曾经在渲染引擎中遇到过这种情况,在多线程场景更新中,自旋锁的性能大幅优于使用Monitor.Enter进行锁定。

14

在进行实时工作时,尤其是与设备驱动程序相关的工作中,我经常使用它们。事实证明(上次我计时时),等待与硬件中断绑定的同步对象(如信号量)至少需要消耗20微秒的时间,无论实际中断发生的时间多长。单个对内存映射硬件寄存器的检查,紧随其后的是对RDTSC的检查(以允许超时,以避免锁住机器),只需要花费高纳秒级别的时间(基本上在噪音范围内)。对于不应该花费太多时间的硬件级握手来说,自旋锁真的很难被击败。


13年后的澄清:如果只是一个检查,20微秒并不算太多。因此,对于高级代码来说,这可能不值得潜在问题。然而,在实际上需要在每个I/O上进行多次握手的情况下,其中几个确实会累加起来。 - T.E.D.

12

我的意见是:如果您的更新满足一些访问条件,那么它们就是很好的自旋锁候选者:

  • 快速,即您将有时间在单个线程量子中获取自旋锁、执行更新并释放自旋锁,以便在持有自旋锁时不会被抢占
  • 局部化,所有您要更新的数据都在最好是已加载的单个页面中,您不希望在持有自旋锁时出现TLB缺失,而且您肯定不希望出现页面故障交换读取!
  • 原子性,您不需要任何其他锁来执行操作,即在自旋锁下永远不要等待锁。

对于任何可能产生效果的事情,都应该使用通知的锁结构(事件、互斥体、信号量等)。


内核模式的锁机制比用户模式的锁选择要慢得多,有时甚至慢100倍!因此,互斥量等可能不是一个真正好的实现选项。 - Zuuum

9

自旋锁的一个使用场景是,如果你预计争用非常少,但是需要很多自旋锁。如果您不需要支持递归锁定,则可以在单个字节中实现自旋锁,并且如果争用非常低,则CPU周期浪费可以忽略不计。

对于实际应用场景,我经常有数千个元素的数组,其中可以安全地并行更新数组的不同元素。两个线程同时尝试更新相同元素的概率非常小(低争用),但我需要为每个元素都有一个锁(我将拥有很多这样的元素)。在这些情况下,我通常会分配一个与我并行更新的数组大小相同的无符号字节数组,并将自旋锁内联实现为以下方式(使用D编程语言):

while(!atomicCasUbyte(spinLocks[i], 0, 1)) {}
    myArray[i] = newVal;
atomicSetUbyte(spinLocks[i], 0);

另一方面,如果我必须使用常规锁,我将不得不分配一个指向对象的指针数组,并为该数组的每个元素分配一个互斥对象。在上述场景中,这是很浪费的。


6
如果您有性能关键代码,并且已经确定它需要比当前速度更快,并且已经确定关键因素是锁速度,那么尝试使用自旋锁可能是一个好主意。在其他情况下,为什么要麻烦呢?普通锁更容易正确使用。

5
请注意以下几点:
  1. 大多数互斥锁的实现在线程实际被取消调度之前会自旋一段时间。因此,很难将这些互斥锁与纯自旋锁进行比较。

  2. 多个线程在同一个自旋锁上“尽可能快地自旋”会消耗所有带宽,并且极大地降低程序效率。您需要通过在自旋循环中添加noop来添加微小的“休眠”时间。


如果您的互斥锁没有这样做,那么您可以自己使用自旋锁+互斥锁:自旋X次,然后锁定。 - Zan Lynx

3
我在我的HLVM项目中使用自旋锁来停止垃圾收集器的全停机阶段,因为它们易于使用并且那是一个玩具VM。然而,在这种情况下,自旋锁可能会产生反效果:
Glasgow Haskell编译器的垃圾收集器中存在一个性能错误,如此恼人以至于有一个名称,即“last core slowdown”。这是由于他们在GC中不适当地使用自旋锁所直接导致的,在Linux上由于其调度程序而加剧,但实际上,该效果可以在其他程序竞争CPU时间时观察到。
该效果在第二张图here上很明显,并且可以看到影响不仅仅是最后一个核心here,其中Haskell程序的性能降低超过5个核心。

3

在应用程序代码中,您几乎从不需要使用自旋锁,如果有什么需要,您应该避免使用它们。

我想不出在正常操作系统上运行的C#代码中使用自旋锁的任何理由。忙锁在应用程序级别上大多是浪费 - 自旋可能导致您使用整个CPU时间片,而锁定将立即在需要时引起上下文切换。

高性能代码,其中线程数=处理器/核心数,在某些情况下可能会受益,但如果您需要在那个级别进行性能优化,则可能正在制作下一代3D游戏,在使用具有较差同步原语的嵌入式操作系统,创建操作系统/驱动程序或以任何情况都不使用C#。


1

在使用自旋锁时,请始终记住以下几点:

  • 快速的用户模式执行。
  • 同步单个进程内的线程,或者如果在共享内存中,则同步多个进程。
  • 在对象被拥有之前不会返回。
  • 不支持递归。
  • 在“等待”期间会消耗100%的CPU。

我亲眼见过很多死锁,只是因为有人认为使用自旋锁是一个好主意。

使用自旋锁时一定要非常小心

(我无法强调这一点的重要性)。


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