发布于 2026-01-06 3 阅读
0

.NET 线程陷阱

.NET 线程陷阱

本文将介绍我在 .NET 应用程序中最常见的 5 个线程错误,并解释如何修复它们。虽然线程是一个涉及诸多方面的复杂主题,但这 5 个错误代表了大多数初学者在线程方面容易犯的错误。

我将从这份清单中省略“应该使用线程安全时却不使用”的错误,而是重点关注在尝试处理多线程场景时可能会意外犯错的领域。

同步异步/等待

.NET 的async`/`await关键字使得异步编程比早期版本更加容易。然而,这种便利性也可能导致反模式的传播。

有时代码会同时提供同步和异步两种操作实现方式。大多数开发者会认为异步方法本质上更好,但事实果真如此吗?

不妨这样想:在异步操作中,你需要启动一个新线程,等待该线程启动完毕,然后再重新加入到原来的调用中。此外,该线程仍然需要执行与同步实现中相同的操作。

这意味着异步操作通常比同步操作成本更高。

所以我的意思是?异步代码不好吗?

多线程当然不是坏事,但你需要巧妙地运用它。异步操作可以让用户界面在长时间运行的操作期间保持响应,或者允许你并行执行操作。

请查看以下代码:

乍一看这似乎很高效,但我们真正想表达的是:

  1. 运行该PlotDominationAsync方法并等待其完成。
  2. 运行该DeployTroopsAsync方法并等待其完成。
  3. 运行该EstablishEvilHeadquartersAsync方法并等待其完成。
  4. 运行该LaunchDeathRayAsync方法并等待其完成。

这里我们为线程开销付出了四倍的代价,而方法仍然是同步执行的。在这个例子中,我们承受了线程的所有弊端,却没有获得任何好处。

不要这样做。

请改用以下Task.WhenAll方法:

这将把等待操作限制在运行时间最长的任务上,并真正带来异步代码带来的好处。

请注意,此示例假设这四个任务可以按任意顺序执行。

配置等待

请看以下片段:

await ProgramKillerRobotsAsync

默认情况下,await关键字会等待任务完成,然后尝试重新加入最初发起该任务的进程。这可能因编程平台而异(例如 WPF、WinForms 和 ASP.NET)。SynchronizationContext

这听起来可能不算太糟,但想象一下一个繁忙的 Web 服务器,它每秒要处理大量的请求。在这种情况下,等待原始上下文可能会造成人为的延迟,甚至在某些情况下导致死锁。

为了解决这个问题,我们ConfigureAwait对可等待的操作使用这种方法,如下所示:

await ProgramKillerRobotsAsync.ConfigureAwait(false)

在这里,我们告诉 .NET,我们不在乎在恢复操作时使用哪个 SynchronizationContext,这在繁忙的环境中效率更高。

当然,对于某些特殊线程(例如用户界面线程)来说,这并非一种可行的策略。在这些情况下,有时您需要确保所有操作都在同一线程上执行。此时,您可以省略ConfigureAwait或指定ConfigureAwait(true)线程优先级,以保持相同的线程偏好。

注意:ASP.NET Core 默认不使用 `<class>` SynchronizationContext,这意味着它ConfigureAwait不会以任何方式影响其性能。但是,对于 .NET Framework 应用程序来说,使用 `<class>` 非常重要。

使用错误的集合

在多个线程可能与集合交互的情况下,集合类的选择至关重要。

具体来说,你需要认真考虑是否要让你的收藏品对线材安全,如果是,你希望如何实现这一点。

如果多个线程可以操作同一个集合,那么该集合很可能需要有线程安全策略。

请看以下示例:

这里,在检查键是否存在和检索该值之间,底层集合可能会发生变化,这可能会导致与线程相关的错误。

最手动的方式是,每次与集合交互时都可以引入一个锁定对象:

这样有所改进,但现在我们也承担了对多线程代码一定程度的责任和风险。我们实际上是在承诺,每次使用集合时,都会记得正确使用_myDataLock并妥善运用它。

.NET 为我们提供了比这更好的工具。

并发集合命名空间中,我们有一些线程安全的集合,可以简化此类场景下的集合管理。

在本例中,我们可以使用ConcurrentDictionary类来处理多线程。让我们来看一下代码:

与其他任何技术一样,使用并发集合也存在权衡取舍。由于这些类默认线程安全,因此如果在不需要线程安全的场景中使用它们,就会因这种安全性的开销而导致性能下降。

因此,不要总是默认使用线程安全的集合,但它们是你在需要时可以使用的工具之一。

静态类/状态

当使用静态类或单例模式时,线程安全很难管理。

如果你要引入某种形式的静态状态,你应该考虑到将来某个时候多个线程会同时访问该状态。

即使你的主应用程序很少使用线程,任何类型的静态状态的存在都可能对并行运行的单元测试造成严重破坏,导致测试不一致、难以调试的测试失败,以及仅在按特定顺序或与特定其他测试一起运行时才会失败的测试。

简而言之,静态状态会浪费你几个小时的时间,同时还会减少你对单元测试的依赖。

鉴于以上原因,我的建议是尽可能避免使用静态状态。

如果实在无法做到这一点,我建议您从一开始就使用线程安全的集合和适当的锁语句,因为如果不这样做,以后会出现线程问题,并且可能需要一些时间才能将其追溯到静态状态。

线程在 IL 层工作。

最后,让我们通过一个例子来探讨一下线程方面一个常见的误解:

这是一个非常简单的方法。你可能觉得这不会有问题,但想象一下,如果有 50 个线程并行调用同一个实例上的同一个方法,那么DoggosPetted在测试运行结束时,线程数很可能不会正好是 50。

这是为什么呢?

我们阅读代码时,往往会逐行或逐条语句地思考。在这个例子中,我们读到这段代码,然后想到“加DoggosPetted1”。

然而,公共语言运行时(CLR)看到的并非如此。CLR看到的语句类似于以下形式:

Read the value of DoggosPetted
Add 1 to the register
Do an add operation between 1 and the value read from DoggosPetted
Store the result of that operation in DoggosPetted
Enter fullscreen mode Exit fullscreen mode

在多线程环境中,如果 CLR 上下文在先前线程从中读取数据DoggosPetted尚未添加值并将新值存储回之前DoggosPetted切换到另一个线程,则会覆盖线程在此期间完成的任何添加操作。

由于在这里使用lock语句来管理字段状态会很麻烦,.NET 提供了Interlocked操作来执行具有共享状态的原子操作。

让我们来看一下我的意思:

Interlocked还提供了递减、添加、删除和交换整数等操作的方法。

虽然讨论这个层面的问题有点像钻牛角尖,但重要的是要明白,我们阅读代码的方式和 CLR 阅读代码的方式是不同的,这可能会导致一些你意想不到的问题,直到你被这些问题困扰之后才会意识到。

其他资源

虽然根据我的经验,这些是 .NET 中最常见的线程问题,但此列表绝不详尽。

穿线过程中有很多环节容易出错,许多专家都深入研究过,提供了详尽的资源。

如果您想了解更多关于线程和潜在错误的信息,我强烈建议您查看 GitHub 上的异步指南页面

这篇文章《.NET线程陷阱》最初发表于Kill All Defects网站。

文章来源:https://dev.to/integerman/net-threading-gotchas-13j6