Все хотят писать код, который работает быстро. Часто мы сидим, просматривая написанные алгоритмы и пытаясь понять, что можно сделать, чтобы настроить их производительность. В таком случае часто прибегают к параллельному выполнению задач. Конечно, если можно выполнять задачи параллельно и делать эту работу одновременно, то ожидаемо общее количество времени на обработку сократиться.
Если быстро посмотреть на результаты, которые появляются в интернете при поиске советов по реализации подобных вещей, то можно увидеть, что есть как много похожих, так и различных предложений от различных программистов. В какой-то момент поиска вы, вероятно, столкнётесь с поиском идеей использования Task.WhenAll
или Parallel.ForEachAsync
.
При чтении некоторых из этих материалов будет видно много различных противоречивых ответов как на StackOverflow, так и по всему интернету. Сегодня я собираюсь сравнить эти два метода с помощью определённых бенчмарков, которые стравят их друг против друга, чтобы, наконец, выяснить применимость каждого из двух методов.
▍ Методика сравнения
Прежде чем, мы перейдём к обзору кода бенчмарков, написанных с помощью популярного NuGet пакета BenchmarkDotNet, стоит отметить, что сравнивая Task.WhenAll
и Parallel.ForEachAsync
, на самом деле мы будем симулировать два разных типа задач для системы.
Читая многие обсуждения этих двух методов в интернете, можно натолкнуться на предложение испытать методы в рамках парадигмы IO bound vs CPU bound.
Что это вообще означает?
IO bound означает, что написанные вами задачи ожидают завершения выполнения каких-то других вещей, которые не являются частью вашего процесса или вовсе не выполняются на вашем компьютере.
CPU bound в свою очередь, показывает другую ситуацию, когда выполняемая задача загружена выполнением некоторых вычислений.
Поэтому в рамках двух следующих бенчмарков, которые будут рассмотрены, произойдёт попытка смоделировать 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 глазами эксперта.
Автор: Степан Минин