易失性,连锁性与锁定


670

假设一个类具有一个public int counter可由多个线程访问的字段。这int仅递增或递减。

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

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

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


阅读C#中线程参考。它涵盖了您问题的来龙去脉。这三个都有不同的用途和副作用。
spoulson

1
simple-talk.com/blogs/2012/01/24/… 您可以看到在数组中使用volitable,但我并没有完全理解它,但这是该功能的另一参考。
eran otzap

50
这就像说“我发现喷水灭火系统从未启动过,因此我将其删除并用烟雾报警器代替”。不这样做的原因是因为它非常危险几乎没有收益。如果您有时间花在更改代码上,那么找到一种减少多线程的方法!找不到一种使多线程代码更危险,更容易破坏的方法!
埃里克·利珀特

1
我的房子既有洒水装置又有烟雾报警器。当在一个线程上增加计数器并在另一个线程上读取计数器时,似乎您既需要锁(或互锁)需要volatile关键字。真相?
yoyo 2014年

2
@yoyo不,您不需要两者。
David Schwartz

Answers:


858

最差(实际上不会工作)

将的访问修饰符更改counterpublic volatile

正如其他人所提到的那样,仅此一点实际上是不安全的。关键volatile是,在多个CPU上运行的多个线程可以并且将缓存数据并重新排序指令。

如果不是 volatile,并且CPU A递增一个值,则CPU B可能直到一段时间后才能真正看到该递增的值,这可能会引起问题。

如果为volatile,则仅确保两个CPU同时看到相同的数据。它根本不会阻止他们交错读取和写入操作,而这正是您要避免的问题。

次好的:

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

这是安全的操作(只要您记得lock访问的其他地方this.counter)。它可以防止任何其他线程执行由保护的其他任何代码locker。同样,使用锁可以防止上述多CPU重新排序问题,这非常好。

问题是,锁定速度很慢,如果您locker在与实际无关的其他地方重复使用,则最终可能会无缘无故地阻塞其他线程。

最好

Interlocked.Increment(ref this.counter);

这是安全的,因为它可以有效地读取,递增和写入不会中断的“一次命中”。因此,它不会影响任何其他代码,并且您也不需要记住锁定其他任何位置。它也非常快(正如MSDN所说,在现代CPU上,这实际上是一条CPU指令)。

但是,我不确定是否会绕过其他CPU重新排序,或者是否还需要将volatile与增量结合起来。

连锁注意事项:

  1. 互锁方法可同时在任意数量的内核或CPU上使用。
  2. 互锁的方法在执行的指令周围加上了完整的围栏,因此不会发生重新排序。
  3. 互锁方法不需要甚至不支持访问volatile字段,因为volatile在给定字段上的操作周围放置了半围墙,而联锁使用的是全围墙。

脚注:挥发物实际上是有益的。

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

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

解决方案是锁定,但在这种情况下也可以使用volatile。这样可以确保线程B始终可以看到线程A编写的最新内容。但是请注意,只有当您有从未读过的作家和从未写过的读者,并且您要写的东西是原子值时,此逻辑起作用。一旦完成一次读-修改-写操作,就需要进入互锁操作或使用锁定。


29
“我不确定...是否还需要将volatile与增量结合起来。” 它们不能与AFAIK结合使用,因为我们不能通过ref传递volatile。好的答案。
Hosam Aly

41
多谢!您一直在寻找“挥发性成分实际上有什么用”的脚注,这是我一直在寻找的内容,并确认了我想如何使用挥发性物质。
雅克·博世

7
换句话说,如果将var声明为volatile,则编译器将假定每次您的代码遇到var时,其值都不会保持相同(即volatile)。因此,在一个循环中,例如:while(m_Var){},并且在另一个线程中将m_Var设置为false,编译器不会简单地检查先前已加载m_Var值的寄存器中已经存在的内容,而是从m_Var中读取该值再次。但是,这并不意味着不声明volatile将导致循环无限继续-指定volatile仅保证在另一个线程中将m_Var设置为false时不会。
Zach Saw

