Warum werden die Fortsetzungen von Task.WhenAll synchron ausgeführt?

14

Ich habe gerade eine merkwürdige Beobachtung bezüglich der Task.WhenAllMethode gemacht, als ich unter .NET Core 3.0 lief. Ich habe eine einfache Task.DelayAufgabe als einzelnes Argument übergeben Task.WhenAllund erwartet, dass sich die umschlossene Aufgabe identisch mit der ursprünglichen Aufgabe verhält. Dies ist jedoch nicht der Fall. Die Fortsetzungen der ursprünglichen Aufgabe werden asynchron ausgeführt (was wünschenswert ist), und die Fortsetzungen mehrerer Task.WhenAll(task)Wrapper werden nacheinander synchron ausgeführt (was unerwünscht ist).

Hier ist eine Demo dieses Verhaltens. Vier Worker-Aufgaben warten darauf, dass dieselbe Task.DelayAufgabe erledigt wird, und fahren dann mit einer umfangreichen Berechnung fort (simuliert durch 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);

Hier ist die Ausgabe. Die vier Fortsetzungen werden erwartungsgemäß in verschiedenen Threads (parallel) ausgeführt.

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

Wenn ich nun die Zeile kommentiere await taskund die folgende Zeile auskommentiere await Task.WhenAll(task), ist die Ausgabe ganz anders. Alle Fortsetzungen werden im selben Thread ausgeführt, sodass die Berechnungen nicht parallelisiert werden. Jede Berechnung beginnt nach Abschluss der vorherigen:

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

Überraschenderweise geschieht dies nur, wenn jeder Mitarbeiter auf einen anderen Wrapper wartet. Wenn ich den Wrapper im Voraus definiere:

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

... und dann awaitdie gleiche Aufgabe in allen Arbeitern, das Verhalten ist identisch mit dem ersten Fall (asynchrone Fortsetzungen).

Meine Frage ist: Warum passiert das? Was bewirkt, dass die Fortsetzungen verschiedener Wrapper derselben Aufgabe synchron im selben Thread ausgeführt werden?

Hinweis: Das Umschließen einer Aufgabe mit Task.WhenAnystatt Task.WhenAllführt zu demselben seltsamen Verhalten.

Eine weitere Beobachtung: Ich hatte erwartet, dass das Umschließen des Wrappers in a Task.Rundie Fortsetzungen asynchron machen würde. Aber es passiert nicht. Die Fortsetzungen der folgenden Zeile werden weiterhin im selben Thread ausgeführt (synchron).

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

Erläuterung: Die oben genannten Unterschiede wurden in einer Konsolenanwendung beobachtet, die auf der .NET Core 3.0-Plattform ausgeführt wird. In .NET Framework 4.8 gibt es keinen Unterschied zwischen dem Warten auf die ursprüngliche Aufgabe oder dem Task-Wrapper. In beiden Fällen werden die Fortsetzungen synchron im selben Thread ausgeführt.

Theodor Zoulias
quelle
nur neugierig, was passiert wenn await Task.WhenAll(new[] { task });?
vasily.sib
1
Ich denke, es liegt am Kurzschluss im InnerenTask.WhenAll
Michael Randall
3
LinqPad liefert für beide Varianten die gleiche erwartete zweite Ausgabe ... Welche Umgebung verwenden Sie, um parallele Läufe zu erhalten (Konsole vs. WinForms vs. ..., .NET vs. Core, ..., Framework-Version)?
Alexei Levenkov
1
Ich konnte dieses Verhalten unter .NET Core 3.0 und 3.1 duplizieren, jedoch erst, nachdem ich die Initiale Task.Delayvon 100auf 1000geändert hatte, damit sie beim awaitBearbeiten nicht abgeschlossen wird .
Stephen Cleary
2
@BlueStrat schöner Fund! Es könnte sicherlich irgendwie verwandt sein. Interessanterweise konnte ich das fehlerhafte Verhalten von Microsoft- Code in den .NET Frameworks 4.6, 4.6.1, 4.7.1, 4.7.2 und 4.8 nicht reproduzieren. Ich erhalte jedes Mal andere Thread-IDs, was das richtige Verhalten ist. Hier ist eine Geige, die am 4.7.2 läuft.
Theodor Zoulias

Antworten:

2

Sie haben also mehrere asynchrone Methoden, die auf dieselbe Taskvariable warten.

    await task;
    // CPU heavy operation

