温故之.NET TPL 数据并行

这篇文章主要讲解 .NET 并行中的数据并行

数据并行指的是对集合或数组同时(即,并行)执行相同操作的场景。在数据并行操作中,需要对源集合进行分区(或者叫分块),以便多个线程能够同时服务于不同的分区。

除了采用 TPL 默认的分区方式,我们也可以自行决定如何分区。但大部分情况下,采用默认的方式就好

TPL 中通过 System.Threading.Tasks.Parallel 类中的 Parallel.ForParallel.ForEach 这两个方法来实现数据的并行

示例如下

// 顺序执行            
foreach (var item in sourceCollection) {
    Process(item);
}

// 并行执行
Parallel.ForEach(sourceCollection, item => Process(item));
复制代码

当我们使用 Parallel.ForParallel.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.IEnumerableSystem.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.ForEachParallel.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 块中,以捕获 OperationCanceledExceptionAggregateException或者其他异常

如以下代码所示

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~

转载于:https://juejin.im/post/5b35b63be51d4558cc35d995

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值