易失性vs.联锁vs.锁定

假设一个类有一个public int counter字段,可以被多个线程访问。这个int只是递增或递减。

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

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

现在我已经发现了volatile,我已经删除了许多lock语句和Interlocked的使用。但是有理由不这样做吗?

161384 次浏览

互锁函数不锁定。它们是原子的,这意味着它们可以在增量期间不可能上下文切换的情况下完成。所以没有死锁或等待的机会。

我想说你应该总是喜欢它而不是锁和增量。

如果您需要在一个线程中写入以在另一个线程中读取,并且如果您希望优化器不对变量的操作重新排序(因为在另一个线程中发生的事情是优化器不知道的),那么Volatile很有用。

这是一篇非常好的文章,如果你想阅读更多关于无锁代码,以及编写它的正确方法

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

编辑:正如评论中提到的,这些天我很高兴在单一变量的情况下使用Interlocked,其中显然可以。当它变得更复杂时,我仍然会恢复到锁定…

当你需要增量时,使用volatile没有帮助-因为读取和写入是单独的指令。另一个线程可以在你读取后但在你写回之前更改值。

就我个人而言,我几乎总是锁定——以显然的方式做对比波动或互锁更容易。增量。就我而言,无锁多线程是为真正的线程专家准备的,我不是其中之一。如果乔·达菲和他的团队构建了很好的库,这些库将并行化事物,而不会像我构建的东西那样锁定,那就太棒了,我会立刻使用它——但是当我自己做线程时,我会尽量保持简单。

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

互锁。*是正确的方法……开销要少得多,因为现代CPU将其作为基元支持。

易失性本身是不正确的。一个线程尝试检索然后写回修改后的值,仍然可能与另一个执行相同操作的线程发生冲突。

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

递增一个变量实际上需要三个操作:

  1. 阅读
  2. 增量

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

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

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重新排序,或者您是否还需要将易失性与增量结合起来。

联锁注释:

  1. 互锁方法在任何数量的核心或CPU上都是安全的。
  2. 互锁方法在它们执行的指令周围应用完整的围栏,因此不会发生重新排序。
  3. 互锁方法不需要或甚至不支持访问易失性字段,因为易失性在给定字段的操作周围放置了一个半栅栏,互锁使用的是完整的栅栏。

脚注:挥发性实际上是好的。

由于volatile不能防止这些类型的多线程问题,它的用途是什么?一个很好的例子是说你有两个线程,一个总是写入一个变量(比如queueLength),另一个总是从同一个变量读取。

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

一个解决方案是锁定,但在这种情况下你也可以使用易失性。这将确保线程B将始终看到线程A编写的最新内容。然而请注意,如果你有从不读取的编写器和从不写入的读取器,这个逻辑只有有效,如果你正在写的东西是原子值,。一旦你做了一次读-修改-写,你需要去联锁操作或使用锁。

我做了一些测试来看看这个理论是如何实际工作的:kennethxu.blogspot.com/2009/05/interlocked-vs-monitor-performance.html。我的测试更侧重于CompareExchnage,但增量的结果相似。在多CPU环境中,互锁不是更快的必要。这是增量在一台2年前的16 CPU服务器上的测试结果。请记住,测试还涉及增加后的安全读取,这在现实世界中很典型。

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

锁定或互锁增量是您正在寻找的。

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

e. g.

while (m_Var)
{ }

如果m_Var在另一个线程中设置为false,但没有声明为易失性,编译器可以自由地使其成为无限循环(但并不意味着总是如此),方法是检查CPU寄存器(例如EAX,因为m_Var从一开始就是这样获取的),而不是对m_Var的内存位置发出另一次读取(这可能是缓存的——我们不知道也不关心,这就是x86/x64的缓存一致性的重点)。事实上,再次感谢MESI协议,我们保证读取的结果在CPU之间始终相同,无论实际结果是退回到物理内存还是仅仅驻留在本地CPU的缓存中。我不会太详细,但请放心,如果出现问题,Intel/AMD可能会发布处理器召回!这也意味着我们不必关心无序执行等问题。结果总是保证按顺序退回-否则我们就被塞满了!

有了互锁增量,处理器需要出去,从给定的地址中获取值,然后增量并写回所有这些,同时拥有整个缓存行的独占所有权(lock xadd),以确保没有其他处理器可以修改它的值。

如果使用了易失性,您最终仍然只有1条指令(假设JIT是有效的)——incdword ptr[m_Var]。然而,处理器(cpuA)在使用联锁版本时并不要求对缓存行的独占所有权。正如您所能想象的,这意味着其他处理器可以在cpuA读取更新值后将其写回m_Var。因此,现在不是将值增加两次,而是只增加一次。

希望这能澄清这个问题。

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

附注:是什么促使这个很晚的回复?所有的回复都明显不正确(尤其是标记为答案的那个),在他们的解释中,我只需要为其他阅读这篇文章的人澄清一下。耸耸肩

备注:我假设目标是x86/x64而不是IA64(它有不同的内存模型)。请注意,微软的ECMA规范被搞砸了,因为它指定了最弱的内存模型,而不是最强的内存模型(指定最强的内存模型总是更好,这样它在跨平台上是一致的——否则在x86/x64上24-7运行的代码可能根本无法在IA64上运行,尽管英特尔为IA64实现了类似的强内存模型)——微软自己承认了这一点——http://blogs.msdn.com/b/cbrumme/archive/2003/05/17/51445.aspx

我想补充一下其他答案中提到的volatileInterlockedlock之间的区别:

易失性关键字可以应用于这些类型的字段

  • 引用类型。
  • 指针类型(在不安全的上下文中)。请注意,尽管指针本身可以是易失性的,但它指向的对象不能。在其他 字,您不能将“指针”声明为“易失性”。
  • 简单类型,如sbytebyteshortushortintuintcharfloatbool
  • 具有以下基本类型之一的枚举类型:bytesbyteshort、ushort、intuint
  • 已知为引用类型的泛型类型参数。
  • IntPtrUIntPtr

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

我只是在这里指出猎户座爱德华兹的答案中关于波动的错误。

他说:

"如果它是易失性的,这只会确保两个CPU在同一时间看到相同的数据 “同一时间”

这是错误的。在微软关于易失性的文档中,提到:

"在多处理器系统上,易失性读取操作不会 保证获得写入该内存位置的最新值 由任何处理器。类似地,易失性写入操作不会 保证写入的值对其他人立即可见 处理器。”