C# .NET 4.5 中的多线程(第一部分)






4.73/5 (52投票s)
对基本的多线程概念进行讨论,并介绍一些多线程对象。
引言
作为一项关键的设计元素,多线程得到了广泛关注,并且在 .NET 的每个版本中都得到了不断扩展。随着库的不断扩展,如何最好地利用每种工具变得越来越具有挑战性。我的目标是创建一个系列文章,解释一些实际的线程考虑因素以及我发现最好用来处理它们的工具和策略。
像许多编程工具一样,随着工具的扩展,它通常会以牺牲灵活性和自定义的抽象为代价来促进开发便利性。在大多数情况下,这是一种最佳的取舍。通常情况下,它可以防止人们犯低级错误,并在底层推广最佳实践。但这确实意味着,如果新工具无法完成某些操作,你仍然需要了解旧工具。
因此,有理由回顾 .NET 1.1 的多线程工具以及它们之间的所有版本。然而,无论你使用什么工具,你都必须了解细节以及幕后发生的事情,才能最有效地使用它们。
因此,第一部分将重点关注基础线程问题和工作原理。我还会介绍一些基本构造,以便在你的应用程序中开始进行多线程处理。请享用!
Using the Code
代码只是包含所讨论代码片段的一些非常简单的控制台应用程序的解决方案。
线程切片
CPU 每个核心一次只能运行一个线程,但即使只有一个核心的 PC 也可以同时运行数十个应用程序。诀窍在于(在大多数情况下)它们并非在同一时间真正运行。CPU 分配时间并调度线程运行,这被称为线程切片(又称时间切片、线程调度),以提供并发运行的外观和感觉。这也是允许应用程序内多线程的机制。
切片立即会带来一些多线程的主要问题
- 可以实际并发运行的线程数量有限。
- 创建线程会增加线程管理成本。
- 线程被调度,这可能按你期望的方式运行,也可能不按你期望的方式运行。
总而言之,这应该清楚地表明必须仔细考虑多线程。事实上,这还表明如果你不这样做,你可能会遇到更差的性能(例如,线程管理的成本大于创建线程的好处)。因此,让我们简要地解决这些问题,并讨论如何通过线程切片最大限度地提高多线程的效率。
注意: 我将采用更多的“黑板物理学”方法来简化解释。不可避免地,你的计算机上还有其他线程在运行,占用了你的应用程序未控制的资源。
有两种基本的线程工作类型
- 处理大量数据。
- 等待数据。
万一“处理”数据不够清楚,我们指的是大量计算和操作,这些操作会在执行期间最大化 CPU 使用率。在这种特定情况下,最佳并发线程数是明确的——CPU 的核心数。只要每个线程负责计算相似数量的数据,完成总操作的时间应该是 1/n,其中 n 是核心数。
那么我们来思考一下……如果每个核心都处于 100%,我无法执行任何其他操作。如果每个线程导致一个核心达到 100%,那么添加更多线程将带来类似于 1/n - C*t 的性能优势,其中 n 是核心数,C 是管理线程的成本,t 是并发线程的总数。这就是为什么你想将这种类型工作的并发执行限制为 CPU 的核心数。
等待数据的多线程处理会变得更加复杂,因为优化将取决于你在等待什么。例如,假设你正在等待通过网络的服务调用返回数据。由于你可以有多个并发网络连接,因此启动调用可能非常有价值;然而,网络通信会存在某种瓶颈,所以不要超过那个阈值。
假设你有一些线程正在执行数据库调用,并且数据库连接被池化,最多有 100 个池化连接……不要超过 100 个并发线程……如果数据库调用是阻塞调用,你可能需要严重限制并发请求,以避免过度的阻塞,这可能会占用池化连接或导致超时。同样,这完全取决于具体情况的限制和阈值。
最后要讨论的基本线程切片考虑因素是执行顺序。没有线程控制机制,线程执行顺序基本上是随机的。这是一个小型代码片段,使用任务并行库 (TPL) 来演示这一点。
Parallel.For(0, 10, (i) =>
{
Console.WriteLine("My i value is " + i);
});
Console.WriteLine("Press any key to continue...");
Console.ReadKey();
同样,执行顺序将是随机的,但这是一个示例输出:
My i value is 9
My i value is 6
My i value is 8
My i value is 7
My i value is 5
My i value is 3
My i value is 2
My i value is 4
My i value is 0
My i value is 1
Press any key to continue...
如果我们创建了执行顺序很重要的线程,就必须建立适当的控制机制。有很多对象和策略可供我们用来控制线程,但我将在未来的文章中介绍它们。
线程执行顺序导致的 C# 代码结果差异被称为竞态条件。虽然不一定,但绝大多数竞态条件都会导致 bug。由竞态条件引起的 bug 有时是最难分离和识别的,因此在创建具有执行顺序依赖性的线程时,必须预先采取极端措施。
我知道这是一个非常基础的多线程切片介绍,但希望它能突出审慎创建线程和限制的必要性。实际环境将需要大量的具体调整才能获得更好的优化和适当的线程控制。
临界区
有很多方法可以使线程切片变得复杂,但对于大多数实现基本多线程功能的人来说,关键区域将引入最多的麻烦。关键区域是应避免多个线程同时访问的代码区域。这里的关键短语是“应避免”,因为没有什么可以阻止代码访问关键区域,也没有什么可以识别关键区域!
关键区域的基本机制是阻塞和信号,这两者都相对简单。阻塞是阻止线程进入代码区域,而信号是允许线程进入代码区域,或通知它可以进入代码区域。换句话说,就是停止和前进……
不那么简单的是,关键区域越安全(阻塞的代码越多,阻塞的总量越多),代码的性能就越差。实际上,由于阻塞将代码执行减少到单个线程,因此它可以将多线程代码有效地变成单线程。这使得降低控制关键区域的成本成为多线程的一个非常重要的方面。
注意:虽然有很多对象可以帮助控制对关键区域的访问,但为了解释关键区域基础知识,我将只介绍最基本的构造——lock
。
lock
关键字通常在 SyncRoot 模式中使用,如下所示:
private object _syncRoot = new object();
public int Count { get; set; }
public void ThreadingMethod()
{
int localCount = 0;
lock(_syncRoot)
{
// Critical section
localCount = ++Count;
Console.WriteLine("Count is now " + Count);
}
// Do more work here
Console.WriteLine("Completing ThreadingMethod " + localCount + " execution.");
}
停! 好的,请继续阅读,但不要草草略过……这个简单的示例中有一些重要细节需要彻底涵盖。
识别关键区域,而不仅仅是非线程安全操作。
可以由多个线程并发执行的操作称为线程安全操作。线程安全操作必须是原子操作或具有线程控制机制。任何由多个线程并发调用的、作用于共享资源的非线程安全操作都必须包含在关键区域内,否则会冒线程 bug 的风险。
在第一个关键区域示例中,实际上只有一个非线程安全操作,那就是
// Critical section
localCount = ++Count;
但这是一行代码,怎么会不是线程安全的呢? .NET 中的任何单个操作都会被编译成 MSIL,这可能会产生多种中间操作。MSIL 中的任何单个操作都会被转换为本地代码,这可能会产生多种操作。这些都可以被线程切片中断。
这在此示例中可能导致的结果是,递增操作在执行过程中被中断,导致 Count
被低值覆盖。线程递增是显示此行为的经典示例。要演示这一点,只需运行下面的代码:
static void Main(string[] args) { Parallel.For(0, 10000, (i) => { ++Count; }); Console.WriteLine("Count is " + Count); Console.WriteLine("Press any key to continue..."); Console.ReadKey(); }
你可能会得到 9,000 到 10,000 之间的值。这是我一次运行得到的结果:
Count is 9243 Press any key to continue...
好的,这很好,这是非线程安全的,但如果这是唯一的非线程安全操作,为什么第一个 Console.WriteLine
会在关键区域内?因为从逻辑上讲,输出到 Console
的值应该是当前线程正在处理的递增值。如果将其排除在外,其他线程可能会在调用 Console.WriteLine
之前再次递增该值。
下面是一个正确控制 Count
在递增期间的访问的示例,但它没有使用 localCount 值,而是在第二个 Console.Writeline
中再次访问 Count
。
// RACE CONDITION!!!
public void ThreadingMethod()
{
lock(_syncRoot)
{
// Part of the critical section
++Count;
Console.WriteLine("Count is now " + Count);
}
// Use this line to expose the race condition easier
System.Threading.Thread.Sleep(TimeSpan.FromMilliseconds(10));
// Other part of the critical section not protected!
Console.WriteLine("Completing ThreadingMethod " + Count + " execution.");
}
在这个特定的示例中,Count
将按预期递增(即,如果我调用此函数 100 次,Count
始终为 100);然而,你会在第二个 Console.WriteLine
中看到重复和丢失的值,例如:
Completing ThreadingMethod 94 execution. Completing ThreadingMethod 99 execution. Completing ThreadingMethod 94 execution. Completing ThreadingMethod 95 execution. Completing ThreadingMethod 100 execution. Completing ThreadingMethod 100 execution. Completing ThreadingMethod 100 execution. Completing ThreadingMethod 100 execution. Completing ThreadingMethod 100 execution.
锁定尽可能少。
在这个简单的示例中,“工作”是写入 Console
,这 hardly 是一个昂贵的操作;然而,在实际情况中,这很可能是非常昂贵的操作,这就是我们最初想对其进行线程化的原因。如果我所有的工作都放在 lock
里面,那么多线程就没有意义了,因为它有效地将其减少为单线程执行。
下面是一个演示过度锁定工作的示例:
// Alternative that effectively reduces it to a single threaded operation
public void ThreadingMethod()
{
lock(_syncRoot)
{
// Critical section
++Count;
Console.WriteLine("Count is now " + Count);
Console.WriteLine("Completing ThreadingMethod " + Count + " execution.");
}
}
如果你运行此示例,你将始终得到从 1 到 Count
的顺序升序输出。基本上,这表明其他线程无法同时进行工作。即使它不是我所有的工作,并且有一些并发工作可用,lock
中的任何不必要的工作都会减少我可以并发运行的工作量,从而降低性能。
减少需要锁定的内容的一个技巧是将共享资源转储到本地变量。在第一个示例中,localCount
就是为此目的。在共享资源 Count
以线程安全的方式修改后,我可以在任何地方使用 localCount
,而不会有线程问题的风险。
但是这里要警告一下,这个示例部分有效是因为 int
是一个值类型。如果你想在线程任务中将共享的引用变量复制到本地变量,你可能需要克隆该对象。否则,变量可能会因其他线程而发生意外更改。
Parallel 类
Parallel
类是 TPL 的一部分,位于 System.Threading.Tasks
命名空间中。
我在线程切片部分简要介绍了 TPL,但为了打开线程创建策略,我将更详细地介绍 Parallel
类。有几种非常有用的创建线程的方法,Parallel
并不是万能的,但它易于理解,易于使用,并且功能丰富。
非常有用的场景
- 使用 Parallel.For 并发加载分页数据。
- 使用 Parallel.ForEach 加载/初始化集合中的对象。
这是我最常见的用法:
Parallel.ForEach(IEnumerable<T> sources, ParallelOptions options, Action<T, ParallelLoopState> body)
此代码将为 source 中的每个项目创建一个 Task
,该 Task
执行 Action
体。ParallelLoopState
允许你停止执行,这在处理特殊条件和异常时很有用。ParallelOptions
允许对 Task
执行进行一些控制。
现在,关于 Task
的一个非常重要的理解是,它是由管理的,所以它不一定是与线程一对一的关系。例如,所有创建的 Task
对象都可能同步运行。我将在未来的文章中更详细地讨论 Task
类,但无论好坏,你知道 TPL 都会为你处理这些细节。
ParallelOptions
只有三个属性,但我通常只使用 MaxDegreeOfParallelism
。你可以将此与 Environment.ProcessorCount
属性结合使用,将并发执行限制为核心数。
new ParallelOptions() { MaxDegreeOfParallelism = Environment.ProcessorCount }
同样,你的 MaxDegreeOfParallelism
应根据其将要执行的代码的具体限制和阈值进行定制,如线程切片部分所述。无论你将其设置为多少,能够将其设置为参数并开箱即用地进行控制都非常方便。
ParallelLoopState
有两个停止执行的方法:Break()
和 Stop()
。区别在于 Stop()
会尝试停止所有迭代的执行,而 Break()
只会停止未来迭代的执行。
注意:根据我的经验,停止或中断并行 Task
很少能立即得到响应。把它想象成让一个疯狂的疯子停止咆哮——通常直到他结束后才会发生。如果你真的需要线程立即停止,则需要更复杂的线程控制。
当你创建线程时,包括使用 Parallel
,处理异常非常重要。后台线程上的未处理异常可能导致应用程序崩溃,或默默地终止。这两者都很糟糕。
一般来说,任何多线程的代码都应该用 try
catch
块包围;然而,我不建议吞噬所有异常。使用正确的异常处理实践。在可以处理的地方处理它们,在应该抛出的地方抛出它们,等等。
如果你确实需要在 Action
体内重新抛出异常,它会向循环抛出另一个 AggregateException
。你当然也应该妥善处理 AggregateException
。
接下来 TBD
本文只是浅尝辄止,因为有超过 100 个对象可以讨论,并且有许多不同的策略可以使用它们。因此,我有点犹豫不决,不知道下一篇文章将涵盖什么。如果你对接下来要听的内容特别感兴趣,请给我一些反馈。
历史
- 2015-09-17 初始文章。