Comments 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).
С чем это может быть связано? Как мне кажется...
Зачем гадать, если можно просто взять и посмотреть а исходники?
А ведь ещё есть 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
Разобраться раз и навсегда: Task.WhenAll или Parallel.ForEachAsync в C#