我在Habr上发表了原始文章,其翻译发布在Codingsight博客上。我将继续在多线程会议上创建我的演讲的文本版本。 第一部分可以在
此处或
此处找到,
这里有更多有关启动线程或任务的基本工具集,查看其状态的方法以及PLinq之类的一些小工具。 在本文中,我希望更多地关注多线程环境中可能出现的问题以及解决这些问题的一些方法。
目录内容
关于共享资源
不可能编写一个可以在多个线程中工作的程序,但是同时不会有一个共享资源: 即使它在您的抽象级别上可以解决,但如果跌至该级别下的一个或多个级别,则表明仍然存在一个公共资源。 我将举一些例子:
范例1:担心可能出现的问题,您使线程可以使用不同的文件。 通过文件流。 在您看来,该程序没有单个公共资源。
深入到下面几个级别后,我们了解到只有一个硬盘驱动器,并且其驱动程序或操作系统将必须解决确保对其进行访问的问题。
范例2:阅读
示例 1之后,您决定将文件放置在具有两个物理上不同的铁件和操作系统的两个不同的远程计算机上。 我们通过FTP或NFS保持2个不同的连接。
在下面的几个级别中,我们了解到什么都没有改变,并且网卡驱动程序或运行该程序的计算机的操作系统必须解决竞争性访问的问题。
范例3:试图证明编写多线程程序的可能性使您大失所望,您完全拒绝了文件,并将计算分解为两个不同的对象,每个对象的链接仅对一个流可用。
我将最后一钉钉在这个想法的棺材上:一个运行时和垃圾回收器,线程调度程序,实际上一个RAM和内存,一个处理器仍是共享资源。
因此,我们发现在整个技术堆栈的整个宽度的所有抽象级别上,如果没有单个共享资源就不可能编写多线程程序。 幸运的是,每个抽象级别通常都能部分或完全解决竞争性访问的问题或完全禁止竞争性访问(例如:任何UI框架均禁止使用来自不同线程的元素),因此问题最常出现在共享资源上您的抽象水平。 为了解决它们,引入了同步的概念。
在多线程环境中工作时可能出现的问题
软件中的错误可以分为几类:
- 该程序不会产生结果。 崩溃或冻结。
- 程序返回不正确的结果。
- 该程序产生正确的结果,但不满足一个或另一个非功能性要求。 运行时间过长或消耗太多资源。
在多线程环境中,导致错误1和2的两个主要问题是
死锁和
竞争条件 。
死锁
死锁-死锁。 有许多不同的变化。 最常见的是以下几种:
线程1做某事时,
线程2阻止了资源
B ,稍后
线程1阻止了资源
A并试图锁定资源
B ,不幸的是,这永远不会发生,因为
线程#2仅在锁定资源
A后才释放资源
B。比赛条件
竞赛条件-竞赛条件。 程序执行的计算的行为和结果的情况取决于运行时线程调度程序的工作。
这种情况的令人不快之处在于,您的程序可能只运行一次,甚至不到一百甚至一百万。
问题可能并存,这使情况更加恶化,例如:线程调度程序的某些行为会导致死锁。
除了这两个导致程序中明显错误的问题外,还有一些可能不会导致错误的计算结果的问题,但是要花费更多的时间或处理能力。 其中两个问题是:
忙等待和
线程饥饿 。
忙碌中
忙等待问题是程序消耗处理器资源而不是用于计算,而是用于等待的问题。
代码中的此类问题通常看起来像这样:
while(!hasSomethingHappened) ;
这是一个极差代码的示例,因为 这样的代码完全占据了处理器的一个核心,而没有任何用处。 仅当在另一个线程中处理某些值的更改至关重要时,才有理由。 快速地说,我是在谈论您什至不能等待几纳秒的情况。 在其他情况下,也就是说,在可以产生健康大脑的所有事物中,使用ResetEvent品种及其Slim版本更为合理。 关于他们下面。
也许有一位读者会建议通过向循环添加诸如Thread.Sleep(1)之类的结构来解决无休止地完全加载一个内核的问题。 这确实可以解决问题,但会产生另一个问题:对更改的响应时间平均为半毫秒,虽然可能不多,但比使用ResetEvent系列的同步原语要多得多。
线程饥饿
线程匮乏是程序同时有太多线程在工作的问题。 这到底意味着那些正在忙于计算的流,而不仅仅是等待任何IO的响应。 由于存在此问题,因此失去了使用线程获得的所有可能的性能提升,因为 处理器花费大量时间切换上下文。
使用各种探查器查找此类问题很方便,下面是在时间轴模式下启动的
dotTrace探查器的屏幕截图示例。
(图片可点击)在不受流媒体饥饿困扰的程序中,反映流的图形上不会出现粉红色。 此外,在“子系统”类别中,很明显,有30.6%的程序正在等待CPU。
诊断出这样的问题后,解决起来非常简单:您一次启动了太多线程,一次启动的线程很少或没有全部启动。
同步工具
联锁
这也许是最轻量的同步方式。 互锁是简单原子操作的集合。 原子操作称为什么时候都不会发生的操作。 在.NET中,互锁由具有多个方法的同名静态类表示,每个方法都实现一个原子操作。
要实现非原子操作的恐怖,请尝试编写一个程序以启动10个线程,每个线程使同一变量递增一百万,并在工作结束时打印该变量的值-不幸的是,它与1000万个变量的值将有很大不同每次程序启动时,都会有所不同。 发生这种情况是因为,即使像增量这样的简单操作也不是原子的,而是涉及从内存中提取值,计算新值并写回。 因此,两个线程可以同时执行这些操作中的每一个,在这种情况下,增量将丢失。
Interlocked类提供了Increment / Decrement方法;很容易猜测它们的作用。 如果您要在多个线程中处理数据并考虑某些事项,它们将很方便使用。 这样的代码将比经典锁更快地工作。 如果对上一段所述的情况使用“互锁”,则该程序将在任何情况下稳定地提供1000万。
乍看之下,CompareExchange方法执行的功能相当不明显,但是它的全部存在使您可以实现许多有趣的算法,尤其是无锁家族。
public static int CompareExchange (ref int location1, int value, int comparand);
该方法采用三个值:第一个值通过引用传递,这是将更改为第二个值的值,如果在比较时location1与comparand相匹配,则将返回location1的原始值。 这听起来很混乱,因为编写与CompareExchange进行相同操作的代码更加容易:
var original = location1; if (location1 == comparand) location1 = value; return original;
只有Interlocked类中的实现是原子的。 也就是说,如果我们自己编写这样的代码,则可能已经满足了location1 ==比较条件的情况,但是到执行位置1 = value表达式时,另一个线程已经更改了location1的值,并且它将丢失。
我们可以在编译器为任何C#事件生成的代码中找到一个使用此方法的好例子。
让我们用一个MyEvent事件编写一个简单的类:
class MyClass { public event EventHandler MyEvent; }
让我们在Release配置中构建项目,并使用
dotPeek打开Show Compiler Generated Code选项,打开程序集:
[CompilerGenerated] private EventHandler MyEvent; public event EventHandler MyEvent { [CompilerGenerated] add { EventHandler eventHandler = this.MyEvent; EventHandler comparand; do { comparand = eventHandler; eventHandler = Interlocked.CompareExchange<EventHandler>(ref this.MyEvent, (EventHandler) Delegate.Combine((Delegate) comparand, (Delegate) value), comparand); } while (eventHandler != comparand); } [CompilerGenerated] remove {
在这里您可以看到,在后台,编译器生成了一个相当复杂的算法。 当多个线程同时订阅该事件时,该算法可以防止丢失事件订阅的情况。 让我们更详细地编写add方法,并记住CompareExchange方法在后台执行的操作
EventHandler eventHandler = this.MyEvent; EventHandler comparand; do { comparand = eventHandler;
尽管它可能仍需要解释,但这已经有些清楚了。 换句话说,我将这种算法描述如下:
如果MyEvent与开始运行Delegate.Combine时的状态相同,请在其中写下Delegate.Combine返回的内容,如果没有,则无所谓,让我们再试一次,直到出现为止。
因此,任何事件订阅都不会丢失。 如果您突然想实现动态线程安全的无锁数组,则必须解决类似的问题。 如果有多个流急于向其添加元素,那么最后都必须添加它们很重要。
Monitor.Enter,Monitor.Exit,锁定
这些是线程同步的最常用构造。 它们实现了关键部分的思想:即在对一个资源的Monitor.Enter,Monitor.Exit调用之间编写的代码只能在一个线程中一次执行。 lock语句是围绕try / finally包装的Enter / Exit调用的语法糖。 在.NET中实现关键部分的一个不错的功能是可以为同一流重新输入该部分。 这意味着此类代码将执行而不会出现问题:
lock(a) { lock (a) { ... } }
当然,不太可能有人用这种方式编写代码,但是如果您将此代码拖入深度调用堆栈的几种方法中,则此功能可以为您节省一些ifs。 为了使这种技巧成为可能,.NET开发人员必须添加一个限制-只能将引用类型的实例用作同步对象,并将几个字节隐式添加到将要写入流标识符的每个对象中。
c#中关键部分的此功能对lock语句的操作施加了一个有趣的限制:您不能在lock语句内使用await语句。 起初,这让我感到惊讶,因为最终会尝试类似的Monitor.Enter / Exit构造进行编译。 怎么了 在这里,有必要再次仔细阅读最后一段,然后向其添加一些有关异步/等待原理的知识:等待后的代码不一定与等待前的代码在同一线程上执行,这取决于同步上下文以及是否存在。没有调用ConfigureAwait。 因此,Monitor.Exit可以在Monitor.Enter以外的线程上执行,这将引发
SynchronizationLockException 。 如果您不相信它,则可以在控制台应用程序中执行以下代码:它会引发SynchronizationLockException。
var syncObject = new Object(); Monitor.Enter(syncObject); Console.WriteLine(Thread.CurrentThread.ManagedThreadId); await Task.Delay(1000); Monitor.Exit(syncObject); Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
值得注意的是,在WinForms或WPF应用程序中,如果从主线程调用此代码,它将可以正常工作。 将有一个同步上下文,该上下文会在等待后实现返回UI-Thread的操作。 无论如何,您都不应在包含await运算符的代码上下文中使用关键部分。 在这些情况下,最好使用同步原语,这将在后面讨论。
谈到.NET中关键部分的工作,值得一提的是其实现的另一个功能。 .NET中的关键部分以两种模式运行:自旋等待模式和内核模式。 旋转等待算法方便地表示为以下伪代码:
while(!TryEnter(syncObject)) ;
此优化基于以下假设:如果资源现在很忙,那么它将很快释放自己,因此可以在短时间内最快捕获关键部分。 如果这不是在短时间内发生,则线程将以内核模式等待,这与从线程模式返回一样需要时间。 .NET开发人员已尽可能地优化了短锁方案,不幸的是,如果许多线程开始中断它们之间的关键部分,则可能会导致CPU负载突然升高。
SpinLock,SpinWait
由于我提到了旋转等待算法,因此值得一提的是BCL SpinLock和SpinWait结构。 如果有理由相信总会有机会非常迅速地锁定,则应使用它们。 另一方面,在剖析结果表明使用其他同步原语成为程序的瓶颈之前,几乎不值得记住它们。
Monitor.Wait,Monitor.Pulse [全部]
这两种方法应一起考虑。 在他们的帮助下,可以实现各种生产者-消费者方案。
生产者-消费者-一种多进程/多线程设计模式,假定存在一个或多个生成数据的线程/进程以及一个或多个处理该数据的进程/线程。 通常使用共享集合。这两个方法只有在导致它们的线程当前具有锁的情况下才能被调用。 Wait方法释放锁定并挂起,直到另一个线程调用Pulse。
为了演示这项工作,我写了一个小例子:
object syncObject = new object(); Thread t1 = new Thread(T1); t1.Start(); Thread.Sleep(100); Thread t2 = new Thread(T2); t2.Start();
(我使用图像而不是文本来直观地显示指令的执行顺序)解析:在第二个流的开始处设置100ms的延迟,以确保稍后开始执行。
-T1:第2行流开始
-T1:第3行流进入关键部分
-T1:6号线,流进入睡眠状态
-T2:第3行流开始播放
-T2:#4号线在等待关键部分时冻结
-T1:7号线释放关键部分,并在等待Pulse退出时冻结
-T2:第8行进入临界区
-T2:第11行使用Pulse方法通知T1
-T2:第14行退出关键部分。 在此之前,T1无法继续执行。
-T1:15号线醒来
-T1:16号线离开临界区
MSDN关于使用Pulse / Wait / Wait方法有一个重要说明,即:Monitor不存储状态信息,这意味着如果在调用Wait方法之前调用Pulse方法,则可能导致死锁。 如果可能出现这种情况,则最好使用ResetEvent系列的类之一。前面的示例清楚地说明了Monitor类的Wait / Pulse方法是如何工作的,但是仍然存在关于何时使用它的问题。 一个很好的例子是BlockingQueue <T>的这种实现,另一方面,System.Collections.Concurrent中的BlockingCollection <T>的实现使用SemaphoreSlim进行同步。
ReaderWriterLockSlim
这是我钟爱的同步原语,由同名的System.Threading命名空间类表示。 在我看来,如果许多程序的开发人员使用此类而不是通常的锁,它们会更好地工作。
想法:许多线程可以读取,只有一个写入。 一旦流声明要写入,就无法开始新的读取,但会等待记录完成。 还有一个可升级读取锁定的概念,如果您在读取过程中了解到需要写东西,可以使用此概念,这样的锁定将在一个原子操作中转换为写入锁定。System.Threading命名空间中还有一个ReadWriteLock类,但是强烈建议在新开发中使用它。 Slim版本可以避免许多情况下导致死锁的情况,此外,它还可以使您快速捕获锁,因为 支持在旋转等待模式下进行同步,然后再进入内核模式。如果在阅读本文时您还不了解此类,那么我想您现在已经回想起最近编写的代码中的许多示例,其中使用这种锁定方法可以使程序有效地工作。
ReaderWriterLockSlim类的接口非常简单明了,但很难称其使用方便:
var @lock = new ReaderWriterLockSlim(); @lock.EnterReadLock(); try {
我喜欢将其用法包装在一个类中,这使使用它更加方便。
想法:使用Dispose方法使Read / WriteLock方法返回一个对象,这将允许它们在使用中使用,并且其行数与通常的锁几乎没有区别。
class RWLock : IDisposable { public struct WriteLockToken : IDisposable { private readonly ReaderWriterLockSlim @lock; public WriteLockToken(ReaderWriterLockSlim @lock) { this.@lock = @lock; @lock.EnterWriteLock(); } public void Dispose() => @lock.ExitWriteLock(); } public struct ReadLockToken : IDisposable { private readonly ReaderWriterLockSlim @lock; public ReadLockToken(ReaderWriterLockSlim @lock) { this.@lock = @lock; @lock.EnterReadLock(); } public void Dispose() => @lock.ExitReadLock(); } private readonly ReaderWriterLockSlim @lock = new ReaderWriterLockSlim(); public ReadLockToken ReadLock() => new ReadLockToken(@lock); public WriteLockToken WriteLock() => new WriteLockToken(@lock); public void Dispose() => @lock.Dispose(); }
这种技巧使您可以简单地进一步写:
var rwLock = new RWLock();
ResetEvent系列
我将此类包括ManualResetEvent,ManualResetEventSlim,AutoResetEvent类。
ManualResetEvent类,其Slim版本和AutoResetEvent类可以处于两种状态:
-在此状态下,处于阻塞状态(未信号通知)的所有调用WaitOne的线程都将冻结,直到事件转换为已通知状态为止。
-降低状态(带信号),在此状态下,挂在WaitOne调用上的所有流都将释放。 发生故障事件时,所有新的WaitOne调用都会立即有条件通过。
AutoResetEvent类与ManualResetEvent类的不同之处在于,它在完全释放一个线程后会自动进入锁定状态。 如果有多个线程挂起等待AutoResetEvent,则与ManualResetEvent不同,Set调用将只释放一个任意线程。 ManualResetEvent将释放所有线程。
让我们看一下AutoResetEvent如何工作的示例:
AutoResetEvent evt = new AutoResetEvent(false); Thread t1 = new Thread(T1); t1.Start(); Thread.Sleep(100); Thread t2 = new Thread(T2); t2.Start();

该示例显示,仅通过释放挂在WaitOne调用上的线程,事件才能自动进入锁定状态(未信号通知)。
与ReaderWriterLock不同,ManualResetEvent类未标记为已弃用,并且不建议在其Slim版本出现后使用。 此类的瘦身版可有效地用于短期期望,因为 它发生在Spin-Wait模式下,常规版本适用于长版本。
除了ManualResetEvent和AutoResetEvent类之外,还存在CountdownEvent类。 该类对于算法的实现非常方便,在该算法中,要进行并行化的部分后面是将结果放在一起的部分。 这种方法称为
fork-join 。 一篇出色的
文章专门介绍了该类的工作,因此在此我将不对其进行详细分析。
结论
- 使用线程时,导致不正确或丢失结果的两个问题是竞争状况和死锁
- 导致程序花费更多时间或资源的问题-线程饥饿和繁忙等待
- .NET具有丰富的线程同步功能
- 有2种锁定等待模式-旋转等待,核心等待。 某些.NET线程同步原语同时使用
- 互锁是一组原子操作,用于无锁算法中,是最快的同步原语
- 锁操作符和Monitor.Enter / Exit实现了关键部分的概念-一段代码一次只能由一个线程执行
- Monitor.Pulse / Wait方法对于实现Producer-Consumer脚本很方便
- 在可以并行读取的脚本中,ReaderWriterLockSlim可能比常规锁定更有效率
- ResetEvent类家族可能对线程同步很有用。