锁定语句的代价有多大?

131

我一直在尝试多线程和并行处理,并且我需要一个计数器来进行一些基本的统计分析,以了解处理速度。为了避免使用我的类时出现并发问题,我在类的私有变量上使用了锁语句:

private object mutex = new object();

public void Count(int amount)
{
 lock(mutex)
 {
  done += amount;
 }
}

但是我想知道......锁定一个变量有多昂贵呢?对性能有什么负面影响吗?


10
锁定变量并不是很昂贵;你要避免的是等待被锁定的变量。 - Gabe
73
比起花费数小时来追踪另一个竞态条件,这要便宜得多;-) - BrokenGlass
2
嗯...如果锁很昂贵,您可能希望通过更改编程以需要更少的锁来避免它们。我可以实现某种类型的同步。 - Kees C. Bakker
1
我在读了@Gabe的评论后,通过将大量代码移出锁定块,显著提高了性能。总之,从现在开始,我只会在锁定块内留下变量访问(通常只有一行),这种方式就像是“即时锁定”。这样做有意义吗? - heltonbiker
2
当然有意义。这也应该是架构原则,你应该尽可能地使锁变得短小、简单和快速。只同步真正必要的数据。在服务器上,你还应该考虑锁的混合特性。即使对你的代码来说不是关键问题,由于锁的混合特性导致核心在每次访问时旋转,因此需要考虑争用。在你的线程被挂起之前,你实际上正在从服务器上的其他服务中吞噬一些CPU资源。 - ipavlu
7个回答

102

这里有一篇关于成本的文章。简短回答是50纳秒。


48
简短的答案:如果另一个线程持有锁,等待时间加上50纳秒。 - Herman
5
线程进入和离开锁的次数越多,成本就会越昂贵。随着线程数量的增加,成本将呈指数级增长。 - Arsen Zahray
27
一些背景说明:在3Ghz x86上对两个数字进行除法大约需要10ns(不包括获取/解码指令的时间);将单个变量从(非缓存的)内存加载到寄存器需要大约40ns。因此,50ns的速度是极快的,你无需担心使用“lock”的成本,就像你不会担心使用变量的成本一样。 - BlueRaja - Danny Pflughoeft
4
同时,当这个问题被提出时,那篇文章已经过时了。 - Otis
11
非常出色的度量标准,“成本几乎为零”,更不用说是不正确的了。你们没有考虑到,它只有在完全没有争用、一个线程的情况下才是短小快速的,此时根本不需要锁。第二个问题是,锁并不是锁,而是混合锁,它在CLR内部检测到锁未被任何人持有,基于原子操作,在这种情况下,它避免了对操作系统核心的调用,后者是不受这些测试衡量的不同环。如果没有获取锁,则测量值为25ns到50ns的实际上是应用程序级别的交替指令代码。 - ipavlu
显示剩余5条评论

54
技术上说,这是不可能量化的,它严重依赖于CPU内存写回缓冲区的状态以及预取器收集到的数据需要被丢弃和重新读取的数量,这两者都非常不确定。我使用150个CPU周期作为一个快速估算,避免了主要的失望。
实际上,这比你认为可以跳过锁定时调试代码所花费的时间要便宜得多。
要获得一个硬数字,您必须进行测量。Visual Studio有一个漂亮的并发分析器作为扩展可用。

2
实际上不是这样的,它可以被量化和测量。只是并不像在代码周围写那些锁一样容易,然后声明它只是50ns,这是一个基于单线程访问锁的谬论。 - ipavlu
8
“认为你可以跳过锁”……我认为当很多人看到这个问题时,他们就会有这种想法。 - Snoop

35

进一步阅读:

我想介绍几篇我写的文章,它们与通用同步原语有关,并深入探讨了监视器、C#锁定语句行为、属性和成本,具体取决于不同的场景和线程数。它特别关注CPU浪费和吞吐量周期,以了解在多种情况下可以推动多少工作:

https://www.codeproject.com/Articles/1236238/Unified-Concurrency-I-Introduction https://www.codeproject.com/Articles/1237518/Unified-Concurrency-II-benchmarking-methodologies https://www.codeproject.com/Articles/1242156/Unified-Concurrency-III-cross-benchmarking

原始回答:

哦,亲爱的!

看来这里标记为“答案”的正确答案本质上是不正确的!我想恭敬地请答案的作者阅读链接的文章到底。 文章

这篇2003年的文章的作者 文章 只在双核机器上进行了测量,在第一个测量案例中,他 只使用单个线程测量锁定,结果是每个锁访问约为50ns。

这并不意味着在并发环境中的锁定情况。 因此,我们必须继续阅读文章,在后半部分,作者正在测量使用两个和三个线程的锁定场景,这接近于今天处理器的并发级别。

因此,作者说,在双核心上使用两个线程时,锁定成本为120ns,而使用3个线程时,则增加到180ns。因此,它似乎明显取决于同时访问锁定的线程数。

所以很简单,除非是单个线程,否则它不是50纳秒,其中锁定将变得无用。

考虑的另一个问题是它被测量为平均时间

如果测量迭代的时间,那么将有1毫秒到20毫秒之间的时间,因为大多数是快速的,但是少数线程将等待处理器时间,并产生甚至长达几毫秒的延迟。

