Почему продолжения Task.When Все выполняются синхронно?

14

Я только что сделал любопытное замечание относительно Task.WhenAllметода при работе на .NET Core 3.0. Я передал простую Task.Delayзадачу в качестве единственного аргумента Task.WhenAllи ожидал, что завернутая задача будет вести себя идентично исходной задаче. Но это не так. Продолжения исходной задачи выполняются асинхронно (что желательно), а продолжения кратныTask.WhenAll(task) оболочек выполняются синхронно один за другим (что нежелательно).

Вот демонстрация этого поведения. Четыре рабочих задачи ожидают выполнения одной и той же Task.Delayзадачи, а затем продолжат тяжелые вычисления (смоделированные a Thread.Sleep).

var task = Task.Delay(500);
var workers = Enumerable.Range(1, 4).Select(async x =>
{
    Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}" +
        $" [{Thread.CurrentThread.ManagedThreadId}] Worker{x} before await");

    await task;
    //await Task.WhenAll(task);

    Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}" +
        $" [{Thread.CurrentThread.ManagedThreadId}] Worker{x} after await");

    Thread.Sleep(1000); // Simulate some heavy CPU-bound computation
}).ToArray();
Task.WaitAll(workers);

Вот вывод. Четыре продолжения работают, как и ожидалось, в разных потоках (параллельно).

05:23:25.511 [1] Worker1 before await
05:23:25.542 [1] Worker2 before await
05:23:25.543 [1] Worker3 before await
05:23:25.543 [1] Worker4 before await
05:23:25.610 [4] Worker1 after await
05:23:25.610 [7] Worker2 after await
05:23:25.610 [6] Worker3 after await
05:23:25.610 [5] Worker4 after await

Теперь, если я прокомментирую строку await taskи раскомментирую следующую строку await Task.WhenAll(task), результат будет совсем другим. Все продолжения выполняются в одном потоке, поэтому вычисления не распараллеливаются. Каждое вычисление начинается после завершения предыдущего:

05:23:46.550 [1] Worker1 before await
05:23:46.575 [1] Worker2 before await
05:23:46.576 [1] Worker3 before await
05:23:46.576 [1] Worker4 before await
05:23:46.645 [4] Worker1 after await
05:23:47.648 [4] Worker2 after await
05:23:48.650 [4] Worker3 after await
05:23:49.651 [4] Worker4 after await

Удивительно, но это происходит только тогда, когда каждый работник ожидает отдельную упаковку. Если я определю оболочку заранее:

var task = Task.WhenAll(Task.Delay(500));

... и затем одна и awaitта же задача внутри всех работников, поведение идентично первому случаю (асинхронные продолжения).

Мой вопрос: почему это происходит? Что заставляет синхронно выполнять продолжения разных оболочек одной и той же задачи в одном и том же потоке?

Примечание: перенос задачи Task.WhenAnyвместо Task.WhenAllрезультата приводит к тому же странному поведению.

Еще одно наблюдение: я ожидал, что завертывание оболочки внутри a Task.Runсделает продолжения асинхронными. Но этого не происходит. Продолжения строки ниже все еще выполняются в том же потоке (синхронно).

await Task.Run(async () => await Task.WhenAll(task));

Пояснение: вышеуказанные различия наблюдались в консольном приложении, работающем на платформе .NET Core 3.0. В .NET Framework 4.8 нет разницы между ожиданием исходной задачи или программой-оболочкой. В обоих случаях продолжения выполняются синхронно в одном и том же потоке.

