Dlaczego kontynuacje Task.WhenAll są wykonywane synchronicznie?

14

Właśnie zrobiłem ciekawą obserwację dotyczącą tej Task.WhenAllmetody, gdy działałem na .NET Core 3.0. Przekazałem proste Task.Delayzadanie jako pojedynczy argument Task.WhenAlli spodziewałem się, że zapakowane zadanie będzie zachowywać się identycznie jak zadanie pierwotne. Ale tak nie jest. Kontynuacje pierwotnego zadania są wykonywane asynchronicznie (co jest pożądane), a kontynuacje wielu Task.WhenAll(task)opakowań są wykonywane synchronicznie jeden po drugim (co jest niepożądane).

Oto demonstracja tego zachowania. Cztery zadania robocze oczekują na zakończenie tego samego Task.Delayzadania, a następnie kontynuują ciężkie obliczenia (symulowane przez 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);

Oto wynik. Cztery kontynuacje przebiegają zgodnie z oczekiwaniami w różnych wątkach (równolegle).

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

Teraz, jeśli skomentuję wiersz await taski odkomentuję następujący wiersz await Task.WhenAll(task), wynik będzie zupełnie inny. Wszystkie kontynuacje są uruchomione w tym samym wątku, więc obliczenia nie są równoległe. Każde obliczenie rozpoczyna się po zakończeniu poprzedniego:

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

Zaskakująco dzieje się tak tylko wtedy, gdy każdy pracownik czeka na inne opakowanie. Jeśli z góry zdefiniuję opakowanie:

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

... a następnie awaitto samo zadanie we wszystkich pracownikach, zachowanie jest identyczne jak w pierwszym przypadku (kontynuacje asynchroniczne).

Moje pytanie brzmi: dlaczego tak się dzieje? Co powoduje, że kontynuacje różnych opakowań tego samego zadania wykonują się synchronicznie w tym samym wątku?

Uwaga: zawinięcie zadania Task.WhenAnyzamiast Task.WhenAllpowoduje takie samo dziwne zachowanie.

Kolejna obserwacja: spodziewałem się, że zawinięcie opakowania wewnątrz a Task.Runspowoduje, że kontynuacje będą asynchroniczne. Ale tak się nie dzieje. Kontynuacje poniższej linii są nadal wykonywane w tym samym wątku (synchronicznie).

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

Wyjaśnienie: Powyższe różnice zaobserwowano w aplikacji konsoli działającej na platformie .NET Core 3.0. W .NET Framework 4.8 nie ma różnicy między oczekiwaniem na pierwotne zadanie lub na opakowanie zadania. W obu przypadkach kontynuacje są wykonywane synchronicznie, w tym samym wątku.

Theodor Zoulias
źródło
tylko ciekawy, co się stanie, jeśli await Task.WhenAll(new[] { task });?
vasily.sib
1
Myślę, że to z powodu zwarcia w środkuTask.WhenAll
Michael Randall
3
LinqPad daje takie same oczekiwane drugie wyjście dla obu wariantów ... Z jakiego środowiska korzystasz, aby uzyskać równoległe uruchamianie (konsola vs. WinForm vs vs. ..., .NET vs. Core, ..., wersja frameworka)?
Aleksiej Lewenkow
1
Udało mi się zduplikować to zachowanie na platformie .NET Core 3.0 i 3.1, ale dopiero po zmianie początkowej Task.Delayz 100na 1000, aby nie była ona ukończona po awaitedycji.
Stephen Cleary
2
@BlueStrat nice find! Z pewnością można to jakoś powiązać. Co ciekawe, nie udało mi się odtworzyć błędnego zachowania kodu Microsoft w .NET Frameworks 4.6, 4.6.1, 4.7.1, 4.7.2 i 4.8. Za każdym razem otrzymuję różne identyfikatory wątków, co jest poprawnym zachowaniem. Oto skrzypce działające na 4.7.2.
Theodor Zoulias

Odpowiedzi:

2

