PLINQ 的潜在陷阱

在许多情况下,PLINQ 可以对顺序 LINQ to Objects 查询提供显著的性能改进。 但是,并行化查询执行的工作引入了复杂性,这可能会导致在顺序代码中不常见或根本不遇到问题。 本主题列出了编写 PLINQ 查询时要避免的一些做法。

不要假设并行始终更快

并行化有时会导致 PLINQ 查询的运行速度比等效的 LINQ to Objects 慢。 基本规则是,含有很少源元素且用户代理速度快的查询不太可能大幅加快速度。 但是,由于性能涉及许多因素,因此建议在决定是否使用 PLINQ 之前测量实际结果。 有关详细信息,请参阅了解 PLINQ 中的加速

避免写入共享内存位置

在顺序代码中,读取或写入静态变量或类字段并不少见。 但是,每当多个线程同时访问此类变量时,则很有可能会出现争用条件。 即使可以使用锁来同步对变量的访问,同步成本也会损害性能。 因此,建议尽可能多地避免或至少限制对 PLINQ 查询中共享状态的访问。

避免过度并行化

使用 AsParallel 方法会产生对源集合进行分区和同步工作线程的开销成本。 并行化的优点进一步受计算机上的处理器数的限制。 在单个处理器上运行多个计算密集型线程无法获得加速。 因此,必须小心不要过度并行化查询。

最常见的场景是嵌套查询中可能发生过度并行化,如以下代码片段所示。

var q = from cust in customers.AsParallel()
        from order in cust.Orders.AsParallel()
        where order.OrderDate > date
        select new { cust, order };
Dim q = From cust In customers.AsParallel()
        From order In cust.Orders.AsParallel()
        Where order.OrderDate > aDate
        Select New With {cust, order}

在这种情况下,最好仅并行化外部数据源(客户),除非适用以下一个或多个条件:

  • 内部数据源 (cust.Orders) 已知非常长。

  • 正在对每个订单执行开销极大的计算。 (示例中所示的操作并不昂贵。)

  • 已知目标系统有足够的处理器来处理通过并行化查询 cust.Orders生成的线程数。

在所有情况下,确定最佳查询形状的最佳方法是测试和度量。 有关详细信息,请参阅 如何:度量 PLINQ 查询性能

避免调用非线程安全方法

从 PLINQ 查询写入非线程安全实例方法可能导致数据损坏,这种损坏可能会在程序中被检测到,也可能不会被检测到。 还可能会导致异常。 在以下示例中,多个线程将尝试同时调用 FileStream.Write 该方法,该方法不受类支持。

Dim fs As FileStream = File.OpenWrite(…)
a.AsParallel().Where(...).OrderBy(...).Select(...).ForAll(Sub(x) fs.Write(x))
FileStream fs = File.OpenWrite(...);
a.AsParallel().Where(...).OrderBy(...).Select(...).ForAll(x => fs.Write(x));

限制对线程安全方法的调用

.NET 中的大多数静态方法都是线程安全的,可以同时从多个线程调用。 但是,即使在这些情况下,所涉及的同步也可能导致查询速度明显放缓。

注释

可以自行对此进行测试,具体方法是在查询中插入一些 WriteLine 调用。 尽管本文档示例中使用了此方法进行演示,但不要在 PLINQ 查询中使用此方法。

避免不必要的订购操作

当 PLINQ 并行执行查询时,它将源序列划分为多个线程可以同时处理的分区。 默认情况下,处理分区的顺序和结果的传递顺序是不可预测的(除OrderBy等运算符外)。 可以指示 PLINQ 保留任何源序列的排序,但这会对性能产生负面影响。 尽可能的最佳做法是构建查询,使其不依赖于顺序保留。 有关详细信息,请参阅 PLINQ 中的订单保留

如果可能,请优先选择 ForAll 而不是 ForEach

尽管 PLINQ 在多个线程上执行查询,但如果在foreach循环(在Visual Basic中为For Each)中使用结果,则查询结果必须合并回单个线程,并由枚举器串行访问。 在某些情况下,这是不可避免的。但在可能的情况下,尽量使用ForAll方法,使每个线程可以输出自己的结果,例如写入像System.Collections.Concurrent.ConcurrentBag<T>这样的线程安全集合。

相同的问题适用于 Parallel.ForEach. 换句话说,source.AsParallel().Where().ForAll(...) 应更加优先考虑而不是 Parallel.ForEach(source.AsParallel().Where(), ...)

注意线程相关性问题

某些技术(例如,单线程单元 (STA) 组件的 COM 互操作性、Windows 窗体以及 Windows Presentation Foundation (WPF))具有要求代码在特定线程上运行的线程关联限制。 例如,在 Windows 窗体和 WPF 中,只能在创建控件的线程上访问控件。 如果尝试访问 PLINQ 查询中 Windows 窗体控件的共享状态,如果在调试器中运行,则会引发异常。 (此设置可以关闭。但是,如果查询在 UI 线程上使用,则可以从枚举查询结果的 foreach 循环中访问控件,因为该代码仅在一个线程上执行。

不要假定 ForEach、For 和 ForAll 的迭代始终并行执行

请务必记住,单个迭代在Parallel.ForParallel.ForEachForAll循环中可能会但不必并行执行。 因此,应避免编写代码,这些代码依赖于迭代的并行执行或依赖于迭代需要按特定顺序执行才能确保结果的正确性。

例如,此代码有可能会死锁:

Dim mre = New ManualResetEventSlim()
Enumerable.Range(0, Environment.ProcessorCount * 100).AsParallel().ForAll(Sub(j)
   If j = Environment.ProcessorCount Then
       Console.WriteLine("Set on {0} with value of {1}", Thread.CurrentThread.ManagedThreadId, j)
       mre.Set()
   Else
       Console.WriteLine("Waiting on {0} with value of {1}", Thread.CurrentThread.ManagedThreadId, j)
       mre.Wait()
   End If
End Sub) ' deadlocks
ManualResetEventSlim mre = new ManualResetEventSlim();
Enumerable.Range(0, Environment.ProcessorCount * 100).AsParallel().ForAll((j) =>
{
    if (j == Environment.ProcessorCount)
    {
        Console.WriteLine("Set on {0} with value of {1}", Thread.CurrentThread.ManagedThreadId, j);
        mre.Set();
    }
    else
    {
        Console.WriteLine("Waiting on {0} with value of {1}", Thread.CurrentThread.ManagedThreadId, j);
        mre.Wait();
    }
}); //deadlocks

在此示例中,一个迭代设置一个事件,所有其他迭代在事件上等待。 在事件设置迭代完成之前,任何等待迭代都无法完成。 但是,在事件设置迭代有机会执行之前,等待迭代可能会阻止用于执行并行循环的所有线程。 这会导致僵局——事件设置的迭代永远不会被执行,等待的迭代将永远无法被唤醒。

具体而言,并行循环的一次迭代不应等待循环的另一次迭代才能取得进展。 如果并行循环决定按相反的顺序安排迭代,则会发生死锁。

另请参阅