Теодор Зулиас
источник
просто любопытно, что будет, если await Task.WhenAll(new[] { task });?
vasily.sib
1
Я думаю, что это из-за короткого замыкания внутриTask.WhenAll
Майкл Рэндалл
3
LinqPad дает одинаковый ожидаемый второй вывод для обоих вариантов ... Какую среду вы используете для параллельного запуска (консоль против WinForms против ..., .NET против Core, ..., версия платформы)?
Алексей Левенков
1
Мне удалось продублировать это поведение на .NET Core 3.0 и 3.1, но только после изменения начального значения Task.Delayс 100на, 1000чтобы оно не было завершено при awaitредактировании.
Стивен Клири
2
@BlueStrat хорошая находка! Это может быть как-то связано. Интересно, что мне не удалось воспроизвести ошибочное поведение кода Microsoft в .NET Frameworks 4.6, 4.6.1, 4.7.1, 4.7.2 и 4.8. Я получаю разные идентификаторы потоков каждый раз, что является правильным поведением. Вот скрипка, работающая на 4.7.2.
Теодор Зулиас

Ответы:

2

Таким образом, у вас есть несколько асинхронных методов, ожидающих одной и той же переменной задачи;

    await task;
    // CPU heavy operation

Да, эти продолжения будут называться последовательно после taskзавершения. В вашем примере каждое продолжение затягивает поток на следующую секунду.

Если вы хотите, чтобы каждое продолжение выполнялось асинхронно, вам может понадобиться что-то вроде;

    await task;
    await Task.Yield().ConfigureAwait(false);
    // CPU heavy operation

Так что ваши задачи возвращаются из начального продолжения и позволяют загрузке процессора работать за пределами SynchronizationContext.

Джереми Лэйкман
источник
Спасибо Джереми за ответ. Да, Task.Yieldэто хорошее решение моей проблемы. Мой вопрос, однако, больше о том, почему это происходит, а не о том, как заставить желаемое поведение.
Теодор Зулиас
Если вы действительно хотите знать, исходный код здесь; github.com/microsoft/referencesource/blob/master/mscorlib/…
Джереми Лэйкман
Я хотел бы, чтобы это было так просто, чтобы получить ответ на мой вопрос, изучая исходный код соответствующих классов. Мне понадобились бы годы, чтобы понять код и понять, что происходит!
Теодор Зулиас
Ключ избегает SynchronizationContext, вызова ConfigureAwait(false)один раз на исходное задание может быть достаточно.
Джереми Лейкман
Это консольное приложение, и SynchronizationContext.Currentоно пустое. Но я просто проверил это, чтобы быть уверенным. Я добавил ConfigureAwait(false)в awaitстроке, и это не имело никакого значения. Наблюдения такие же, как и ранее.
Теодор Зулиас
1

Когда задача создается с использованием Task.Delay(), ее параметры создания устанавливаются Noneвместо RunContinuationsAsychronously.

Это может привести к серьезным изменениям между .net framework и .net core. Независимо от этого, это, кажется, объясняет поведение, которое вы наблюдаете. Вы также можете проверить это из копаться в исходном коде , который Task.Delay()является newing доDelayPromise , который вызывает по умолчанию Taskконструктор , не оставляя никаких вариантов создания указанных.

Танвеер Бадар
источник
Спасибо Танвир за ответ. Итак, вы предполагаете, что в .NET Core RunContinuationsAsychronouslyвместо создания Noneпри создании нового Taskобъекта он стал по умолчанию ? Это объясняет некоторые мои наблюдения, но не все. В частности, это не объясняет разницу между ожиданием одной Task.WhenAllи той же оболочки и ожиданием разных оболочек.
Теодор Зулиас
0

В вашем коде следующий код находится вне повторяющегося тела.

var task = Task.Delay(100);

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

await task;

но если вы запустите следующее, он будет проверять состояние task, поэтому он будет запускать его в одном потоке

await Task.WhenAll(task);

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

var task = Task.Delay(100);
await Task.WhenAll(task);
Сейедрауф Модарреси
источник
Спасибо Сейедрауфу за ответ. Ваше объяснение не кажется мне слишком удовлетворительным. Задание возвращается Task.WhenAllобычным Task, как и оригинал task. Обе задачи в какой-то момент завершаются: исходный в результате события таймера и составной в результате завершения исходной задачи. Почему их продолжения должны отображать другое поведение? В чем аспект одной задачи отличается от другой?
Теодор Зулиас