PLINQ和TPL的性能表现对比

4

我需要执行一些数据库操作,我尝试使用PLINQ

someCollection.AsParallel()
              .WithCancellation(token)
              .ForAll(element => ExecuteDbOperation(element))

我注意到它相比之下速度很慢:

var tasks = someCollection.Select(element =>
                                    Task.Run(() => ExecuteDbOperation(element), token))
                          .ToList()

await Task.WhenAll(tasks)

我更喜欢使用PLINQ语法,但出于性能考虑我被迫使用第二个版本。

有人能解释一下这两个版本在性能上的巨大差异吗?


1
someCollection 包含多少个元素?ExecuteDbOperation 操作的平均时间是多少? - Disappointed
我认为4-10个,我还没有使用Stopwatch来测试ExecuteDbOperation,但它当然是一个IO操作... 如果有人已经进行了基准测试,对于PLINQ/TPL的使用阈值也很好。例如:10-1000个元素和1秒操作优先使用PLINQ,而1000-10000个元素和50毫秒操作则优先使用TPL。 - Stefano d'Antonio
对您来说,将 ExecuteDbOperation 设为 async 是一个可选项吗? - svick
@Clockwork-Muse 我指的是据我所知,AsParallel.ForAll() 不可等待,因此调用线程会被阻塞,直到所有工作完成。即使这些工作在不同的线程上完成。 - Peter Bons
@svick 我们最终会将其改为异步,但我仍然对此情况感到好奇。 - Stefano d'Antonio
显示剩余3条评论
3个回答

4

我的推测是因为创建的线程数量不同。

在第一个例子中,这个数字大致等于你计算机的核心数。相比之下,第二个例子将会创建与someCollection元素数量相同的线程。对于IO操作来说,这通常更加高效。

Microsoft指南"Patterns_of_Parallel_Programming_CSharp"建议对于IO操作创建比默认值更多的线程(第33页):

var addrs = new[] { addr1, addr2, ..., addrN };
var pings = from addr in addrs.AsParallel().WithDegreeOfParallelism(16)
select new Ping().Send(addr);

1
我在考虑分区,这是一个很好的测试方法,可以指定查询并查看其并行性,但在我的情况下,应该只有4-10个元素在超线程8核机器上运行,所以我并不完全确定。 - Stefano d'Antonio
@Uno 第二个示例中没有任何分区。 - Disappointed
I/O 的事情可能会比较复杂。书中的 ping 示例之所以能够很好地工作,是因为它是一个具有高延迟的分布式查询(向许多机器询问)。相比之下,单个磁盘 I/O 可能不会从多个线程中获得太多利润,甚至会降低性能:当读取许多未分段的文件时,每个操作都会使 I/O 达到最大值;但每次线程切换可能会产生头部重新定位的惩罚。(旧式的旋转磁盘假设...)但类似的效果也可能发生在需要在不同表之间进行交换的数据库服务器上。 - Peter - Reinstate Monica
@失望的确如此,在这种情况下,分区实际上可能会对性能产生不利影响,但我指出,如果PLINQ为每个逻辑处理器创建一个线程,则线程数很可能与第二个示例相同。感谢链接,看起来很有趣,我稍后会查看。 - Stefano d'Antonio

4
PLINQ和Parallel.ForEach()主要设计用于处理CPU密集型工作负载,这就是为什么它们对IO密集型工作不太适用的原因。对于某些特定的IO密集型工作,存在最佳的并行度,但它并不取决于CPU核心数量,而PLINQ和Parallel.ForEach()中的并行度确实在更大或更小的程度上取决于CPU核心数量。
具体来说,PLINQ的工作方式是使用固定数量的Task,默认情况下基于计算机上的CPU核心数量。这旨在为一系列PLINQ方法提供良好的性能。但似乎这个数字比您的工作的理想并行度要小。
另一方面,Parallel.ForEach()委托决定运行多少TaskThreadPool。只要线程被阻塞,ThreadPool会慢慢地添加它们。结果是,随着时间的推移,Parallel.ForEach()可能会更接近理想的并行度。
正确的解决方案是通过测量找出您的工作的正确并行度,然后使用它。
理想情况下,您应该使代码异步,然后使用某种方法来限制async代码的并行度
由于您表示目前无法这样做,我认为一个不错的解决方案是避免使用ThreadPool,而是在专用线程上运行您的工作(可以通过使用TaskCreationOptions.LongRunningTask.Factory.StartNew()来创建这些线程)。
如果您愿意继续使用ThreadPool,另一种解决方案是使用PLINQ ForAll(),但也要调用WithDegreeOfParallelism()

3
我认为如果元素数量超过10000个,最好使用PLINQ,因为它不会为集合中的每个元素创建任务,而是在内部使用分区器。每个任务的创建都有一些开销,因为需要进行数据初始化。分区器只会创建针对当前可用核心进行优化的任务数,因此它将重复使用这些任务来处理新数据。您可以在此处阅读更多信息: http://blogs.msdn.com/b/pfxteam/archive/2009/05/28/9648672.aspx

有点像。它会创建任务,但不能保证它们会被使用或提供数据。 - Clockwork-Muse
我不是专家,但总的来说,这对于更多元素会更有效 @Clockwork-Muse? - Jacob Sobus
1
很多这些问题的答案是“取决于情况”,需要进行分析。即使您正在创建数千个任务,几乎所有运行时、GC'd语言在创建小对象方面都具有良好的性能(因为这是一种常见的事情)。当您达到硬件级别时,数据局部性最终变得非常重要,这会导致一些奇怪的情况。 - Clockwork-Muse
真的,所以有效的答案是:“这取决于个人情况,需要进行评估和决策” ;) - Jacob Sobus

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接