35
@Zach Saw:在C ++的内存模型下,volatile是您描述的方式(基本上对设备映射的内存很有用,而其他方面不多)。在CLR(此问题标记为C#)的内存模型下,volatile将在对该存储位置的读写周围插入内存屏障。内存障碍(以及某些汇编指令的特殊锁定变形)是您告诉处理器不要重新排序,它们非常重要……
Orion Edwards

19
@ZachSaw:C#中的一个volatile字段阻止C#编译器和jit编译器进行某些优化来缓存该值。它还可以保证可以观察到在多个线程上的读和写顺序。作为一个实现细节,它可以通过在读取和写入时引入存储屏障来实现。规范中描述了所保证的精确语义;请注意,该规范不能保证所有线程都将观察到所有易失性写入和读取的一致顺序。
埃里克·利珀特

147

编辑:正如在评论中指出,这几天我很高兴地使用Interlocked了的情况下,单变量的地方是明显没关系。当变得更加复杂时,我仍然会恢复锁定状态。

volatile当您需要递增时,使用无助-因为读和写是分开的指令。读完之后但写回之前,另一个线程可能会更改该值。

就我个人而言,我几乎总是只是锁定-以明显正确的方式比波动或Interlocked.Increment 更容易正确。就我而言,无锁多线程是针对真正的线程专家的,我不是其中之一。如果Joe Duffy和他的团队构建了不错的库,这些库可以并行化事物而又没有我要构建的东西那么多,那真是太好了,我将在心跳中使用它-但是当我自己进行线程化时,我会尝试把事情简单化。


16
+1是为了确保我从现在开始忘记无锁编码。
Xaqron 2011年

5
由于无锁代码在某个阶段处于锁定状态,因此绝对不是真正无锁的-无论是在(FSB)总线还是在CPU间级别,您仍然要付出一定的代价。但是,只要您不使发生锁定的位置的带宽饱和,锁定在这些较低级别的速度通常会更快。
Zach Saw

2
没有什么错互锁,正是你寻找和快于全锁()
夏侯

5
@Jaap:是的,这些天我使用联锁来制造真正的单个柜台。我只是不想开始弄乱试图找出变量的多个无锁更新之间的交互。
乔恩·斯基特

6
@ZachSaw:您的第二条评论说,互锁的操作在某个阶段“锁定”。术语“锁定”通常表示一个任务可以无限制地维持对资源的排他控制;无锁编程的主要优势在于,它避免了由于拥有任务被搁置而导致资源变得无法使用的危险。互锁类使用的总线同步不仅“通常更快”-在大多数系统上,它有一定的最坏情况时间,而锁没有。
2012年

44

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

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

  1. 增量

Interlocked.Increment 将这三个部分作为一个原子操作执行。


4
换句话说,互锁式更改是完全可行的,因此是原子性的。易失成员只有部分被保护,因此不能保证线程安全。
JoeGeeky 2011年

1
其实,volatile没有不能确保该变量没有被缓存。它只是对如何进行缓存设置了限制。例如,它仍然可以缓存在CPU的L2缓存中,因为它们在硬件上是一致的。它仍然可以被完善。仍然可以将写入内容发布到缓存中,依此类推。(我认为这就是Zach的想法。)
David Schwartz 2015年

42

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

可变性绝对不是您要追求的,它只是告诉编译器将变量视为始终更改,即使当前代码路径允许编译器以其他方式优化对内存的读取。

例如

while (m_Var)
{ }

如果在另一个线程中将m_Var设置为false,但未将其声明为volatile,则编译器可以通过检查CPU寄存器(例如EAX)自由地使其成为无限循环(但并不意味着总是如此)。而不是从一开始就读取m_Var的内容),而不是对m_Var的内存位置发出另一次读取(这可能是已缓存的-我们不知道也不在乎,这就是x86 / x64的缓存一致性的关键)。其他人先前提到指令重新排序的所有文章只是表明他们不了解x86 / x64体系结构。挥发性确实发出读/写障碍,如先前帖子所言,“阻止重新排序”。实际上,再次感谢MESI协议,无论实际结果是退回物理内存还是仅驻留在本地CPU的缓存中,我们都可以确保在CPU中读取的结果始终相同。我不会太详细地介绍这个问题,但是请放心,如果出错,英特尔/ AMD可能会召回处理器!这也意味着我们不必担心乱序执行等问题。始终保证结果可以按顺序退役-否则我们将被塞满!

使用Interlocked Increment,处理器需要退出,从给定的地址中获取值,然后进行递增并写回-所有这些都拥有整个缓存行的独占所有权(锁定xadd),以确保没有其他处理器可以修改它的价值。

使用volatile,您仍然只会得到1条指令(假设JIT达到了应有的效率)-inc dword ptr [m_Var]。但是,处理器(cpuA)在执行互锁版本时不会要求获得缓存行的专有所有权。可以想象,这意味着其他处理器可以在cpuA读取m_Var之后将其写回m_Var。因此,您不必再增加两次该值,而只需结束一次即可。

希望这可以解决问题。

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

ps是什么促使这个很晚的答复?所有的答复在解释中都如此公然不正确(尤其是标记为答案的答复),我只需要为阅读此书的其他人清除它即可。耸耸肩

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


