【问题标题】:WhenAll vs WaitAll in parallelWhenAll 与 WaitAll 并行
【发布时间】:2015-01-07 19:36:12
【问题描述】:

我正在尝试了解 WaitAllWhenAll 的工作原理并遇到以下问题。从方法中获取结果有两种可能的方式:

  1. return Task.WhenAll(tasks).Result.SelectMany(r=> r);
  2. return tasks.Select(t => t.Result).SelectMany(r => r).ToArray();

如果我理解正确,第二种情况就像在 tasks 上调用 WaitAll 并在此之后获取结果。

看起来第二种情况的性能要好得多。我知道WhenAll 的正确用法是使用await 关键字,但我仍然想知道为什么这些行的性能差异如此之大。

在分析了系统的流程后,我想我已经弄清楚了如何在一个简单的测试应用程序中对问题进行建模(测试代码基于 I3arnon 答案):

    public static void Test()
    {
        var tasks = Enumerable.Range(1, 1000).Select(n => Task.Run(() => Compute(n)));

        var baseTasks = new Task[100];
        var stopwatch = Stopwatch.StartNew();
        for (int i = 0; i < 100; i++)
        {
            baseTasks[i] = Task.Run(() =>
            {
                tasks.Select(t => t.Result).SelectMany(r => r).ToList();
            });

        }
        Task.WaitAll(baseTasks);
        Console.WriteLine("Select - {0}", stopwatch.Elapsed);

        baseTasks = new Task[100];
        stopwatch.Restart();
        for (int i = 0; i < 100; i++)
        {
            baseTasks[i] = Task.Run(() =>
            {
                Task.WhenAll(tasks).Result.SelectMany(result => result).ToList();
            });

        }
        Task.WaitAll(baseTasks);
        Console.WriteLine("Task.WhenAll - {0}", stopwatch.Elapsed);
    }

看起来问题在于从其他任务(或Parallel 循环)启动任务。在这种情况下,WhenAll 会导致程序的性能更差。这是为什么呢?

【问题讨论】:

  • 第二个已经延迟执行,并且只会在您迭代生成的IEnumerable 时进行评估。你确定你的任务真的开始了吗?
  • @Chips_100 原始样本使用ToArray,因此不会延迟执行
  • @Richard - 我必须承认一开始我忘了添加 ToArray。我已经编辑了这个问题,所以现在已经到位了。
  • 我不建议像在 Gist 中那样将 Parallel.ForEachTask 结合使用。如果可以最大限度地提高性能,请使用Parallel.ForEach,但如果这是不可能的并且您必须启动Task,那么在高度优化和分区的并行循环中这样做是没有意义的。
  • @MartinLiversage 我按照你的建议做了。谢谢。

标签: c# .net task-parallel-library async-await


【解决方案1】:

您在 Parallel.ForEach 循环中开始任务,您应该避免这样做。 Paralle.ForEach 的重点是在可用的 CPU 内核上并行化许多小型但密集的计算,并且启动任务不是密集计算。相反,它会创建一个任务对象并将其存储在一个队列中,如果任务池已经饱和,它很快就会启动 1000 个任务。所以现在Parallel.ForEach 与任务池竞争计算资源。

在第一个很慢的循环中,调度似乎不是最理想的,并且使用的 CPU 很少,可能是因为 Parallel.ForEach 中的 Task.WhenAll。如果您将 Parallel.ForEach 更改为普通的 for 循环,您将看到加速。

但如果你的代码真的像 Compute 函数一样简单,在迭代之间没有任何状态,你可以摆脱任务并简单地使用 Parallel.ForEach 来最大化性能:

Parallel.For(0, 100, (i, s) =>
{
    Enumerable.Range(1, 1000).Select(n => Compute(n)).SelectMany(r => r).ToList();
});

至于为什么Task.WhenAll 执行得更差,你应该意识到这段代码

tasks.Select(t => t.Result).SelectMany(r => r).ToList();

不会并行运行任务。 ToList 基本上将迭代包装在 foreach 循环中,循环体创建一个任务,然后等待任务完成,因为您检索了 Task.Result 属性。所以循环的每次迭代都会创建一个任务,然后等待它完成。 1000 个任务一个接一个地执行,处理任务的开销很小。这意味着您不需要我上面建议的任务。