这对于任何需要高吞吐量、低延迟的应用程序来说都是一个坏消息。

最后需要考虑的问题是,在锁内部可能会出现较慢的操作,这种情况往往很常见。如果在锁内执行的代码块越长,争用就越高,延迟也会急剧上升。

请注意,从2003年到现在已经过去了十多年,这几代专门设计用于完全并发运行的处理器,而锁定显著地损害了它们的性能。


4
澄清一下,这篇文章并不是说锁的性能会随着应用程序中线程数量的增加而降低;性能会随着争夺锁的线程数量的增加而降低。(虽然这是上面答案中暗示的,但并没有明确说明。) - Gooseberry
我猜你的意思是:“因此,它似乎明显取决于同时访问的线程数量,而更多则更糟。” 是的,措辞可能可以更好。我指的是“同时访问”,即线程同时访问锁,从而创建争用。 - ipavlu

23

这并没有回答你关于性能的问题,但我可以说.NET Framework确实提供了一个Interlocked.Add方法,它将允许你在不手动锁定其他对象的情况下将amount添加到你的done成员中。


1
是的,这可能是最好的答案。但主要原因是代码更短、更清晰。速度上的差异不太可能被注意到。 - H H
谢谢你的回答。我正在使用锁做更多的事情。添加整数只是其中之一。非常感谢你的建议,我从现在开始会使用它。 - Kees C. Bakker
锁定机制要比无锁代码更容易实现,即使无锁代码可能会更快。仅使用Interlocked.Add与未同步的+=一样存在问题。 - hangar
无锁(Lock-free)不仅是“潜在更快”的,它可以在极度紧密、长时间运行的并发循环中快上数个数量级。 - antikbd

10

lock(Monitor.Enter / Exit)非常廉价,比等待句柄或互斥体等替代方案更为廉价。

但是,如果它速度变慢了(一点),您是否愿意拥有一个速度快但结果不正确的程序?


8
哈哈...我追求的是快速的方案和优秀的结果。 - Kees C. Bakker
1
@henk-holterman 你的陈述存在多个问题: 首先,正如这个问题和答案清楚地表明的那样,人们对锁对整体性能的影响了解不足,甚至有人声称50ns的神话只适用于单线程环境。 其次,你的陈述在这里并将会保留多年,在此期间,处理器的核心数量增加,但核心速度并没有增加太多。第三,应用程序随着时间的推移变得越来越复杂,然后在许多核心的环境中,锁定层叠在一起,并且数量正在上升,2、4、8、10、20、16、32。 - ipavlu
我的常规方法是以尽可能少的交互方式松散地构建同步。这很快就会转向无锁数据结构。我为我的代码制作了围绕自旋锁的包装器,以简化开发,即使TPL有特殊的并发集合,我也开发了自己的自旋锁集合,围绕列表、数组、字典和队列,因为我需要更多的控制,并且有时需要在自旋锁下运行一些代码。我可以告诉你,这是可能的,并且允许解决多个TPL集合无法处理的场景,并获得极高的性能/吞吐量增益。 - ipavlu

7

相比于没有锁的替代方案,一个在紧密循环中使用锁的成本是巨大的。你可以循环多次,仍然比使用锁更高效。这就是为什么无锁队列如此高效的原因。

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace LockPerformanceConsoleApplication
{
    class Program
    {
        static void Main(string[] args)
        {
            var stopwatch = new Stopwatch();
            const int LoopCount = (int) (100 * 1e6);
            int counter = 0;

            for (int repetition = 0; repetition < 5; repetition++)
            {
                stopwatch.Reset();
                stopwatch.Start();
                for (int i = 0; i < LoopCount; i++)
                    lock (stopwatch)
                        counter = i;
                stopwatch.Stop();
                Console.WriteLine("With lock: {0}", stopwatch.ElapsedMilliseconds);

                stopwatch.Reset();
                stopwatch.Start();
                for (int i = 0; i < LoopCount; i++)
                    counter = i;
                stopwatch.Stop();
                Console.WriteLine("Without lock: {0}", stopwatch.ElapsedMilliseconds);
            }

            Console.ReadKey();
        }
    }
}

输出:

With lock: 2013
Without lock: 211
With lock: 2002
Without lock: 210
With lock: 1989
Without lock: 210
With lock: 1987
Without lock: 207
With lock: 1988
Without lock: 208

5
这可能不是一个好的示例,因为你的循环除了一个单变量赋值之外,并没有做任何事情,而且一个锁至少需要 2 个函数调用。此外,你得到的每个锁 20 纳秒并不差。 - Zar Shardan

6
有几种不同的方式来定义“成本”。有实际的开销,包括获取和释放锁;正如Jake所写,除非执行此操作数百万次,否则可以忽略不计。
更重要的是,这对执行流程的影响。这段代码只能由一个线程进入。如果您有5个线程定期执行此操作,其中4个将等待锁被释放,然后成为在该锁被释放后进入该代码片段的第一个线程。因此,您的算法将受到显着影响。如何受影响取决于算法以及调用操作的频率。您无法真正避免它而不引入竞态条件,但可以通过最小化对已锁定代码的调用次数来缓解它。

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