Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение6 мин
Количество просмотров19K
Всего голосов 42: ↑41 и ↓1+56
Комментарии18

Комментарии 18

А для тех, кому выводы автора окажутся недостаточным ответом на вопрос "что же использовать, WhenAll или ForEachAsync" - книжка Стивена Клири "Concurrency in C# Cookbook".
В ней рассказано, для чего в свое время появилась библиотека TPL, какие возможности по конфигурированию она предлагает, и где ее уместно использовать.

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

А теперь внезапный поворот и все выводы на свалку: TaskWhenAll() в CPU сценарии на самом деле бежит последовательно. ?

Если хотите параллельности, то нужно добавить await Task.Yield(); перед циклом for

Код не станет от этого работать параллельно. После await'a исполнение вернется к вызывающему потоку и так во всех итерациях. В итоге, весь код вызовется в одном потоке. Нужно еще добавить ConfigureAwait(false)

Код начнет работать асинхронно, а значит все таски будут запущенны прежде чем начнется цикл. ConfigureAwait(false) нужен только если присутствует SynchronizationContext, в остальных случаях, после окончания ожидания, цикл продолжится на Thread Pool'е. То есть параллельно.

Для таска быть запущенным и исполнятся - разные вещи. И планировщик всё равно много потоков по-умолчанию не выделит. Т.е. ну будет висеть не один загруженный поток, а пять (я вот прям сейчас у себя проверил). Это даже не в пять раз быстрее получается.

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

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

Лучше явно вызвать Task.Run

Верно. Но речь шла конкретно о сравнении этих двух методов. Я просто прокомментировал о том, что сравнение не правильное.

И вообще, скорость это самая не интересная деталь при сравнивании этих двум подходов. Task.WhenAll убьет ваш Thread Pool если после await есть тяжелая CPU операция. Чего не произойдет с Parallel.ForEachAsync. А Parallel.ForEachAsync может бежать последовательно, если вдруг вы бежите с установленными лимитами на CPU (например в k8s).

Тяжёлые операции рекомендуется запускать мимо тредпула с помощью опции LongRunning.

С чем это может быть связано? Как мне кажется...

Зачем гадать, если можно просто взять и посмотреть а исходники?

А ведь ещё есть ActionBlock из TPL

касательно не правильного тестирования второго кейса написал тут
Как легко получить deadlock на Task.WhenAll / Песочница / Хабр (habr.com)


а как пофиксить и результаты у вас в gist (ниже вырезка)
https://gist.github.com/Stepami/17ceafbfdd91259a9821fd808e3eb08f?permalink_comment_id=4885757#gistcomment-4885757

| Method                  | CollectionCount | CpuWorkIterations |  Mean, us  |  Error, us |  StdDev, us  |
|-------------------------|----------------:|------------------:|-----------:|-----------:|-------------:|
| TaskWhenAllFixOptPrefer | 100             | 1000000           | 61679,49   | 24578,22   | 1347,22      |
| ParallelForEach         | 100             | 1000000           | 74319,36   | 45998,11   | 2521,31      |

Доступ закрыт в вашу песочницу

Да там статейка на один гист также, но висит в модерации
Deadlock and Task.WhenAll. Don't forget to use Task.Run or Task.Factory.StartNew (github.com)

Тут просто пара тест-кейсов, а не полное объяснение почему это происходит (про машины состояний на хабре полно статей)

Стоит передать в Parallel.ForEachAsync ParralelOptions и результаты в IO bound снова сравняются

new ParallelOptions
{
  MaxDegreeOfParallelism = 1000,
  CancellationToken = default
}
|     TaskWhenAll |             100 |              1000 | 1,008.89 ms | 112.286 ms | 6.155 ms |  29,336 B |
| ParallelForEach |             100 |              1000 | 1,009.61 ms |  61.388 ms | 3.365 ms |  56,912 B |

В отличии от Parallel.ForEach, у Parallel.ForEachAsync есть неприятная особенность

Описание к свойству MaxDegreeOfParallelism у ParralelOptions гласит

If MaxDegreeOfParallelism is -1, then there is no limit placed on the number of concurrently running operations.

На деле же, в кишках в таком случае будет использовано свойтво Environment.ProcessorCount

private static int DefaultDegreeOfParallelism => Environment.ProcessorCount;
...
_remainingDop = dop < 0 ? DefaultDegreeOfParallelism : dop; // где dop - MaxDegreeOfParallelism

Зарегистрируйтесь на Хабре, чтобы оставить комментарий