Чому продовження 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.