37

在我的应用程序中,我并行执行了几十到几百个动作(这些动作没有返回值)。

哪种方法是最优化的:

  1. 在 foreach 循环中使用Task.Factory.StartNew迭代Action数组 ( Action[])

    Task.Factory.StartNew(() => someAction());

  2. 使用数组Parallelwhereactions的类 ( )ActionAction[]

    Parallel.Invoke(actions);

这两种方法是否等效?是否有任何性能影响?

编辑

我已经进行了一些性能测试,并且在我的机器上(每台 2 CPU 2 核)结果似乎非常相似。我不确定它在 1 CPU 等其他机器上会是什么样子。另外我不确定(不知道如何非常准确地测试它)什么是内存消耗。

4

3 回答 3

47

这两者之间最重要的区别是,Parallel.Invoke在继续执行代码之前将等待所有操作完成,而StartNew将继续执行下一行代码,允许任务在自己的好时机完成。

这种语义差异应该是您首先(也可能是唯一)考虑的因素。但出于信息目的,这里有一个基准:

/* This is a benchmarking template I use in LINQPad when I want to do a
 * quick performance test. Just give it a couple of actions to test and
 * it will give you a pretty good idea of how long they take compared
 * to one another. It's not perfect: You can expect a 3% error margin
 * under ideal circumstances. But if you're not going to improve
 * performance by more than 3%, you probably don't care anyway.*/
void Main()
{
    // Enter setup code here
    var actions2 =
    (from i in Enumerable.Range(1, 10000)
    select (Action)(() => {})).ToArray();

    var awaitList = new Task[actions2.Length];
    var actions = new[]
    {
        new TimedAction("Task.Factory.StartNew", () =>
        {
            // Enter code to test here
            int j = 0;
            foreach(var action in actions2)
            {
                awaitList[j++] = Task.Factory.StartNew(action);
            }
            Task.WaitAll(awaitList);
        }),
        new TimedAction("Parallel.Invoke", () =>
        {
            // Enter code to test here
            Parallel.Invoke(actions2);
        }),
    };
    const int TimesToRun = 100; // Tweak this as necessary
    TimeActions(TimesToRun, actions);
}


#region timer helper methods
// Define other methods and classes here
public void TimeActions(int iterations, params TimedAction[] actions)
{
    Stopwatch s = new Stopwatch();
    int length = actions.Length;
    var results = new ActionResult[actions.Length];
    // Perform the actions in their initial order.
    for(int i = 0; i < length; i++)
    {
        var action = actions[i];
        var result = results[i] = new ActionResult{Message = action.Message};
        // Do a dry run to get things ramped up/cached
        result.DryRun1 = s.Time(action.Action, 10);
        result.FullRun1 = s.Time(action.Action, iterations);
    }
    // Perform the actions in reverse order.
    for(int i = length - 1; i >= 0; i--)
    {
        var action = actions[i];
        var result = results[i];
        // Do a dry run to get things ramped up/cached
        result.DryRun2 = s.Time(action.Action, 10);
        result.FullRun2 = s.Time(action.Action, iterations);
    }
    results.Dump();
}

public class ActionResult
{
    public string Message {get;set;}
    public double DryRun1 {get;set;}
    public double DryRun2 {get;set;}
    public double FullRun1 {get;set;}
    public double FullRun2 {get;set;}
}

public class TimedAction
{
    public TimedAction(string message, Action action)
    {
        Message = message;
        Action = action;
    }
    public string Message {get;private set;}
    public Action Action {get;private set;}
}

public static class StopwatchExtensions
{
    public static double Time(this Stopwatch sw, Action action, int iterations)
    {
        sw.Restart();
        for (int i = 0; i < iterations; i++)
        {
            action();
        }
        sw.Stop();

        return sw.Elapsed.TotalMilliseconds;
    }
}
#endregion

结果:

Message               | DryRun1 | DryRun2 | FullRun1 | FullRun2
----------------------------------------------------------------
Task.Factory.StartNew | 43.0592 | 50.847  | 452.2637 | 463.2310
Parallel.Invoke       | 10.5717 |  9.948  | 102.7767 | 101.1158 

如您所见,使用 Parallel.Invoke 大约比等待一堆新任务完成快 4.5 倍。当然,那是你的行为完全没有任何作用的时候。每个动作做的越多,你注意到的差异就越小。

于 2013-01-02T23:40:36.780 回答
14

在总体方案中,考虑到在任何情况下实际处理大量任务的开销时,这两种方法之间的性能差异可以忽略不计。

Parallel.Invoke基本上Task.Factory.StartNew()为您执行。所以,我想说可读性在这里更重要。

此外,正如 StriplingWarrior 所提到的,它会为您Parallel.Invoke执行WaitAll(阻止代码直到所有任务完成),因此您也不必这样做。如果您想让任务在后台运行而不关心它们何时完成,那么您需要Task.Factory.StartNew().

于 2013-01-02T23:36:01.013 回答
13

我使用了 StriplingWarror 的测试来找出差异的来源。我这样做是因为当我使用 Reflector 查看代码时,Parallel 类与创建一堆任务并让它们运行没有什么不同。

从理论的角度来看,这两种方法在运行时间方面应该是等效的。但是由于(不是很现实的)带有空动作的测试确实表明 Parallel 类要快得多。

任务版本确实花费了几乎所有时间来创建新任务,这确实导致了许多垃圾收集。您看到的速度差异纯粹是由于您创建了许多很快变成垃圾的任务。

相反,Parallel 类确实创建了自己的任务派生类,该类在所有 CPU 上同时运行。只有一个物理任务在所有内核上运行。同步确实发生在任务委托内部,这确实解释了 Parallel 类的速度要快得多。

ParallelForReplicatingTask task2 = new ParallelForReplicatingTask(parallelOptions, delegate {
        for (int k = Interlocked.Increment(ref actionIndex); k <= actionsCopy.Length; k = Interlocked.Increment(ref actionIndex))
        {
            actionsCopy[k - 1]();
        }
    }, TaskCreationOptions.None, InternalTaskOptions.SelfReplicating);
task2.RunSynchronously(parallelOptions.EffectiveTaskScheduler);
task2.Wait();

那么什么更好呢?最好的任务是永远不会运行的任务。如果您需要创建如此多的任务以至于它们成为垃圾收集器的负担,您应该远离任务 API 并坚持使用 Parallel 类,它可以让您在所有核心上直接并行执行而无需新任务。

如果您需要变得更快,那么手动创建线程并使用手动优化的数据结构为您的访问模式提供最大速度可能是最高效的解决方案。但是您不太可能成功这样做,因为 TPL 和并行 API 已经经过大量调整。通常,您需要使用众多重载之一来配置正在运行的任务或 Parallel 类,以用更少的代码实现相同的目标。

但是如果你有一个非标准的线程模式,那么你最好不要使用 TPL 来充分利用你的内核。甚至 Stephen Toub 也确实提到过 TPL API 并不是为超快速性能而设计的,但主要目标是让“普通”程序员更容易处理线程。要在特定情况下击败 TPL,您需要远高于平均水平,并且您需要了解很多关于 CPU 缓存行、线程调度、内存模型、JIT 代码生成等方面的知识……以便在您的特定场景中提出一些建议更好的。

于 2013-01-03T14:19:02.597 回答