Разобраться раз и навсегда: Task.WhenAll или Parallel.ForEachAsync в C#

в 9:06, , рубрики: .net, async, csharp, parallel, parallel programming, parallel.foreach, ruvds_статьи, task, tpl
Разобраться раз и навсегда: Task.WhenAll или Parallel.ForEachAsync в C# - 1

Все хотят писать код, который работает быстро. Часто мы сидим, просматривая написанные алгоритмы и пытаясь понять, что можно сделать, чтобы настроить их производительность. В таком случае часто прибегают к параллельному выполнению задач. Конечно, если можно выполнять задачи параллельно и делать эту работу одновременно, то ожидаемо общее количество времени на обработку сократиться.

Если быстро посмотреть на результаты, которые появляются в интернете при поиске советов по реализации подобных вещей, то можно увидеть, что есть как много похожих, так и различных предложений от различных программистов. В какой-то момент поиска вы, вероятно, столкнётесь с поиском идеей использования Task.WhenAll или Parallel.ForEachAsync.

При чтении некоторых из этих материалов будет видно много различных противоречивых ответов как на StackOverflow, так и по всему интернету. Сегодня я собираюсь сравнить эти два метода с помощью определённых бенчмарков, которые стравят их друг против друга, чтобы, наконец, выяснить применимость каждого из двух методов.

▍ Методика сравнения

Прежде чем, мы перейдём к обзору кода бенчмарков, написанных с помощью популярного NuGet пакета BenchmarkDotNet, стоит отметить, что сравнивая Task.WhenAll и Parallel.ForEachAsync, на самом деле мы будем симулировать два разных типа задач для системы.

Читая многие обсуждения этих двух методов в интернете, можно натолкнуться на предложение испытать методы в рамках парадигмы IO bound vs CPU bound.

Что это вообще означает?

IO bound означает, что написанные вами задачи ожидают завершения выполнения каких-то других вещей, которые не являются частью вашего процесса или вовсе не выполняются на вашем компьютере.

CPU bound в свою очередь, показывает другую ситуацию, когда выполняемая задача загружена выполнением некоторых вычислений.

Разобраться раз и навсегда: Task.WhenAll или Parallel.ForEachAsync в C# - 2

Поэтому в рамках двух следующих бенчмарков, которые будут рассмотрены, произойдёт попытка смоделировать IO bound работу и CPU bound работу для сравнения методов Task.WhenAll и Parallel.ForEachAsync.

▍ Код бенчмарков

Начнём с примера симуляции IO bound работы. В коде ниже стоит отметить несколько моментов.

Во-первых, бенчмарк сконфигурирован двумя различными параметрами, чтобы в динамике исследовать то, что будет испытывать программа.

Во-вторых, «настоящей» IO bound работы, вроде обращения к стороннему ресурсу, происходить не будет в целях безопасности. В действительности она моделируется через вызов await Task.Delay и управляемо настроенную величину задержки ожидания.

[ShortRunJob]
public class BenchmarkSimulatedIo
{
    [Params(1, 10, 100)]
    public int CollectionCount;
 
    [Params(1, 10, 100, 1000)]
    public int SimulatedIoDelays;
 
    [Benchmark]
    public async Task TaskWhenAll()
    {
        var tasks = Enumerable
            .Range(0, CollectionCount)
            .Select(async _ => await Task.Delay(SimulatedIoDelays))
            .ToArray();
 
        await Task.WhenAll(tasks);
    }
 
    [Benchmark]
    public async Task ParallelForEach() =>
        await Parallel.ForEachAsync(
            Enumerable.Range(0, CollectionCount),
            cancellationToken: default,
            async (i, ct) => await Task.Delay(SimulatedIoDelays, ct));
}

Теперь давайте перейдём к другому классу с бенчмарками, где будет имитироваться работа, нагружающая процессор или CPU bound work.

Как можно заметить, код очень похож на тот, что вы увидели только что выше.

Конечно, параметр задержки поменялся на другой параметр, который будет постепенно увеличивать объём работы, повышая нагрузку на процессор.

Соответственно, моделирование CPU bound работы будет заключаться в вычислении случайных чисел заданное количество раз, что будет держать поток в занятости.