3
有趣。你能参考一下吗?我很乐意对此表示赞同,但是在获得高度投票的答案(与我所阅读的资源一致)后的三年内,以某种激进的语言发表文章将需要更多切实的证据。
史蒂文·埃弗斯

如果您可以指出要引用的部分,我很乐意从某个地方挖掘出一些东西(我非常怀疑我是否已经泄露了任何x86 / x64供应商商业机密,因此这些内容应该可以从Wiki,Intel轻松获得) PRM(程序员参考手册),MSFT博客,MSDN或类似的东西……
Zach Saw

2
为什么有人想防止CPU缓存超出了我的范围。在这种情况下,用于执行缓存一致性的整个房地产(在大小和成本上绝对不能忽略)被完全浪费了……除非您不需要诸如图形卡,PCI设备等缓存一致性,否则就不会设置一条直写的缓存行。
Zach Saw

4
是的,您所说的一切都是100%至少达到99%。当您在忙于开发工作时,此站点(大部分)非常有用,但是不幸的是,没有(投票)对应的答案的准确性。因此,基本上,在stackoverflow中,您可以感觉到读者的普遍理解是什么,而不是真正的理解。有时,最重要的答案只是纯粹的胡言乱语-神话。不幸的是,这就是在解决问题的过程中引起阅读的人们的原因。这是可以理解的,但是没人能知道所有事情。
user1416420

1
@BenVoigt我可以继续回答有关.NET所运行的所有体系结构的问题,但这将花费几页,而且绝对不适合SO。基于最广泛使用的.NET底层硬件内存模型来教育人们比任意一种更好。通过我的评论“到处都是”,我正在纠正人们在假定刷新/使高速缓存无效等方面所犯的错误。他们对底层硬件进行了假设,但未指定使用哪种硬件。
Zach Saw 2013年

16

互锁的功能不会锁定。它们是原子的,这意味着它们可以完成而不会在增量期间进行上下文切换。因此,没有死锁或等待的机会。

我要说的是,您应该始终喜欢锁定和递增。

如果您需要在一个线程中进行写入以在另一个线程中进行读取,并且如果您希望优化程序不对变量进行重新排序(因为在优化程序不知道的另一线程中发生了事情),则Volatile很有用。这是增加方式的正交选择。

如果您想了解有关无锁代码的更多信息以及正确的编写方式,这是一篇非常不错的文章。

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


11

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

Interlocked。*是执行此操作的正确方法...开销要少得多,因为现代CPU支持将此作为原始函数。

单靠挥发是不正确的。试图检索然后写回修改后值的线程仍可能与另一个执行此操作的线程冲突。


8

我做了一些测试以了解该理论的实际工作原理:kennethxu.blogspot.com/2009/05/interlocked-vs-monitor-performance.html。我的测试更侧重于CompareExchnage,但Increment的结果却相似。在多CP​​U环境中,互锁不是必须更快。这是在2年历史的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,这里的讨论是关于以线程安全方式增加计数器的方案。您在想其他什么使用场景,或者将如何测试?感谢您的评论顺便说一句。
肯尼思·许

重点是,这是一个人工测试。在现实世界中,您不会经常遇到相同的位置。如果是这样,那么您就会受到FSB的瓶颈(如服务器框中所示)。无论如何,请在您的博客上查看我的回复。
Zach Saw

2
再回头看。如果真正的瓶颈在于FSB,则监视器实现应遵循相同的瓶颈。真正的区别在于Interlocked正在忙于等待和重试,这成为高性能计数的真正问题。至少我希望我的评论引起人们的注意,即“互锁”并不总是正确的选择。人们正在寻找替代品的事实很好地解释了这一点。您需要一个长加法器gee.cs.oswego.edu/dl/jsr166/dist/jsr166edocs/jsr166e/…–
肯尼思·许

8

3

我想添加到其他的答案之间的区别提到的volatileInterlockedlock

volatile关键字可以应用于以下类型的字段

  • 参考类型。
  • 指针类型(在不安全的上下文中)。请注意,尽管指针本身可以是易失性的,但其指向的对象却不能。换句话说,您不能将“指针”声明为“易失性”。
  • 简单的类型,如sbytebyteshortushortintuintcharfloat,和bool
  • 枚举类型具有以下基本类型之一:bytesbyteshort,USHORT, int,或uint
  • 通用类型参数已知为引用类型。
  • IntPtrUIntPtr

不能将其他类型(包括double和)long标记为“易失性”,因为不能保证对这些类型的字段进行读写操作。要保护对这些类型的字段的多线程访问,请使用Interlocked类成员或使用该lock语句保护访问 。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.