我在业余时间开发维护了一款免费开源的升讯威在线客服系统,也收获了许多用户。对我来说,只要能获得用户的认可,就是我最大的动力。
最近客服系统成功经受住了客户现场组织的压力测试,获得了客户的认可。
客户组织多名客服上线后,所有员工同一时间打开访客页面疯狂不停的给在线客服发消息,系统稳定无异常无掉线,客服回复消息正常。消息实时到达无任何延迟。
https://kf.shengxunwei.com/
我会通过一系列的文章详细分析升讯威在线客服系统的并发高性能技术是如何实现的,使用了哪些方案以及具体的做法。
本篇介绍 PLINQ 并行查询技术。
并行 LINQ (PLINQ) 是语言集成查询 (LINQ) 模式的并行实现。 PLINQ 将整套 LINQ 标准查询运算符实现为 System.Linq 命名空间的扩展方法,并提供适用于并行操作的其他运算符。 PLINQ 将 LINQ 语法的简洁和可靠性与并行编程的强大功能结合在一起。
###什么是并行查询
一个 PLINQ 查询的许多方面都类似于非并行的 LINQ to Objects 查询。 与顺序 LINQ 查询一样,PLINQ 查询对任何内存中 IEnumerable 或 IEnumerable 数据源执行操作,并且推迟了执行,即在枚举查询前不会开始执行。 主要区别在于,PLINQ 会尝试充分利用系统上的所有处理器。 方法是将数据源分区成片段,然后在多个处理器上针对单独工作线程上的每个片段执行并行查询。 在许多情况下,并行执行意味着查询运行速度显著提高。
通过并行执行,通常只需向数据源添加 AsParallel 查询操作,PLINQ 即可显著提升性能(与某些类型查询的旧代码相比)。 但是,并行可能会引入其自身的复杂性,因此并非所有的查询操作的运行速度在 PLINQ 中都更快。 事实上,并行实际上会降低某些查询的速度。 因此,应了解排序等问题将如何对并行查询产生影响。
###PLINQ 查询性能的影响因素
下面各部分列出了并行查询性能的一些最重要的影响因素。 这些都是一般性说明,本身并不足以用于在所有情况下预测查询性能。
var queryA = from num in numberList.AsParallel()
select ExpensiveFunction(num); //good for PLINQ
var queryB = from num in numberList.AsParallel()
where num % 2 > 0
select num; //not as good for PLINQ
系统上的逻辑内核数量(并行度)。
这一点是上一部分的必然结果,在具有更多内核的计算机上,适合并行查询运行得更快,这是因为可以在更多并发线程之间划分工作。 加速总量取决于查询整体工作的并行度百分比。 不过,不要认为所有查询在八核计算机上的运行速度都是在四核计算机上的两倍。 优化查询以实现最佳性能时,请务必在具有不同数量内核的计算机上度量实际结果。 这一点与第 1 点相关:需要更大的数据集,才能利用更多的计算资源。
操作的数量和种类。
如果有必要维护源序列中的元素顺序,PLINQ 提供 AsOrdered 运算符。 虽然排序有相关成本,但此成本通常还算低。 GroupBy 和 Join 操作同样也会产生开销。 如果允许按任意顺序处理源集合中的元素,并在准备就绪后立即将它们传递给下一个运算符,PLINQ 的性能最佳。
查询执行形式。
若要通过调用 ToArray 或 ToList 存储查询结果,所有并行线程的结果都必须合并到一个数据结构中。 这就涉及不可避免的计算成本。 同样,如果使用 foreach(Visual Basic 中的 For Each)循环来循环访问结果,工作线程的结果必须串行化到枚举器线程。 不过,如果只想根据每个线程的结果执行某操作,可以使用 ForAll 方法对多个线程执行此操作。
合并选项类型。
PLINQ 可以配置为缓冲输出并在生成整个结果集后分块区生成或一次性全部生成,也可以配置为在各个结果生成时流式传输它们。 前一个导致总体执行时间减少,后一个导致所生成元素之间的延迟减少。 尽管合并选项不一定会对总体查询性能造成重大影响,但它们可能会影响感知性能,因为它们控制用户在看到结果前必须等待的时间。
###选择使用模型
var source = Enumerable.Range(1, 10000);
// Opt in to PLINQ with AsParallel.
var evenNums = from num in source.AsParallel()
where num % 2 == 0
select num;
Console.WriteLine("{0} even numbers out of {1} total",
evenNums.Count(), source.Count());
// The example displays the following output:
// 5000 even numbers out of 10000 total
AsParallel 扩展方法将后续查询运算符(在此示例中为 where 和 select)绑定到 System.Linq.ParallelEnumerable 实现。
###执行模式
默认情况下,PLINQ 是保守的。 在运行时,PLINQ 基础结构将分析查询的总体结构。 如果通过并行可能会提高查询速度,PLINQ 则将源序列分区为可以同时运行的任务。 如果并行化查询不安全,PLINQ 则只会按顺序运行查询。 如果 PLINQ 可以在可能会较昂贵的并行算法或成本较低的顺序算法之间进行选择,它会默认选择顺序算法。 可以使用 WithExecutionMode 方法和 System.Linq.ParallelExecutionMode 枚举指示 PLINQ 选择并行算法。 如果你通过测试和测量知道特定查询以并行方式执行得更快时,此做法非常有用。
###并行度
默认情况下,PLINQ 使用主机计算机上的所有处理器。 可以使用 WithDegreeOfParallelism 方法指示 PLINQ 使用不超过指定数量的处理器。 当你要确保计算机上运行的其他进程收到一定的 CPU 时间量时,此做法将非常有用。 下面的片段将查询限制为最多使用两个处理器。
var query = from item in source.AsParallel().WithDegreeOfParallelism(2)
where Compute(item) > 42
select item;
在查询要执行大量非受计算限制的工作(如文件 I/O)的情况下,最好指定比计算机上的内核数要大的并行度。
###已排序和未排序的并行查询
在某些查询中,一个查询运算符必须产生保留源序列排序的结果。 为此,PLINQ 提供了 AsOrdered 运算符。 AsOrdered 不同于 AsSequential。 尽管仍并行处理 AsOrdered 序列,但会缓冲和排序它的结果。 由于顺序暂留通常涉及额外的工作,因此处理 AsOrdered 序列可能比处理默认 AsUnordered 序列更慢。 特定的已排序并行操作是否比操作的顺序版本更快取决于许多因素。
下面的代码示例演示了如何选择使用顺序保留。
var evenNums =
from num in numbers.AsParallel().AsOrdered()
where num % 2 == 0
select num;
###并行和顺序查询
某些操作要求按顺序提供源数据。 必要时,ParallelEnumerable 查询运算符自动还原为顺序模式。 对于要求顺序执行的用户定义的查询运算符和用户委托,PLINQ 提供了 AsSequential 方法。 使用 AsSequential 时,查询中的所有后续运算符都会顺序执行,直到再次调用 AsParallel。
###异常
当一个 PLINQ 查询执行时,可能会同时从不同的线程引发多个异常。 此外,处理异常的代码可能与引发异常的代码处于不同的线程上。 PLINQ 使用 AggregateException 类型封装查询抛出的所有异常,并将这些异常封送回调用线程。 在调用线程上,只需要一个 try-catch 块。 不过,可以循环访问在 AggregateException 中封装的所有异常,并捕获任何可以安全恢复的异常。 在极少数情况下,可能会抛出一些未在 AggregateException 中包装、ThreadAbortException 也没有进行包装的异常。
如果允许异常向上冒泡回到联接线程,则查询也许可以在引发异常后继续处理一些项。
###自定义分区程序
在某些情况下,可以通过编写利用源数据的某些特征的自定义分区程序来提高查询性能。 在查询中,自定义分区程序本身是被查询的可枚举对象。
int[] arr = new int[9999];
Partitioner partitioner = new MyArrayPartitioner(arr);
var query = partitioner.AsParallel().Select(SomeFunction);
PLINQ 支持固定数量的分区(尽管在运行时期间为了负载均衡可能会将数据重新动态分配到这些分区)。 For 和 ForEach 仅支持动态分区。也就是说,分区数在运行时发生变化。
###ForAll 运算符
在顺序 LINQ 查询中,执行一直延迟到在 foreach(Visual Basic 中为 For Each)循环中或通过调用 ToList、ToArray 或 ToDictionary 等方法枚举查询。 在 PLINQ 中,还可以使用 foreach 执行查询以及循环访问结果。 但是,foreach 本身不会并行运行,因此,它要求将所有并行任务的输出合并回该循环正在上面运行的线程中。 在 PLINQ 中,在必须保留查询结果的最终排序,以及以按串行方式处理结果时,例如当为每个元素调用 Console.WriteLine 时,则可以使用 foreach。 为了在无需顺序暂留以及可自行并行处理结果时更快地执行查询,请使用 ForAll 方法执行 PLINQ 查询。 ForAll 不执行最终的这一合并步骤。 下面的代码示例说明如何使用 ForAll 方法。 此处使用 System.Collections.Concurrent.ConcurrentBag 是因为它已优化,可以同时添加多个线程,而无需尝试移除任何项。
var nums = Enumerable.Range(10, 10000);
var query =
from num in nums.AsParallel()
where num % 10 == 0
select num;
// Process the results as each thread completes
// and add them to a System.Collections.Concurrent.ConcurrentBag(Of Int)
// which can safely accept concurrent add operations
query.ForAll(e => concurrentBag.Add(Compute(e)));
###衡量 PLINQ 性能
在很多情况下,可以并行化查询,但是设置并行查询的开销可能会超出获得的性能收益。 如果查询不执行大量的计算,或者如果数据源较小,则 PLINQ 查询的速度可能比顺序 LINQ to Objects 查询的速度慢。 可以在 Visual Studio Team Server 中使用并行性能分析器比较各种查询的性能,查找处理瓶颈,以及确定查询是并行运行还是按顺序运行。
升讯威在线客服与营销系统是一款客服软件,但更重要的是一款营销利器。
https://kf.shengxunwei.com/