这篇文章主要讲解 .NET
的任务并行,与数据并行不同的是:数据并行以数据为处理单元,而任务并行,则以任务(工作)为单元
关于任务的理解,如果还有疑问,可以参考之前的文章
任务并行基础
如果我们想要创建并行的任务,可以通过 Parallel.Invoke
来实现。它可以很方便的帮助我们同时运行多个任务,如下
public static void WorkOne() { // 任务一}public static void WorkTwo() { // 任务二}Parallel.Invoke(WorkOne, WorkTwo);// 我们也可以通过 Lambda 表达式这样写Parallel.Invoke( () => { // 任务一 }, () => { // 任务二 });复制代码
借助 Parallel.Invoke
,我们只需表达想同时运行的操作,CLR
会处理所有线程调度的具体信息(包括将线程数量自动缩放至计算机上的内核数)
需要特别注意
TPL
在后台创建的Task
数量不一定与所提供的操作的数量相等。 因为TPL
可能会针对操作的数量进行不同程度的优化
因此,对 Parallel.Invoke
,我们可以这样理解(只是为了理解方便,不表示其内部具体实现也是这样的)
- 分配一个具有 4 个线程的“线程池”(假设计算机处理器为 4 核 4 线程)。或者根据指定的
ParallelOptions
中的MaxDegreeOfParallelism
属性来确定具体数量 - 采用
Task.Run
的方式运行每一个任务。每执行一个任务,就从“线程池”中取一个空闲的线程。如果没有多余的空闲线程,则等待 - 直到处理完所有的任务为止
这也可以理解为对其内部实现的一个猜测。如果有兴趣,可以使用 .NET Refactor
看一下其源码
如果程序有 UI
线程,且任务的创建从 UI
线程开始,那么在使用方式上会有变化,如下代码所示
Task.Run(() => { Parallel.Invoke( () => { // 任务一 }, () => { // 任务二 });});复制代码
这对于其他的并行(如数据并行)也是一样的。
只要我们需要从UI
线程创建并行,就应该使用 Task.Run
来启动它们。否则,很有可能产生死锁(一般出现在当并行代码内部需要访问 UI
的情况下,其他情况我也暂时没有遇到过) 如果我们分不清当前创建并行的是 UI
线程还是其他类型的线程。我们可以统一使用 Task
的方式来启动它们。反正在大部分情况下,使用 Task
来启动也不会造成什么性能问题
不过,如果我们需要并行立即启动,或者尽快启动,使用 Task
来启动可能就不太合适,在系统工作量比较重的情况下,我们也不清楚这个 Task
什么时候能够执行。
Thread
与 Task
不同,Thread
不以任务为单位,当我们调用 Thread.Start()
的时候,线程就会立即执行。而 Task
,当我们调用 Task.Run
的时候,它需要接受 TPL
的调度(Task Scheduler
)。因此,其执行时间就不确定了 针对创建并行,有以下建议
- 在不确定创建并行的是
UI
线程还是其他线程时,使用Task.Run
来启动并行(如前面例子所示) - 在系统工作比较重的情况下,如果希望并行能够立即启动,我们应该使用
Thread
的方式 - 否则,在大多数情况下,无论
PC
端、Web
端、还是WebApi
后台,我们使用Task.Run
来启动并行是比较好的方式
通过 Thread
方式启动并行,示例如下
Thread thread = new Thread(() => { Parallel.Invoke( () => { Debug.WriteLine("Work 1"); },() => { Debug.WriteLine("Work 2"); });});thread.Start();复制代码
针对并行的建议
前面提到,在多处理器条件下,使用 Parallel
可以显著提升性能。但事物总有两面性,因此还是有一些坑需要我们注意
- 对于任务并行,如果任务间具有强关联性(即有很多任务的执行依赖于其他的任务或者多个任务之间存在资源共享)。个人不建议使用并行库,因为在以往的经验中,这样的处理并没有为我们带来特别大的性能提升
Parallel.For
和Parallel.ForEach
以数据并行为主;Parallel.Invoke
以任务并行为主- 不要对循环进行过度并行化。所谓物极必反,过度的并行化,不但增加了管理的难度,线程间的同步以及最后各个分区的合并,都会对性能造成影响
- 如果并行里面的单次迭代的工作量较小,推荐使用
Partitioner
来手动的对源集合进行分块 - 避免在并行代码块内调用非线程安全的方法,就算是声明为线程安全的方法,也应该尽量少的调用
- 尽量避免在
UI
线程上执行并行循环。也应尽量避免在并行代码中更新UI
,因为这有可能会产生数据损坏或死锁 - 在并行迭代中,我们不应该假定每一个迭代顺序开始。比如有集合 [1,2,3,4,5,6,7,8],假设分为 4 个分块 [1,2]、[3,4]、[5,6]、[7,8],我们不应该认为 [1,2] 这个块要比 [5,6] 这个块先执行。理解这个很重要,可以防止我们写出可能产生死锁的代码,示例如【示例A】所示
示例A
ManualResetEventSlim mre = new ManualResetEventSlim();int processor = Environment.ProcessorCount;var source = Enumerable.Range(0, processor * 100);Parallel.ForEach(source, item => { if (item == processor) { mre.Set(); } else { mre.Wait(); }});复制代码
对于这段代码,就可能会(可能性非常大)发生死锁。如前面【针对并行的建议】的最后一点所说,同样地,此处我们也无法确定 mre.Set()
与 mre.Wait()
到底谁先执行
后话
最近看了一些书籍,决定无论何时,凡是关注了我的朋友,都一律关注回去 源于以下一点:尊重是相互的,学习也是相互的在此,也感谢在微信公众号、知乎、简书、掘金等内容平台关注我的朋友。欢迎关注公众号【嘿嘿的学习日记】,所有的文章,都会在公众号首发,Thank you~