易失性 vs. 互锁 vs. 锁

780

假设一个类有一个被多个线程访问的 public int counter 字段。这个 int 只会被增加或减少。

为了增加这个字段,应该使用哪种方法,为什么?

  • lock(this.locker) this.counter++;,
  • Interlocked.Increment(ref this.counter);,
  • counter 的访问修饰符改为 public volatile

现在我已经发现了 volatile,我一直在删除许多 lock 语句和使用 Interlocked 的地方。但是不这样做是否有原因呢?


阅读C#中的线程参考。它涵盖了你的问题的方方面面。这三个线程各有不同的目的和副作用。 - spoulson
1
您可以看到在数组中使用volatile,我并不完全理解它,但这是对它的另一个引用。 - eran otzap
63
这就像说“我发现喷淋系统从未启动,因此我要将其替换为烟雾报警器”。不建议这样做的原因是非常危险几乎没有好处。如果你有时间去改变代码,那么找到一种减少多线程的方法!不要找一种让多线程代码更加危险和容易出错的方法! - Eric Lippert
1
我的房子既有洒水器又有烟雾报警器。当在一个线程上递增计数器并在另一个线程上读取它时,似乎需要使用锁(或Interlocked)和volatile关键字。是这样吗? - yoyo
2
@yoyo 不需要两个。 - David Schwartz
这是我尝试解释C#中volatile、lock和Interlocked三者之间区别的文章:https://www.harshmaurya.in/volatile-vs-lock-vs-interlocked-in-c-net/ - Samarsh
10个回答

991

最糟糕的方法(实际上不起作用)

counter的访问修饰符更改为public volatile

如其他人所提到的,仅使用这种方法并不安全。 volatile的意义在于多个CPU上运行的多个线程可能会缓存数据并重新排序指令。

如果它不是volatile,那么CPU A增加一个值,那么CPU B可能要过一段时间才能看到增加后的值,这可能会引起问题。

如果它是volatile,则只需确保两个CPU同时看到相同的数据。它根本无法阻止它们交错地读取和写入操作,这就是您需要避免的问题。

次佳方法:

lock(this.locker) this.counter++;

只要您记住在访问this.counter的任何其他位置上都要lock,这样做是安全的。 它可以防止任何其他线程执行由locker保护的任何其他代码。 使用锁还可以防止上述多CPU重排问题,这非常好。

问题在于,锁定很慢,如果您在与其不相关的其他位置上重新使用locker,则可能会无缘无故地阻塞其他线程。

最佳方法

Interlocked.Increment(ref this.counter);

这是安全的,因为它实际上在“一次命中”中执行读取、增加和写入操作,不能被中断。由于这个原因,它不会影响任何其他代码,您也不需要记住在其他地方锁定。它也非常快(如MSDN所说,在现代CPU上,这通常只是一个CPU指令)。

然而,我并不确定它是否可以避免其他CPU重新排序,或者是否还需要将volatile与增量结合使用。

Interlocked注意事项:

  1. 交错方法在任何数量的核心或CPU上都是并发安全的。
  2. 交错方法在执行它们的指令周围应用了一个完整的屏障,因此不会进行重新排序。
  3. 交错方法不需要甚至不支持对易失字段的访问,因为易失被放置在给定字段的半屏障周围,而交错使用完整的屏障。

注释:易失实际上有什么作用。

既然volatile不能解决这些类型的多线程问题,那么它有什么用呢?一个很好的例子是你有两个线程,一个总是写入一个变量(比如queueLength),一个总是从同一变量中读取。

如果queueLength不是易失的,线程A可能会写入五次,但线程B可能会看到这些写入被延迟(甚至可能在错误的顺序中)。

一个解决方案是加锁,但你也可以在这种情况下使用易失。这将确保线程B始终能看到线程A写入的最新内容。然而需要注意的是,仅当你具有从未读取的写入者和从未写入的读取者以及正在写入的内容是原子值时,才能使用此逻辑。一旦进行单个读取修改写入操作,就需要使用交错操作或锁定。


