当所有与等待所有并行

本文关键字:并行 等待 | 更新日期: 2023-09-27 18:34:32

我试图了解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会导致程序的性能更差。为什么?

当所有与等待所有并行

您正在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 个任务可能会增加相当大的开销。

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

你的测试太复杂了,所以我自己做了。下面是包含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