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

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




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

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

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

评论

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

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

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

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

@yoyo不,您不需要两者。

#1 楼

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

counter的访问修饰符更改为public 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重新排序,或者是否还需要合并
InterlockedNotes:

Interlocked方法在任何数量的CORE或CPU上都是安全的。
Interlocked方法在它们执行的指令周围加上了完整的围栏,因此重新排序确实可以不会发生。
互锁的方法不需要甚至不支持访问volatile字段,因为volatile在给定字段的操作周围放置了半个栅栏,而互锁使用的是整个栅栏。

脚注:实际上,什么是volatile
由于volatile不能防止此类多线程问题,它的用途是什么?一个很好的例子是,您有两个线程,一个总是写一个变量(例如queueLength),另一个总是从相同的变量读取。
如果queueLength不是volatile,线程A可以写五次,但是线程B可能会认为这些写入被延迟(甚至可能以错误的顺序)。
解决方案是锁定,但在这种情况下也可以使用volatile。这样可以确保线程B始终可以看到线程A编写的最新内容。但是请注意,只有当您有从未读过的作家和从未写过的读者,并且您要写的东西是原子值时,此逻辑才起作用。一旦完成一次读-修改-写操作,就需要进入互锁操作或使用锁。

评论


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

– Hosam Aly
09年1月17日在13:07

多谢!您一直在寻找“挥发性物质实际上有什么用”的脚注,它确定了我想使用挥发性物质的方式。

–雅克·博世(Jacques Bosch)
2010年5月10日下午6:22

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

–扎克锯
2011年6月23日7:41

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

– Orion Edwards
2011年7月8日,下午3:39

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

–埃里克·利珀特
2013年12月3日17:22

#2 楼

编辑:正如评论中指出的那样,这些天来,我很高兴在显然可以的单个变量的情况下使用Interlocked。当它变得更复杂时,我仍将恢复锁定状态。

当需要递增时,使用volatile将无济于事-因为读和写是单独的指令。在您读完之后但在写回之前,另一个线程可能会更改值。

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

评论


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

– Xaqron
2011年1月3日,下午1:51

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

–扎克锯
2011年7月7日,在1:25

互锁没有任何问题,这正是您要寻找的东西,而且比完整的lock()更快

– Jaap
2012年3月22日在20:24

@Jaap:是的,这些天我会使用联锁来获得真正的单个计数器。我只是不想开始弄乱试图找出变量的多个无锁更新之间的交互。

–乔恩·斯基特(Jon Skeet)
2012年3月22日20:30

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

–超级猫
2012年8月21日15:01



#3 楼

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

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


read
increment
write

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

评论


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

–JoeGeeky
2011-12-4 19:58

实际上,volatile不能确保不缓存该变量。它只是对如何缓存进行了限制。例如,它仍然可以缓存在CPU的L2缓存中,因为它们在硬件上是一致的。它仍然可以被完善。仍然可以将写入内容发布到缓存中,依此类推。 (我认为这正是Zach的目标。)

– David Schwartz
2015年12月4日12:36



#4 楼

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

绝对不是您想要的-可变变量只是告诉编译器将变量视为始终更改,即使当前代码路径允许
如果不是在另一个线程中将m_Var设置为false,但未将其声明为volatile,则编译器将优化从内存中的读取。

例如

while (m_Var)
{ }


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

使用互锁增量,处理器需要熄灭,从给定的地址中获取值,然后递增并写回-所有这些都具有对整个缓存行的独占所有权(锁定xadd),以确保没有其他处理器可以修改其值。

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

希望这可以解决问题。

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

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

p.p.s.我假设目标是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年,用一些激进的语言发表文章将需要更多切实的证据。

–史蒂文·埃弗斯(Steven Evers)
2011年7月7日,下午3:28

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

–扎克锯
2011年7月7日在4:19

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

–扎克锯
2011年7月7日4:29



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

–user1416420
2012年12月14日上午8:03

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

–扎克锯
13年2月10日在22:39

#5 楼

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

我要说,您应该始终将它比锁定并递增。

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

如果您想了解有关无锁代码的更多信息,以及编写无锁代码的正确方法,那么这是一篇非常不错的文章。 > http://www.ddj.com/hpc-high-performance-computing/210604448

#6 楼

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

Interlocked。*是正确的方法。 ..少得多的开销,因为现代CPU支持将此作为原语。

本身的volatile不正确。尝试检索然后写回修改后值的线程仍可能与另一个执行此操作的线程冲突。

#7 楼

我进行了一些测试以了解该理论的实际作用: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


评论


但是,您测试的代码示例实在是太琐碎了-用这种方式测试确实没有多大意义!最好的办法是了解不同方法的实际作用,并根据您的使用情况使用适当的方法。

–扎克锯
2011年6月23日15:10

@Zach,这里的讨论是关于以线程安全的方式增加计数器的方案。您还在想其他什么使用场景,或者将如何测试?感谢您的评论顺便说一句。

–徐K
2011年6月27日21:05

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

–扎克锯
2011年7月7日在1:18

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

–徐K
13年10月6日在16:01

#8 楼

我第二次回答乔恩·斯基特(Jon Skeet)的问题,并希望为每个想更多地了解“易失性”和“互锁”的人添加以下链接:

原子性,波动性和不变性是不同的,第一部分-(Eric Lippert的精彩冒险在编码中)

原子性,挥发性和不变性不同,第二部分

原子性,挥发性和不变性不同,第三部分

Sayonara挥发性- (Joe Duffy的Weblog的Wayback Machine快照于2012年出现)

#9 楼

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

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


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

IntPtrUIntPtr

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