[ShortRunJob]
public class BenchmarkSimulatedCpu
{
    private int[]? _dataSet;
    
    [Params(1, 10, 100)]
    public int CollectionCount;
 
    [Params(1000, 10_000, 100_000, 1_000_000)]
    public int CpuWorkIterations;
 
    [GlobalSetup]
    public void GlobalSetup() =>
        _dataSet = Enumerable.Range(0, CollectionCount).ToArray();
 
    [Benchmark]
    public async Task TaskWhenAll()
    {
        var tasks = _dataSet!.Select(_ =>
        {
            for (var i = 0; i < CpuWorkIterations; i++)
            {
                Random.Shared.Next();
            }
 
            return Task.CompletedTask;
        }).ToArray();
 
        await Task.WhenAll(tasks);
    }
 
    [Benchmark]
    public async Task ParallelForEach() =>
        await Parallel.ForEachAsync(
            _dataSet!,
            cancellationToken: default,
            (_, ct) =>
            {
                for (var i = 0; i < CpuWorkIterations; i++)
                {
                    Random.Shared.Next();
                }
 
                return ValueTask.CompletedTask;
            });
}

▍ Результаты

Посмотрим на результаты бенчмарков, в которых моделировалась IO bound работа.

Если идти последовательно, от самых простых кейсов с наименьшими числами к более сложным, то будет заметно отрезок с двумя концами, разница между которыми в количестве работы.

В частности, мы собираемся сосредоточиться на времени выполнения. Это означает, что необходимо посмотреть на столбец Mean, который и содержит в себе это время.

В целом, перфоманс Task.WhenAll и Parallel.ForEachAsync идёт рука об руку до тех пор, пока количество задач не достигает 100.

Начиная с этой отметки, даже с задержкой в одну миллисекунду Parallel.ForEachAsync ведёт себя значительно медленнее, чем Task.WhenAll. Причём относительное замедление пропорционально количеству ядер CPU.

Method CollectionCount SimulatedIoDelays Mean Error StdDev
TaskWhenAll 1 1 1.171 ms 0.1364 ms 0.0075 ms
ParallelForEach 1 1 1.175 ms 0.2840 ms 0.0156 ms
TaskWhenAll 1 10 10.840 ms 0.5011 ms 0.0275 ms
ParallelForEach 1 10 10.789 ms 0.1741 ms 0.0095 ms
TaskWhenAll 1 100 101.040 ms 2.2472 ms 0.1232 ms
ParallelForEach 1 100 101.193 ms 1.5578 ms 0.0854 ms
TaskWhenAll 1 1000 1,001.347 ms 1.4010 ms 0.0768 ms
ParallelForEach 1 1000 1,001.518 ms 1.5143 ms 0.0830 ms
TaskWhenAll 10 1 1.169 ms 0.1317 ms 0.0072 ms
ParallelForEach 10 1 1.179 ms 0.1755 ms 0.0096 ms
TaskWhenAll 10 10 10.834 ms 1.4424 ms 0.0791 ms
ParallelForEach 10 10 10.941 ms 1.7121 ms 0.0938 ms
TaskWhenAll 10 100 101.043 ms 1.8448 ms 0.1011 ms
ParallelForEach 10 100 101.137 ms 0.4404 ms 0.0241 ms
TaskWhenAll 10 1000 1,001.110 ms 10.2905 ms 0.5641 ms
ParallelForEach 10 1000 1,001.253 ms 9.3657 ms 0.5134 ms
TaskWhenAll 100 1 1.199 ms 0.1534 ms 0.0084 ms
ParallelForEach 100 1 11.559 ms 0.2389 ms 0.0131 ms
TaskWhenAll 100 10 11.010 ms 0.6529 ms 0.0358 ms
ParallelForEach 100 10 108.896 ms 22.7026 ms 1.2444 ms
TaskWhenAll 100 100 101.124 ms 2.4495 ms 0.1343 ms
ParallelForEach 100 100 1,011.473 ms 13.9072 ms 0.7623 ms
TaskWhenAll 100 1000 1,001.554 ms 0.7778 ms 0.0426 ms
ParallelForEach 100 1000 10,010.997 ms 6.5700 ms 0.3601 ms