Ja, diese Fortsetzungen werden nach Abschluss in Reihe aufgerufen task. In Ihrem Beispiel belastet jede Fortsetzung den Thread für die nächste Sekunde.

Wenn Sie möchten, dass jede Fortsetzung asynchron ausgeführt wird, benötigen Sie möglicherweise Folgendes:

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

Damit Ihre Aufgaben von der anfänglichen Fortsetzung zurückkehren und die CPU-Last außerhalb von ausgeführt werden kann SynchronizationContext.

Jeremy Lakeman
quelle
Danke Jeremy für die Antwort. Ja, Task.Yieldist eine gute Lösung für mein Problem. Meine Frage ist jedoch mehr darüber, warum dies geschieht, und weniger darüber, wie das gewünschte Verhalten erzwungen werden kann.
Theodor Zoulias
Wenn Sie es wirklich wissen möchten, finden Sie hier den Quellcode. github.com/microsoft/referencesource/blob/master/mscorlib/…
Jeremy Lakeman
Ich wünschte, es wäre so einfach, die Antwort auf meine Frage zu erhalten, indem ich den Quellcode der verwandten Klassen studiere. Ich würde ewig brauchen, um den Code zu verstehen und herauszufinden, was los ist!
Theodor Zoulias
Der Schlüssel besteht darin, das zu vermeiden. Ein einmaliger SynchronizationContextAufruf ConfigureAwait(false)der ursprünglichen Aufgabe kann ausreichend sein.
Jeremy Lakeman
Dies ist eine Konsolenanwendung, und die SynchronizationContext.Currentist null. Aber ich habe es nur überprüft, um sicherzugehen. Ich fügte ConfigureAwait(false)in der awaitZeile hinzu und es machte keinen Unterschied. Die Beobachtungen sind die gleichen wie zuvor.
Theodor Zoulias
1

Wenn eine Aufgabe mit erstellt wird Task.Delay(), werden ihre Erstellungsoptionen auf Noneanstatt gesetzt RunContinuationsAsychronously.

Dies könnte den Wechsel zwischen dem .net-Framework und dem .net-Kern unterbrechen. Unabhängig davon scheint es das Verhalten zu erklären, das Sie beobachten. Sie können dies auch aus Graben in den Quellcode überprüfen , die Task.Delay()sich Newing auf eine , DelayPromisedie ruft die Standard - TaskKonstruktor so dass keine Erstellungsoptionen festgelegt.

Tanveer Badar
quelle
Danke Tanveer für die Antwort. Sie spekulieren also, dass in .NET Core das beim Erstellen eines neuen Objekts RunContinuationsAsychronouslyzum Standard geworden ist ? Dies würde einige meiner Beobachtungen erklären, aber nicht alle. Insbesondere würde es nicht den Unterschied zwischen dem Warten auf denselben Wrapper und dem Warten auf verschiedene Wrapper erklären . NoneTaskTask.WhenAll
Theodor Zoulias
0

In Ihrem Code befindet sich der folgende Code außerhalb des wiederkehrenden Körpers.

var task = Task.Delay(100);

Jedes Mal, wenn Sie Folgendes ausführen, wartet es auf die Aufgabe und führt sie in einem separaten Thread aus

await task;

Wenn Sie jedoch Folgendes ausführen, wird der Status von überprüft task, sodass er in einem Thread ausgeführt wird

await Task.WhenAll(task);

Wenn Sie jedoch die Aufgabenerstellung daneben verschieben WhenAll, wird jede Aufgabe in einem separaten Thread ausgeführt.

var task = Task.Delay(100);
await Task.WhenAll(task);
Seyedraouf Modarresi
quelle
Danke Seyedraouf für die Antwort. Ihre Erklärung klingt für mich jedoch nicht allzu zufriedenstellend. Die von zurückgegebene Aufgabe Task.WhenAllist nur eine reguläre Task, wie das Original task. Beide Aufgaben werden irgendwann abgeschlossen, das Original als Ergebnis eines Timer-Ereignisses und das Composite als Ergebnis des Abschlusses der ursprünglichen Aufgabe. Warum sollten ihre Fortsetzungen ein anderes Verhalten zeigen? In welchem ​​Aspekt unterscheidet sich die eine Aufgabe von der anderen?
Theodor Zoulias