31
“我并不完全确定...如果您还需要将volatile与增量结合使用。” 据我所知,它们不能结合在一起,因为我们无法通过引用传递一个volatile。顺便说一句,您的回答非常好。 - Hosam Aly
50
非常感谢!您在“volatile的实际用途”一节中的注释正是我想要的,它确认了我想要使用volatile的方式。 - Jacques Bosch
38
在C++的内存模型中,volatile是你所描述的那样(基本上只有用于设备映射内存等少数情况)。在CLR (这个问题标记了C#)的内存模型中,volatile会在对该存储位置进行读写时插入内存屏障。内存屏障(以及某些汇编指令的特殊锁定变体)是让处理器不要重新排序事物的方式,它们非常重要... - Orion Edwards
22
在C#中,一个“volatile”字段阻止了C#编译器和JIT编译器做一些将值缓存的优化,并保证多个线程读写的顺序。作为实现细节,它可能通过在读写时引入内存屏障来实现这一点。具体的语义保证在规范中描述;请注意,规范并不保证所有volatile写入和读取的一致排序会被所有线程观察到。 - Eric Lippert
7
@ZachSaw - 我刚刚重新阅读了很久以前的评论,意识到我之前的评论是错误的 -“根据CLR的内存模型,volatile将在对该存储位置进行读写时插入内存屏障”-由于ARM的较弱内存模型,CLR将为某些volatile操作在ARM上插入内存屏障-但你是正确的,它不会在x86上这样做,因为它没有必要。对此我深表歉意。 - Orion Edwards
显示剩余27条评论

164

编辑:如评论中所述,现在我很高兴在仅涉及单个变量并且显然可行的情况下使用Interlocked。当它变得更加复杂时,我仍然会回归锁定...

在需要增加操作时,使用volatile无效-因为读取和写入是分开的指令。另一个线程可以在你读取但在你写回之前更改该值。

就个人而言,我几乎总是使用锁定-以一种明显正确的方式比使用易失性或Interlocked.Increment更容易掌握。我认为,无锁多线程编程是真正的线程专家才能做到的,而我不属于这个范畴。如果Joe Duffy和他的团队构建出不需要像我构建的那样多锁定的优秀库,那太棒了,我会毫不犹豫地使用它-但是当我自己进行线程处理时,我尽量保持简单。


21
感谢你确保我从现在开始忘记无锁编程,给你点个赞。 - Xaqron
7
无锁代码并不是真正的无锁,因为它们在某个阶段会进行锁定——无论是在(FSB)总线或跨CPU级别,仍然会有一些代价需要支付。然而,在这些较低级别上进行锁定通常更快,只要你不饱和锁定发生的带宽即可。 - Zach Saw
5
Interlocked没有任何问题,它正是你要找的,并且比使用完整的lock()锁更快。 - Jaap
5
@Jaap:是的,现在如果我要使用真正的单个计数器,我会使用interlocked。但是我不想开始尝试计算多个无锁变量更新之间的交互作用。 - Jon Skeet
7
@ZachSaw:你的第二条评论说,交错操作在某个阶段会“锁定”;术语“锁定”通常意味着一个任务可以在不受限时间内维护对资源的独占控制权;无锁编程的主要优势在于它避免了由于拥有资源的任务被拦截而导致资源变得无法使用的危险。Interlocked类使用的总线同步不仅“通常更快”,在大多数系统上,其最坏情况时间是有界的,而锁则没有这个保证。 - supercat
显示剩余6条评论

48

"volatile"并不能替代Interlocked.Increment!它只是确保变量不会被缓存,而是直接使用。

增加一个变量需要实际上执行三个操作:

  1. 读取
  2. 增加
  3. 写入

Interlocked.Increment将所有三个部分作为单个原子操作执行。


5
换句话说,互锁更改是全栅栏的,因此是原子性的。而易失性成员只是部分栅栏的,因此不能保证是线程安全的。 - JoeGeeky
2
实际上,“volatile”并不能确保变量不被缓存。它只是对其缓存方式施加了限制。例如,它仍然可以被缓存在CPU的L2缓存中,因为它们在硬件上是一致的。它仍然可以被预取。写入仍然可以被发布到缓存中等等。(我想这就是Zach所说的内容)。 - David Schwartz

45

你需要的是锁定或互锁增量。

volatile 绝对不是你想要的 - 它只是告诉编译器即使当前代码路径允许编译器优化从内存读取,也要将变量视为始终在改变。

例如:

while (m_Var)
{ }

如果在另一个线程中将m_Var设置为false但未声明为易失性,则编译器可以通过使其针对CPU寄存器(例如EAX,因为这是从一开始就从m_Var获取到的)进行检查而不是发出对m_Var内存位置的另一个读取来使其成为无限循环(但并不意味着它总是会这样做)。 (这可能被缓存-我们不知道也不关心,这就是x86 / x64的高速缓存一致性的要点)。之前其他人提到的指令重排序的所有帖子都只是表明他们不理解x86 / x64体系结构。易失性并不像之前的帖子暗示的那样发出读/写屏障,以防止重排序。实际上,由于MESI协议的存在,我们保证了我们读取的结果始终相同,无论实际结果是否已退役到物理内存或仅驻留在本地CPU的高速缓存中。我不会过多介绍此事,但请放心,如果出现问题,英特尔/ AMD可能会发布处理器召回通知!这也意味着我们不必关心乱序执行等问题。结果始终保证按顺序退休-否则我们就完了!
使用Interlocked Increment时,处理器需要出去,从给定地址获取值,然后递增并将其写回-所有这些都是在独占整个缓存行的所有权(锁定xadd)的情况下进行的,以确保没有其他处理器可以修改其值。
使用volatile关键字,你仍然最终只有1条指令(假设JIT编译器是高效的)- inc dword ptr [m_Var]。但是,处理器(cpuA)在执行所有使用interlocked版本时并没有请求对缓存行的独占所有权。正如你所想象的那样,这意味着其他处理器在cpuA读取m_Var后可能会将更新后的值写回。因此,你最终只增加了一次而不是两次。

希望这能解决问题。

更多信息,请参见“了解低锁定技术在多线程应用程序中的影响” - http://msdn.microsoft.com/en-au/magazine/cc163715.aspx

p.s. 什么促使我这么晚才回复?所有回复都在解释上明显不正确(特别是标记为答案的回复),我必须为任何阅读此文的人澄清一下。 耸肩

p.p.s. 我假设目标是 x86/x64 而不是 IA64(它有不同的内存模型)。请注意,微软的 ECMA 规范存在问题,它指定了最弱的内存模型而不是最强的一个(最好针对最强的内存模型进行规定,以便在各个平台上保持一致。否则,在 x86/x64 上运行24/7的代码可能在 IA64 上根本无法运行,尽管英特尔已为 IA64 实现了类似强大的内存模型)- 微软自己也承认了这一点 - http://blogs.msdn.com/b/cbrumme/archive/2003/05/17/51445.aspx


3
有趣。你能提供参考资料吗?我很乐意支持这个观点,但在已经有一篇高票答案并且与我所读的资源一致的情况下,要在三年后发布带有些许攻击性语言的帖子,需要更具体的证据支持。 - Steven Evers
2
为什么有人想要阻止 CPU 缓存超出了我的理解范围。如果是这样的话,专门用于执行缓存一致性的整个空间(绝对不是小的,成本也不低)完全浪费了...除非你不需要缓存一致性,例如显卡、PCI 设备等,否则就不会将缓存行设置为写穿方式。 - Zach Saw
4
是的,你说的话都是百分之百准确的,至少要有99%。这个网站在工作开发中非常有用,但不幸的是,回答与(投票)得分相对应的准确性并不高。因此,在stackoverflow上,你只能感受到读者的普遍理解,而不是真正的答案。有时,排名靠前的答案只是无意义的胡言乱语 - 某种神话。不幸的是,这就是那些在解决问题时遇到这篇文章的人所接收到的信息。尽管如此,这也是可以理解的,毕竟没有人能知道全部。 - user1416420
1
@BenVoigt 我可以继续回答 .NET 运行的所有架构,但那需要几页纸,而且绝对不适合在 SO 上。基于最广泛使用的 .NET 底层硬件内存模型来教育人们要好得多,而不是任意选择一个。我的评论“到处都是”,是为了纠正人们在假设刷新/使缓存无效等方面犯的错误。他们对底层硬件做出了假设,却没有指定具体是哪种硬件。 - Zach Saw
1
@Zach:即使是ECMA模型也会给易失性访问提供获取和/或释放语义(不是完整的屏障,但这些也是屏障/栅栏的类型),不是吗? - Ben Voigt
显示剩余9条评论

18

Interlocked函数不会锁定。它们是原子操作,这意味着它们可以在增量期间完成而不可能发生上下文切换。因此不存在死锁或等待的可能性。

我认为你应该始终优先选择使用它来进行锁定和增量操作。

如果您需要在一个线程中进行写入并在另一个线程中进行读取,并且希望优化器不重新排列变量上的操作(因为在另一个线程中发生了一些优化器不知道的事情),那么Volatile就非常有用了。这是与如何增量选择无关的选择。

如果您想更多地了解无锁代码以及编写它的正确方法,那么这真的是一篇很好的文章。

http://www.ddj.com/hpc-high-performance-computing/210604448


13

lock(...)可以起作用,但可能会阻塞线程,并且如果其他代码以不兼容的方式使用相同的锁,可能会导致死锁。

Interlocked.*是正确的方法...现代CPU支持这种原语,因此开销要小得多。

仅使用volatile是不正确的。一个线程试图检索并写回修改后的值仍然可能与另一个执行相同操作的线程发生冲突。


10

我进行了一些测试,以查看理论如何实际运作:kennethxu.blogspot.com / 2009/05 / interlocked-vs-monitor-performance.html 。 我的测试更专注于CompareExchnage,但Increment的结果类似。在多CPU环境中,Interlocked不一定更快。这是在一个两年前的16 CPU服务器上进行Increment测试的结果。请注意,测试还涉及增加后的安全读取,在真实世界中是典型的操作。

D:\>InterlockVsMonitor.exe 16
Using 16 threads:
          InterlockAtomic.RunIncrement         (ns):   8355 Average,   8302 Minimal,   8409 Maxmial
    MonitorVolatileAtomic.RunIncrement         (ns):   7077 Average,   6843 Minimal,   7243 Maxmial

D:\>InterlockVsMonitor.exe 4
Using 4 threads:
          InterlockAtomic.RunIncrement         (ns):   4319 Average,   4319 Minimal,   4321 Maxmial
    MonitorVolatileAtomic.RunIncrement         (ns):    933 Average,    802 Minimal,   1018 Maxmial

你测试的代码示例太过简单了,这样测试并没有多大意义!最好的方法是理解不同的方法实际上在做什么,并根据你所拥有的使用场景选择适当的方法。 - Zach Saw
@Zach,这里讨论的是以线程安全的方式增加计数器的情况。你还有其他使用场景或如何测试它的想法吗?顺便感谢你的评论。 - Kenneth Xu
重点是,这只是一个人工测试。在任何真实的情况下,你不会经常锤击同一位置。如果你确实这样做了,那么你的FSB(如你的服务器盒子所示)就成为了瓶颈。无论如何,请查看我在你的博客上的回复。 - Zach Saw
3
回过头来看。如果真正的瓶颈在于 FSB,那么监视器实现应该观察到同样的瓶颈。真正的区别在于 Interlocked 正在执行繁忙等待和重试,这在高性能计数中成为一个真正的问题。至少我希望我的评论引起了注意,即 Interlocked 并不总是计数的正确选择。人们正在寻找替代方案这一事实可以很好地解释它。你需要一个长加法器 http://gee.cs.oswego.edu/dl/jsr166/dist/jsr166edocs/jsr166e/LongAdder.html - Kenneth Xu


4
我希望在其他答案中提到的内容上添加一些补充,即volatileInterlockedlock之间的区别:

可以将volatile关键字应用于这些类型的字段

  • 引用类型。
  • 指针类型(在不安全的上下文中)。请注意,尽管指针本身可以是易失性的,但它所指向的对象不能是易失性的。换句话说,您不能将“指针”声明为“易失性的”。
  • 简单类型,例如sbytebyteshortushortintuintcharfloatbool
  • 枚举类型,其基类型为bytesbyteshortushortintuint之一。
  • 已知为引用类型的通用类型参数。
  • IntPtrUIntPtr

其他类型,包括doublelong,不能被标记为"volatile",因为对于这些类型的字段的读写不能保证是原子性的。为了保护对这些类型字段的多线程访问,使用Interlocked类成员或使用lock语句来保护访问。


2

我在这里只是想指出Orion Edwards答案中关于volatile的错误。

他说:

"如果它是volatile,这只是确保两个CPU同时看到相同的数据。"

这是错误的。在微软有关volatile的文档中提到:

"在多处理器系统上,volatile读操作不能保证获得任何处理器写入该内存位置的最新值。同样,volatile写操作不能保证所写入的值会立即对其他处理器可见。"


这并没有回答问题。一旦您拥有足够的声望,您将能够评论任何帖子;相反,提供不需要询问者澄清的答案。- 来自审核 - sjakobi

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