Теперь перейдём к рассмотрению результатов бенчмарков, в которых моделировалась CPU bound работа. Взаимодействовать с результирующей таблицей будем аналогичным образом.

Напоминаю, что теперь вместо величины задержки ожидания, ниже отображается то количество работы, которое предполагается к загрузке CPU.

При 10 одновременных задачах и нагрузке на уровне 10 000 итераций Parallel.ForEachAsync начинает вырываться вперёд с чуть более, чем двукратным отрывом. При этом с ростом нагрузки растёт кратность отставания Task.WhenAll.

Method CollectionCount CpuWorkIterations Mean Error StdDev
TaskWhenAll 1 1000 3.034 us 0.5148 us 0.0282 us
ParallelForEach 1 1000 15.227 us 1.7659 us 0.0968 us
TaskWhenAll 1 10000 29.591 us 0.0890 us 0.0049 us
ParallelForEach 1 10000 46.344 us 43.1756 us 2.3666 us
TaskWhenAll 1 100000 295.580 us 2.6053 us 0.1428 us
ParallelForEach 1 100000 338.877 us 12.4913 us 0.6847 us
TaskWhenAll 1 1000000 2,949.115 us 11.9418 us 0.6546 us
ParallelForEach 1 1000000 3,005.970 us 14.3835 us 0.7884 us
TaskWhenAll 10 1000 29.677 us 1.2080 us 0.0662 us
ParallelForEach 10 1000 52.364 us 37.5063 us 2.0558 us
TaskWhenAll 10 10000 295.677 us 1.9550 us 0.1072 us
ParallelForEach 10 10000 133.792 us 31.2377 us 1.7122 us
TaskWhenAll 10 100000 2,953.540 us 104.2567 us 5.7147 us
ParallelForEach 10 100000 942.083 us 273.4102 us 14.9865 us
TaskWhenAll 10 1000000 29,646.533 us 4,224.7201 us 231.5712 us
ParallelForEach 10 1000000 7,173.921 us 1,159.1159 us 63.5351 us
TaskWhenAll 100 1000 295.824 us 1.2814 us 0.0702 us
ParallelForEach 100 1000 155.797 us 50.4889 us 2.7675 us
TaskWhenAll 100 10000 2,950.146 us 25.6237 us 1.4045 us
ParallelForEach 100 10000 928.488 us 1,189.4708 us 65.1989 us
TaskWhenAll 100 100000 29,456.723 us 159.3951 us 8.7370 us
ParallelForEach 100 100000 4,374.397 us 3,164.1426 us 173.4373 us
TaskWhenAll 100 1000000 298,187.458 us 44,659.3374 us 2,447.9290 us
ParallelForEach 100 1000000 47,080.260 us 16,038.7790 us 879.1396 us

▍ Вывод

Итак, полученные результаты оказались достаточно интересными. Для кого-то, возможно, даже ожидаемыми.

Однако если подводить итоги, то мы увидели следующее: Parallel.ForEachAsync перформил лучше в условиях CPU bound работы, в то время как Task.WhenAll показал себя лучше в условиях IO bound работы.

С чем это может быть связано? Как мне кажется, планировщик потоков за кулисами Parallel.ForEachAsync ограничивает сам себя в терминах того, как много он сможет сделать в параллель. Соответственно, в случае Task.WhenAll никакого троттлинга нет, и всё управление и планирование потоков ложится на плечи операционной системы.

▍ P.S.

Среда выполнения для замера производительности из статьи:

BenchmarkDotNet v0.13.12, macOS Monterey 12.3 (21E230) [Darwin 21.4.0]
Apple M1 Pro, 1 CPU, 10 logical and 10 physical cores
.NET SDK 8.0.100

Полный код всех бенчмарков доступен по ссылке: gist.github.com/Stepami/17ceafbfdd91259a9821fd808e3eb08f

Ещё я веду Telegram-канал StepOne, куда выкладываю много интересного контента про коммерческую разработку, C# и мир IT глазами эксперта.

Автор: Степан Минин

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js