Masz więc wiele metod asynchronicznych oczekujących na tę samą zmienną zadania;

    await task;
    // CPU heavy operation

Tak, te kontynuacje będą wywoływane szeregowo po taskzakończeniu. W twoim przykładzie każda kontynuacja następnie przerywa wątek na następną sekundę.

Jeśli chcesz, aby każda kontynuacja przebiegała asynchronicznie, możesz potrzebować czegoś takiego;

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

Aby Twoje zadania powróciły od początkowej kontynuacji, i pozwól, aby obciążenie procesora działało poza SynchronizationContext.

Jeremy Lakeman
źródło
Dzięki Jeremy za odpowiedź. Tak, Task.Yieldto dobre rozwiązanie mojego problemu. Moje pytanie dotyczy jednak bardziej tego, dlaczego tak się dzieje, a mniej tego, jak wymusić pożądane zachowanie.
Theodor Zoulias
Jeśli naprawdę chcesz wiedzieć, kod źródłowy jest tutaj; github.com/microsoft/referencesource/blob/master/mscorlib/…
Jeremy Lakeman
Chciałbym, aby tak łatwo było uzyskać odpowiedź na moje pytanie, studiując kod źródłowy powiązanych klas. Zajmie mi wieki, aby zrozumieć kod i dowiedzieć się, co się dzieje!
Theodor Zoulias
Kluczem jest uniknięcie SynchronizationContext, ConfigureAwait(false)wystarczy raz zadzwonić do pierwotnego zadania.
Jeremy Lakeman
To jest aplikacja konsolowa, a jej SynchronizationContext.Currentwartość jest zerowa. Ale właśnie to sprawdziłem. Dodałem ConfigureAwait(false)w awaitlinii i to nie miało znaczenia. Obserwacje są takie same jak poprzednio.
Theodor Zoulias
1

Gdy zadanie jest tworzone przy użyciu Task.Delay(), jego opcje tworzenia są ustawione na Nonezamiast RunContinuationsAsychronously.

Może to być przełomowa zmiana między strukturą .net a rdzeniem .net. Niezależnie od tego wydaje się wyjaśniać zachowanie, które obserwujesz. Można również sprawdzić to od kopania do kodu źródłowego, który Task.Delay()jest newing sięDelayPromise który wywołuje domyślny Taskkonstruktor nie pozostawiając możliwości tworzenia określonych.

Tanveer Badar
źródło
Dzięki Tanveer za odpowiedź. Więc spekulujesz, że w .NET Core RunContinuationsAsychronouslystał się domyślny zamiast Nonepodczas konstruowania nowego Taskobiektu? To by wyjaśniało niektóre z moich obserwacji, ale nie wszystkie. W szczególności nie wyjaśniłoby to różnicy między oczekiwaniem na to samo Task.WhenAllopakowanie a oczekiwaniem na inne opakowanie.
Theodor Zoulias
0

W twoim kodzie poniższy kod nie występuje w treści cyklicznej.

var task = Task.Delay(100);

więc za każdym razem, gdy uruchomisz następującą, będzie czekać na zadanie i uruchomi je w osobnym wątku

await task;

ale jeśli uruchomisz poniższe, sprawdzi stan task, więc uruchomi go w jednym wątku

await Task.WhenAll(task);

ale jeśli przeniesiesz tworzenie zadania obok WhenAllniego, uruchomisz każde zadanie w osobnym wątku.

var task = Task.Delay(100);
await Task.WhenAll(task);
Seyedraouf Modarresi
źródło
Dzięki Seyedraouf za odpowiedź. Twoje wyjaśnienie nie wydaje mi się jednak zbyt satysfakcjonujące. Zadanie zwrócone przez Task.WhenAlljest po prostu normalne Task, jak oryginał task. Oba zadania kończą się w pewnym momencie, oryginał w wyniku zdarzenia czasowego, a złożony w wyniku zakończenia pierwotnego zadania. Dlaczego ich kontynuacje powinny wykazywać inne zachowanie? W jakim aspekcie jedno zadanie różni się od drugiego?
Theodor Zoulias