WhenAll和WaitAll在并行中的区别

3
我正在尝试理解WaitAllWhenAll的工作原理,并遇到以下问题。 有两种可能的方法可以从方法中获取结果:
  1. return Task.WhenAll(tasks).Result.SelectMany(r=> r);
  2. return tasks.Select(t => t.Result).SelectMany(r => r).ToArray();
如果我理解正确,第二种情况就像在tasks上调用WaitAll,然后在此之后获取结果。
看起来第二种情况的性能要好得多。 我知道使用await关键字是使用WhenAll的正确方式,但我仍然想知道为什么这些行的性能差异如此之大。
在分析系统流程后,我认为已经找到了如何在简单的测试应用程序中对问题进行建模(测试代码基于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会导致程序的性能大大降低。为什么会这样呢?


5
第二个任务具有延迟执行的特性,只有在迭代生成的 IEnumerable 时才会被评估。你确定你的任务已经开始了吗? - Dennis
@Chips_100 原始示例使用 ToArray,因此它不会有延迟执行。 - Richard Szalay
1
@Richard - 我必须承认在开始时我忘了加 ToArray。现在我已经编辑了问题,所以这个问题已经解决了。 - bryl
在你的Gist中将Parallel.ForEachTask结合使用并不是我推荐的做法。如果可以,请使用Parallel.ForEach来最大化性能,但如果不可能并且必须启动Task,那么在一个高度优化和分区的并行循环中这样做是没有意义的。 - Martin Liversage
@bryl:我强烈建议您在问题中包含相关代码,使其自包含,而不是链接到另一个网站。链接可能会失效,即使它没有失效,为了理解问题和答案,不得不切换上下文也会有些分散注意力。 - Martin Liversage
1
@MartinLiversage 我按照您的建议去做了。谢谢。 - bryl
2个回答

7
您正在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.WhenAny与Task.WhenAll结合使用)。没有更多上下文很难回答。但是,您的问题似乎关注性能,启动100,000个任务可能会增加相当大的开销。
Parallel.ForEach是一个不错的选择,如果您想执行100,000个独立计算,就像您在示例中所做的那样。如果要执行涉及"慢速"调用其他系统的并发活动,并且希望等待和组合结果并处理错误,则Tasks非常适合。对于大规模并行性,它们可能不是最佳选择。

这看起来是一个不错的答案,但请看第二种情况。在这里,我们有一个标准的for循环,其中我们启动任务,这些任务反过来枚举另一组任务的序列。同样地:WhenAll表现得更差。您能分享一下您对这种情况的想法吗? - bryl
我已经通过一个例子编辑了我的问题:现在我立即开始所有任务。但是看起来你的建议是正确的:可能任务调度程序以某种方式知道任务非常“小”,并尝试以同步方式逐个运行它们。 - bryl
请看问题的“第二部分”。这里属性中的 ToArray 会立即启动所有任务。 - bryl
@bryl:ToArrayToList 类似,都是使用 foreach 循环来获取元素,循环体将为每个任务获取 Task.Result。因此,这些任务将按顺序创建和执行,而不是并行执行。您可以通过创建一个强制延迟的小示例和一些 Console.WriteLine 语句来验证这一点,以查看任务的执行情况。 - Martin Liversage
注意,在问题的“第2部分”中,在获取t.Result之前,我使用了ToArray。我能以某种方式重写它使其更明显吗? - bryl
显示剩余3条评论

3

你的测试过于复杂,所以我自己写了一个。这里有一个简单的测试,包括你的 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


我已经移除了延迟执行,但问题仍然存在。还有其他的想法吗?仍然是第二行代码导致更好的系统性能。 - bryl
添加了完整的复现步骤,请查看已编辑的问题。 - bryl
这个实现得很好。但是请注意,现在问题是关于在Parallel循环内启动任务的情况 - 这是问题发生的场景。我已经根据您的实现更新了Gist上的代码。请参见[链接](https://gist.github.com/anonymous/1e461c3a411a9be0d36e) - bryl
@bryl 好的,这回答了原来的问题。如果你有新的问题,应该单独发布一个新的问题。 - i3arnon

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