多线程需要仔细编程。 对于多数任务,通过将执行请求以线程池线程的方式排队,可以降低复杂性。 本主题解决了更困难的情况,例如协调多个线程的工作,或处理阻止的线程。
注释
从 .NET Framework 4 开始,任务并行库和 PLINQ 提供 API,可降低多线程编程的一些复杂性和风险。 有关详细信息,请参阅 .NET 中的并行编程。
死锁和争用条件
多线程处理解决了吞吐量和响应能力的问题,但这样做会引入新问题:死锁和争用条件。
死锁数
当两个线程尝试锁定另一个线程已锁定的资源时,会发生死锁。 两个线程都无法进一步取得进展。
托管线程处理类的许多方法都提供了超时设定,有助于检测死锁。 例如,以下代码尝试获取名为 lockObject
的对象上的锁。 如果在 300 毫秒内未获取到锁,Monitor.TryEnter 将会返回 false
。
If Monitor.TryEnter(lockObject, 300) Then
Try
' Place code protected by the Monitor here.
Finally
Monitor.Exit(lockObject)
End Try
Else
' Code to execute if the attempt times out.
End If
if (Monitor.TryEnter(lockObject, 300)) {
try {
// Place code protected by the Monitor here.
}
finally {
Monitor.Exit(lockObject);
}
}
else {
// Code to execute if the attempt times out.
}
竞争条件
争用条件是程序的结果取决于两个或更多个线程中的哪一个先到达某一特定代码块时出现的一种 bug。 多次运行程序会产生不同的结果,并且无法预测任何给定运行的结果。
争用条件的一个简单例子是递增一个字段。 假设一个类具有一个私有的 静态 字段(在 Visual Basic 中称为Shared),每次创建该类的实例时,该字段的值都会增加,使用类似 objCt++;
(C#)或 objCt += 1
(Visual Basic)的代码。 此作需要将值从 objCt
寄存器加载到寄存器中,递增该值并将其存储在其中 objCt
。
在多线程应用程序中,一个已加载并增加值的线程可能会被另一个线程抢先,后者执行了所有三个步骤;当第一个线程恢复执行并存储其值时,它会覆盖 objCt
,而没有考虑到在此期间值已经发生了变化。
通过使用 Interlocked 类的方法(例如 Interlocked.Increment),可以轻松避免这种特定的竞争条件。 若要阅读有关在多个线程之间同步数据的其他技术,请参阅 同步多线程的数据。
争用条件也可能会在同步多个线程的活动时发生。 每当你编写一行代码时,必须考虑到如果一个线程在执行该行之前(或者在组成该行的任何单个计算机指令之前)被抢占,然后另一个线程超越它,这种情况下会发生什么。
静态成员和静态构造函数
类在其类构造函数(C# 中为 static
,Visual Basic 中为 Shared Sub New
)完成运行之前不会被初始化。 为了防止对未初始化的类型执行代码,公共语言运行时会阻止从其他线程对 static
类(Shared
Visual Basic 中的成员)的所有调用,直到类构造函数完成运行。
例如,如果类构造函数启动一个新线程,并且线程过程调用 static
该类的成员,则新线程将阻塞,直到类构造函数完成。
这适用于可以具有 static
构造函数的任何类型。
处理器数目
系统上是否有多个处理器,还是只有一个处理器可以影响多线程体系结构。 有关详细信息,请参阅 处理器数。
使用 Environment.ProcessorCount 属性确定运行时可用的处理器数。
一般建议
使用多个线程时,请考虑以下准则:
请勿用于 Thread.Abort 终止其他线程。 在另一个线程上调用
Abort
类似于在该线程上引发异常,而不知道该线程在其处理中达到了什么点。请勿使用 Thread.Suspend 和 Thread.Resume 同步多个线程的活动。 使用 Mutex、 ManualResetEvent、 AutoResetEvent和 Monitor。
不要控制主程序中的工作线程的执行(例如,使用事件)。 相反,设计您的程序,使工作线程负责在有任务时等待、执行任务,并在完成后通知程序的其他部分。 如果不阻止工作线程,请考虑使用线程池线程。 Monitor.PulseAll 在工作线程被阻塞的情况下非常有用。
不要将类型用作锁对象。 也就是说,避免一些代码,如 C# 中的
lock(typeof(X))
或 Visual Basic 中的SyncLock(GetType(X))
,或避免使用 Monitor.Enter 和 Type 对象。 对于给定类型,每个应用程序域只有一个实例 System.Type 。 如果锁定对象的类型是“公共的”,那么不属于自己的代码也能锁定该对象,从而导致死锁。 有关其他问题,请参阅 可靠性最佳做法。在锁定实例(例如
lock(this)
C# 或SyncLock(Me)
Visual Basic 中)时要小心。 如果应用程序中的其他代码(在类型外部)对对象执行锁,则可能会出现死锁。请务必确保已进入监视器的线程始终离开该监视器,即使线程在监视器中时发生异常也是如此。 C# lock 语句和 Visual Basic SyncLock 语句会自动提供此行为,并通过使用 finally 块来确保调用Monitor.Exit。 如果无法确保将调用 Exit ,请考虑将设计更改为使用 Mutex。 Mutex 在当前拥有它的线程终止后会自动释放。
对需要不同资源的任务使用多个线程,并避免将多个线程分配给单个资源。 例如,涉及 I/O 的任何任务都会受益于拥有自己的线程,因为该线程将在 I/O 操作期间阻塞,从而允许其他线程继续执行。 用户输入是受益于专用线程的另一个资源。 在单处理器计算机上,涉及密集计算的任务与用户输入共存,以及涉及 I/O 的任务,但多个计算密集型任务相互竞争。
请考虑使用类的方法 Interlocked 进行简单状态更改,而不是使用
lock
语句(SyncLock
在 Visual Basic 中)。 该lock
语句是一个非常好的通用工具,但Interlocked类在需要进行原子性更新时提供更好的性能。 如果不存在争用,它会在内部执行一个锁定前缀。 在代码评审中,注意类似以下示例所示的代码。 在第一个示例中,状态变量递增:SyncLock lockObject myField += 1 End SyncLock
lock(lockObject) { myField++; }
可以通过使用Increment方法而不是
lock
语句来提高性能,如下所示:System.Threading.Interlocked.Increment(myField)
System.Threading.Interlocked.Increment(myField);
注释
将 Add 方法用于大于 1 的原子增量。
第二个示例中,仅当引用类型变量为空引用(
Nothing
在 Visual Basic 中)时,才会更新引用类型变量。If x Is Nothing Then SyncLock lockObject If x Is Nothing Then x = y End If End SyncLock End If
if (x == null) { lock (lockObject) { x ??= y; } }
可以改用 CompareExchange 此方法来改进性能,如下所示:
System.Threading.Interlocked.CompareExchange(x, y, Nothing)
System.Threading.Interlocked.CompareExchange(ref x, y, null);
注释
CompareExchange<T>(T, T, T) 方法重载为引用类型提供类型安全的替代项。
类库相关建议
设计用于多线程的类库时,请考虑以下准则:
如果可能,请避免进行同步。 这对于大量使用的代码尤其如此。 例如,可以调整算法以容忍竞争条件,而不是去消除竞争条件。 不必要的同步会降低性能,并且可能导致出现死锁和争用情况。
默认情况下,使静态数据(
Shared
在 Visual Basic 中)线程安全。默认情况下不要使实例数据线程安全。 添加锁以创建线程安全代码可降低性能,增加锁争用,并有可能发生死锁。 在常见的应用程序模型中,每次只执行一个线程执行用户代码,从而最大程度地减少线程安全性的需求。 因此,默认情况下,.NET 类库不是线程安全的。
避免提供更改静态状态的静态方法。 在常见的服务器方案中,静态状态跨请求共享,这意味着多个线程可以同时执行该代码。 这可能引发线程错误。 请考虑使用一种设计模式,将数据封装到在各请求之间不共享的实例中。 此外,如果同步静态数据,则更改状态的静态方法之间的调用可能会导致死锁或冗余同步,从而对性能产生不利影响。