Чому продовження Task.WhenAll виконується синхронно?


14

Щойно я зробив цікаве спостереження щодо Task.WhenAllметоду під час роботи на .NET Core 3.0. Я передав просте Task.Delayзавдання як єдиний аргумент Task.WhenAll, і я очікував, що завершене завдання буде поводитися однаково з початковим завданням. Але це не так. Продовження початкового завдання виконується асинхронно (що бажано), а продовження декількох Task.WhenAll(task)обгортків виконується синхронно один за одним (що небажано).

Ось демонстрація такої поведінки. Чотири робочі завдання чекають того ж Task.Delayзавдання, щоб виконати, а потім продовжують важкі обчислення (імітовані а 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));

Пояснення: вищезазначені відмінності спостерігалися в програмі Console, що працює на платформі .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.Currentnull. Але я просто перевірив це, щоб бути впевненим. Я додав ConfigureAwait(false)у awaitрядку, і це не мало значення. Спостереження такі ж, як і раніше.
— Теодор Зуліяс

1

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

Це може призвести до зміни змін .net Framework і .net core. Незалежно від цього, схоже, це пояснює поведінку, яку ви спостерігаєте. Ви також можете перевірити це з копатися в вихідному коді , який Task.Delay()є newing доDelayPromise , який викликає за замовчуванням Taskконструктор , не залишаючи ніяких варіантів створення зазначених.


Дякую Tanveer за відповідь. Отже, ви припускаєте, що в .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. Обидва завдання виконуються в якийсь момент, оригінал в результаті події таймера і складений в результаті виконання оригінального завдання. Чому їхнє продовження має відображати різну поведінку? У якому аспекті одне завдання відрізняється від іншого?
— Теодор Зуліяс
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.