另一方面,代码

Task.WhenAll(tasks).Result.SelectMany(result => result).ToList();

将启动所有任务并尝试同时执行它们,并且由于任务池无法并行执行 1000 个任务,这些任务中的大多数在执行之前都会排队。这会产生很大的管理和任务切换开销,从而导致性能不佳。

关于您添加的最后一个问题:如果外部任务的唯一目的是启动内部任务,那么外部任务没有用处,但如果外部任务在那里执行某种内部任务的协调任务然后它可能是有意义的(也许你想将Task.WhenAnyTask.WhenAll结合起来)。没有更多的上下文,很难回答。但是,您的问题似乎与性能有关,启动 100,000 个任务可能会增加相当大的开销。

Parallel.ForEach 是一个不错的选择,如果您想像示例中那样执行 100,000 次独立计算。任务非常适合执行并发活动,这些活动涉及对您想要等待和组合结果以及处理错误的其他系统的“慢速”调用。对于大规模并行性,它们可能不是最佳选择。

【讨论】:

  • 这看起来是个不错的答案,但请看第二种情况。在这里,我们有标准的for 循环,我们在其中启动任务,然后枚举我们的另一个任务的顺序。再说一遍:WhenAll 的表现要差得多。您能谈谈您对此案的看法吗?
  • @bryl:我编辑了我的答案,试图解释为什么Task.WhenAll 很慢。
  • 我已经通过另一个例子编辑了我的问题:现在我立即开始所有任务。但看起来您的建议是正确的:可能任务调度程序以某种方式知道,任务非常“小”,并尝试以同步方式一个接一个地运行它们。
  • @bryl:在第一个更快的循环中,您只有在前一个任务完成时才开始下一个任务。检索Task.Result 将阻塞并等待任务完成,这是在foreach 循环内完成的,ToList 本质上就是这样。因此,由于这段代码,任务是同步执行的。
  • 看看问题的“第 2 部分”。这里ToArray in property 立即启动所有任务
【解决方案2】:

你的测试太复杂了,所以我自己做了。这是一个包含您的 Consume 方法的简单测试:

public static void Test()
{
    var tasks = Enumerable.Repeat(int.MaxValue, 10000).Select(n => Task.Run(() => Compute(n)));

    var stopwatch = Stopwatch.StartNew();
    Task.WhenAll(tasks).Result.SelectMany(result => result).ToList();
    Console.WriteLine("Task.WhenAll - {0}", stopwatch.Elapsed);

    stopwatch.Restart();
    tasks.Select(t => t.Result).SelectMany(r => r).ToList();
    Console.WriteLine("Select - {0}", stopwatch.Elapsed);
}

private static List<int> Compute(int seed)
{
    var results = new List<int>();
    for (int i = 0; i < 5000; i++)
    {
        results.Add(seed * i);
    }

    return results;
}

输出:

Task.WhenAll - 00:00:01.2894227
Select - 00:00:01.7114142

但是,如果我使用Enumerable.Repeat(int.MaxValue, 100),则输出为:

Task.WhenAll - 00:00:00.0205375
Select - 00:00:00.0178089

基本上,选项之间的区别在于您是阻塞一次还是阻塞每个元素。当有很多元素时阻塞一次会更好,但是对于每个阻塞很少会更好。

由于没有太大的区别,而且您只在处理许多项目时才关心性能,并且从逻辑上讲,您希望在所有任务完成后继续进行,我建议使用Task.WhenAll

【讨论】:

  • 我已经删除了延迟执行,但问题仍然存在。还有其他想法吗?仍然是第 2 行可以提高系统性能。
  • 添加了完整的复制品。看看编辑过的问题。
  • 这很好实现。但是,请注意,现在的问题是关于在 Parallel 循环中启动任务 - 这是发生问题的场景。我已经根据您的实现更新了 Gist 上的代码。见link
  • @bryl 很好,这回答了最初的问题。如果您有新问题,您应该单独发布一个。
猜你喜欢
  • 2011-09-01
  • 2014-09-27
  • 1970-01-01
  • 2014-12-21
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多