这篇文章主要讲解 .NET
并行中的数据并行
数据并行指的是对集合或数组同时(即,并行)执行相同操作的场景。在数据并行操作中,需要对源集合进行分区(或者叫分块),以便多个线程能够同时服务于不同的分区。
除了采用 TPL
默认的分区方式,我们也可以自行决定如何分区。但大部分情况下,采用默认的方式就好
在 TPL
中通过 System.Threading.Tasks.Parallel
类中的 Parallel.For
或 Parallel.ForEach
这两个方法来实现数据的并行
示例如下
// 顺序执行
foreach (var item in sourceCollection) {
Process(item);
}
// 并行执行
Parallel.ForEach(sourceCollection, item => Process(item));
复制代码
当我们使用 Parallel.For
或 Parallel.ForEach
时,TPL
会使用 Task Scheduler
在后台根据系统资源和工作量来动态的将 sourceCollection
分成多个块,以便在多个处理器之间同时进行处理
TPL
动态分块
当各个处理器的工作量差别很大的时候(比如一个累得不行,一个闲得要死),TPL
就会对工作量进行重新得分配,也即为重新分块,以保证各个处理器的工作量处于一个比较平衡的状态
数据并行非常适用于那些数据之间可分而治之,最后通过某种方式将每个分块的结果汇总即可得到最终的结果的场景
但如果被处理的数据量太小,使用数据并行可能就不合适。它可能还没有顺序处理快——数据并行的优势极有可能无法抵消并行中迭代带来的性能损耗以及资源消耗
单个任务于单次迭代
后面我们会多次提到这两个概念
- 单个任务,是针对每个分块而言的
- 而单次迭代则表示的是针对单个任务中,其持有分块中的每个元素进行处理的过程
比如我们有个集合 [1,2,3,4,5,6,7,8],假设分为 2 个分块 [1,2,3,4]、[5,6,7,8]。则这 2 个分块表示有 2 个任务,每个任务对其拥有的分块里面的元素(比如对元素 1、对元素 2 等)进行处理,则表示为这个任务中的单次迭代
理解单个任务与单个迭代对理解后面的知识有很大帮助。因此在阅读后面的内容之前,尽可能的理解这两个概念
Parallel.For
举个例子:计算一个目录中的文件的总大小
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
public class App {
public static void Main() {
long totalSize = 0;
// 每兆的字节数
double bytesPerMb = 1024 * 1024.0f;
// 用于获取总大小的目标,可以随便指定
string dirName = @"D:\backgrounds";
if (!Directory.Exists(dirName)) {
Console.WriteLine("目录不存在。。");
return;
}
String[] files = Directory.GetFiles(dirName);
// 这儿启动并行循环,TPL 会根据系统情况,自动将我们指定的集合分成多个适当大小的块
// 当然我们也可以自主决定如何分块
Parallel.For(0, files.Length, index => {
FileInfo fi = new FileInfo(files[index]);
// 这个在之前的文章中已经介绍过。
// 用于在多个线程之间对简单变量进行更改
// 性能比 lock 语句要好很多
Interlocked.Add(ref totalSize, fi.Length);
});
Console.WriteLine($"目录: '{dirName}'");
Console.WriteLine($"{files.Length:N0} files, {totalSize / bytesPerMb:N2} Mb");
}
}
复制代码
输出如下
目录: 'D:\backgrounds'
31 files, 2.44 Mb
复制代码
当我们调用 Parallel.For
的时候,它会返回一个 ParallelLoopResult
对象。其定义如下
public struct ParallelLoopResult {
public bool IsCompleted { get; }
public long? LowestBreakIteration { get; }
}
复制代码
我们可以通过它来判断并行是否已经结束
具有线程局部变量的 Parallel.For 循环
线程本地变量可以用来保存由 Parallel.For
循环创建的每个单独任务的状态。通过使用线程本地变量,我们可以避免单个迭代中大量的共享资源的同步行为
可以认为,我们在单个任务的每次迭代中将计算的值存储起来,待这个任务完成之后,一次性的将结果写入共享资源。这样就提升了多个任务间资源同步的性能。因为我们不需要在单次迭代中进行资源的同步了
以下计算包含一百万个元素的数组中所有元素值的总和。示例代码如下
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace App {
public class Demo {
static void Main() {
int[] nums = Enumerable.Range(0, 1000000).ToArray();
long total = 0;
Parallel.For<long>(0, nums.Length, () => 0, (j, loop, subtotal) => {
subtotal += nums[j];
return subtotal;
}, taskTotal => Interlocked.Add(ref total, taskTotal));
Console.WriteLine($"总和是: {total:N0}");
Console.ReadKey();
}
}
}
复制代码
其中,说明一下 Parallel.For
的部分参数
- 第三个参数
() => 0
,其定义形式为Func<long> localInit
,这为了让我们对给定类型的线程局部变量进行初始化 - 最后一个参数为
Action<TLocal> localFinally
,表示的是单个任务处理完之后,会执行的代码。
比如我们代码里面的taskTotal => Interlocked.Add(ref total, taskTotal)
,其中taskTotal
表示的是单个任务完成之后得到的结果
线程局部变量这种方式,在分而治之的场景中,是非常有用的。并且,这种方式还可以改善我们并行循环的性能,何乐而不为呢?
Parallel.ForEach
Parallel.ForEach
循环与 Parallel.For
循环类似,只不过需要源集合实现 System.Collections.IEnumerable
或 System.Collections.Generic.IEnumerable<T>
接口。
它依然是对源集合进行分区,并根据系统实时情况在多个线程上安排工作。当然,系统上的处理器越多,并行方法的运行速度就越快
示例代码如下
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace App {
public class Demo {
static void Main() {
int[] nums = Enumerable.Range(0, 1000000).ToArray();
long total = 0;
Parallel.ForEach(nums, item => {
Interlocked.Add(ref total, item);
});
Console.WriteLine($"总和是: {total:N0}");
Console.ReadKey();
}
}
}
复制代码
若非泛型集合要用于 Parallel.ForEach
中,可以使用 IEnumerable.Cast
扩展方法,将集合转换为泛型集合。如下所示
Parallel.ForEach(nums.Cast<object>(), item => {
// 对 item 的处理逻辑
});
复制代码
具有线程局部变量的 Parallel.ForEach 循环
在使用线程局部变量的情况下,Parallel.ForEach
与 Parallel.ForEach
的使用方式有一点不同——就是为泛型指定类型的时候
代码如下
Parallel.ForEach<int, long>(nums, () => 0, (index, loop, subtotal) => {
subtotal += nums[index];
return subtotal;
}, taskTotal => Interlocked.Add(ref total, taskTotal));
复制代码
看 Parallel.ForEach<int, long>
,这儿我们不仅仅指定了局部变量的类型,还指定了源的类型(即集合中的元素的类型)。而 Parallel.For<long>
只指定了线程局部变量的类型。在使用的过程需要注意
ParallelOptions
即为并行选项,定义如下
public class ParallelOptions {
public ParallelOptions();
public TaskScheduler TaskScheduler { get; set; }
public int MaxDegreeOfParallelism { get; set; }
public CancellationToken CancellationToken { get; set; }
}
复制代码
通过它,我们可以对并行进行相对精细的控制。其中
TaskScheduler
:表示的是用于并行的任务调度器MaxDegreeOfParallelism
:通过它,我们可以控制这个并行操作同一时间段允许的最大并发任务数量。这其实可以理解为,需要多少个线程来处理这个并行循环。通常指定为系统的处理器数量, 即System.Environment.ProcessorCount
CancellationToken
:这个属性在之前的文章已经介绍过了。通过它,我们可以取消并行操作
ParallelLoopState
即单次迭代的状态信息,定义如下
public class ParallelLoopState {
public bool ShouldExitCurrentIteration { get; }
public bool IsStopped { get; }
public bool IsExceptional { get; }
public long? LowestBreakIteration { get; }
public void Break();
public void Stop();
}
复制代码
通过它我们可以确定每个迭代操作的状态,也可以中止当前迭代或者结束当前任务
ShouldExitCurrentIteration
:指示是否应该退出当前迭代IsStopped
:这个任务中是否已经有迭代调用了ParallelLoopState.Stop
方法。一般情况下,如果为True
,我们应该退出当前迭代IsExceptional
:指示当前任务中是否有未处理的异常,这些异常由单个迭代抛出LowestBreakIteration
:获取调用ParallelLoopState.Break()
的所有迭代中,索引最小的那个迭代Break()
:告诉系统在合适的时候,跳出当前迭代Stop()
:告诉系统在合适的时候,停止当前任务的执行
取消 Parallel.For 或 Parallel.ForEach
可以通过为并行指定一个带有取消令牌(CancellationToken
)的 ParallelOptions
选项
示例代码如下
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace App {
public class Demo {
static void Main() {
int[] nums = Enumerable.Range(0, 1000000).ToArray();
long total = 0;
CancellationTokenSource cts = new CancellationTokenSource();
// 初始化 ParallelOptions
ParallelOptions po = new ParallelOptions {
// 为该并行指定取消令牌
CancellationToken = cts.Token,
// 需要多少个线程来处理这个并行循环。通常指定为系统的处理器数量, 即 System.Environment.ProcessorCount
MaxDegreeOfParallelism = Environment.ProcessorCount
};
try {
// 将 ParallelOptions 通过第二个参数传给这个并行循环
Parallel.ForEach(nums, po, item => {
Interlocked.Add(ref total, item);
// 通过 ParallelOptions 携带的取消令牌来控制当前迭代
// 之前的文章我们说到,对于任务,我们调用 ThrowIfCancellationRequested() 方法即可
po.CancellationToken.ThrowIfCancellationRequested();
});
} catch (OperationCanceledException ex) {
// 做一些资源清理工作
}
// 3 秒之后取消
cts.CancelAfter(3000);
Console.ReadKey();
}
}
}
复制代码
异常处理
通常,我们需要将并行调用封闭到 try-catch
块中,以捕获 OperationCanceledException
、AggregateException
或者其他异常
如以下代码所示
try {
Parallel.ForEach(nums, po, item => {
Interlocked.Add(ref total, item);
po.CancellationToken.ThrowIfCancellationRequested();
});
} catch (OperationCanceledException ex) {
// 做一些资源清理工作
} catch(AggregateException aex) {
// 这儿我们可以按照之前的文章介绍的方式
// 对异常进行处理
}
复制代码
使用 Partitioner 类加快小型循环体的速度
由于数据分区中的开销和在每个循环迭代上调用委托的成本,对于小型循环体而言,可能会造成性能不好。因此,我们可以使用 Partitioner
类来做一些优化。
Partitioner
类提供了 Partitioner.Create
方法,使我们能够为委托主体提供一个顺序循环,以便每个分区(每个任务)仅调用一次委托,而不是每个迭代调用一次委托
其使用方式如下
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace App {
public class Demo {
static void Main() {
int[] nums = Enumerable.Range(0, 1000000).ToArray();
long total = 0;
// 这是重点
var rangePartitioner = Partitioner.Create(0, nums.Length);
Parallel.ForEach(rangePartitioner, (range, loop) => {
int tmpValue = 0;
for (int i = range.Item1; i < range.Item2; i++) {
tmpValue += i;
}
// 一次性提交当前任务的结果
Interlocked.Add(ref total, tmpValue);
});
Console.ReadKey();
}
}
}
复制代码
发现没有,前面单次迭代执行的操作,现在放在一个 for
循环里面了。对于单个循环工作量比较少的情况,这可以提升性能。
但如果单个迭代内,工作量比较大,那么通过 TPL
来对其管理,则可能会获得更好的性能(当然了,前提是我们的机器是多个处理器的)
至此,这篇文章的内容讲解完毕。欢迎关注公众号【嘿嘿的学习日记】,所有的文章,都会在公众号首发,Thank you~