Menu

C# Task: Task.Run, WhenAll, WhenAny, Abbruch und Threads

Task ist die Einheit für asynchrone und parallele Arbeit in .NET. Lerne Task.Run für CPU-lastige Arbeit, Ergebnisse lesen, Tasks mit WhenAll und WhenAny kombinieren, Fortsetzungen, Abbruch mit CancellationToken, wie sich ein Task von einem Thread unterscheidet und wann Parallel.For besser passt.

Diese Seite enthält ausführbare Editoren - bearbeiten, ausführen und Ausgabe sofort sehen.

Ein Task ist ein Objekt, das für laufende Arbeit steht. Sie läuft vielleicht auf einem Thread des Thread-Pools, wartet auf einen Timer oder auf eine Netzwerkantwort; in jedem Fall kannst du fragen, ob sie fertig ist, ihr Ergebnis holen, auf sie warten oder weitere Arbeit an sie anhängen. async und await sind die Sprachsyntax über Tasks. Diese Seite behandelt die Task-API selbst und Threads.

Task.Run: Arbeit im Thread-Pool

Task.Run nimmt ein Lambda und reiht es in den Thread-Pool ein, eine Menge von Threads, die .NET vorhält, damit nicht für jeden Auftrag ein neuer erzeugt wird. Es gibt einen Task zurück oder einen Task<T>, wenn das Lambda einen Wert zurückgibt.

Ausgabe:

Started, completed yet: False
Primes up to 200,000: 17984
Status: RanToCompletion

Result blockiert den aufrufenden Thread, bis der Task fertig ist. Im Main eines Konsolenprogramms ist das akzeptabel, aber in async-Code würdest du int primes = await task; schreiben, das wartet, ohne zu blockieren. Dasselbe gilt für task.Wait() bei einem Task ohne Ergebnis.

Task.Run ist für CPU-lastige Arbeit: Berechnungen, die einen Kern beschäftigen. Für I/O, etwa das Lesen einer Datei oder den Aufruf einer Web-API, nimm die async-Methode, die die Bibliothek bereits bietet (File.ReadAllTextAsync, HttpClient.GetStringAsync), und warte sie direkt mit await ab. Solange die I/O läuft, hält ein abgewarteter async-Aufruf gar keinen Thread fest; sie in Task.Run zu verpacken bringt also nichts und fügt nur einen Umweg über den Thread-Pool hinzu. Einen Pool-Thread blockiert dagegen Task.Run um einen synchronen, blockierenden Aufruf wie File.ReadAllText: Dieser Thread wartet untätig, bis Festplatte oder Netzwerk antworten.

Mehrere Tasks gleichzeitig: WhenAll

Teile unabhängige Arbeit in Tasks auf, starte sie alle und kombiniere die Ergebnisse mit Task.WhenAll. Auf einem Rechner mit mehreren Kernen laufen die Teile parallel.

Ausgabe:

Parts: 14999995, 14999999, 15000003, 15000000
Total: 59999997

Jeder Teil arbeitet auf seinem eigenen Bereich und gibt seine eigene Summe zurück, die Tasks teilen also nichts und brauchen kein Locking. WhenAll gibt die Ergebnisse in der Reihenfolge der Tasks zurück, egal wie die Threads zufällig fertig wurden. Wenn Tasks gemeinsamen Zustand aktualisieren müssen, siehe lock.

WhenAny und Timeouts

Task.WhenAny ist fertig, wenn der erste seiner Tasks fertig ist, und gibt diesen Task zurück. Die eigentliche Arbeit gegen Task.Delay antreten zu lassen ergibt einen Timeout:

Ausgabe:

fast mirror answered after 100 ms
Timed out after 300 ms

WhenAny gibt den gewinnenden Task selbst zurück, nicht sein Ergebnis, du wartest ihn also erneut ab, um den Wert zu bekommen (oder die Exception, wenn er gescheitert ist). Beachte, dass die verlierenden Tasks weiterlaufen; WhenAny stoppt sie nicht. Um die langsame Arbeit wirklich zu stoppen, brich sie wie unten gezeigt mit einem Token ab. Ab .NET 6 drückt await work.WaitAsync(TimeSpan.FromMilliseconds(300)) denselben Timeout in einem Aufruf aus.

Fortsetzungen mit ContinueWith

Vor await bestand der Weg, Code nach einem fertigen Task auszuführen, in ContinueWith. In älterem Code begegnet es dir noch:

Ausgabe:

Report total: 42

Jede Fortsetzung bekommt den vorherigen Task und läuft, wenn er fertig ist. await tut dasselbe mit viel weniger Aufwand, behandelt Exceptions natürlich und kehrt in den richtigen Kontext zurück, nimm es also in neuem Code. ContinueWith läuft auch, wenn der vorherige Task gescheitert ist oder abgebrochen wurde, älterer Code muss also t.IsFaulted prüfen oder TaskContinuationOptions übergeben, um nicht .Result auf einem gescheiterten Task aufzurufen.

Abbruch mit CancellationToken

Tasks werden kooperativ abgebrochen. Eine CancellationTokenSource gibt einen Token aus; der Code, der die Arbeit erledigt, prüft den Token und stoppt sich selbst. Nichts wird gewaltsam abgebrochen.

Ausgabe:

Cancelled before finishing all 10 orders
Processed 3

Der Token wird durch jede Schicht nach unten gereicht, und async-APIs wie Task.Delay, HttpClient und Datenbankaufrufe akzeptieren ihn ebenfalls, ein Abbruch stoppt sie also mitten im Warten. Fang OperationCanceledException ab; die TaskCanceledException, die manche APIs werfen, leitet davon ab. CancellationTokenSource ist IDisposable, daher das using.

Thread gegenüber Task

Ein Thread ist ein Thread des Betriebssystems, den du selbst erzeugst. Er ist schwerer: Jeder hat seinen eigenen Stack (unter Windows standardmäßig 1 MB), das Starten dauert messbar, und er gibt dir weder Ergebnis noch Exception-Behandlung.

Ausgabe:

Thread started
Result: 42
ThreadTask
Läuft aufEinem neuen, eigenen OS-ThreadEinem Pool-Thread (oder keinem Thread bei I/O)
Kosten beim StartHochNiedrig
ErgebniswertNein, teile eine VariableTask<T>.Result / await
ExceptionsUnbehandelte lassen den Prozess abstürzenIm Task gespeichert, bei await erneut geworfen
AbbruchManuelle FlagsCancellationToken eingebaut
Darauf wartenJoin()await, Wait(), WhenAll

Greif nur zu Thread, wenn du etwas brauchst, das der Pool nicht bietet: eine lang laufende Schleife, die nie einen Pool-Thread ausleihen soll, eine bestimmte Priorität oder ein Single-Threaded Apartment für COM. Für lang laufende Tasks bekommt Task.Factory.StartNew(work, TaskCreationOptions.LongRunning) einen eigenen Thread und behält die Task-API.

Parallel.For und Parallel.ForEach

Für „mach das mit jedem Element und nutze alle Kerne“ ist System.Threading.Tasks.Parallel einfacher, als Tasks von Hand zu erzeugen. Es teilt den Bereich auf Threads auf und kehrt zurück, wenn alle Durchläufe fertig sind:

Beispielausgabe:

image 4 on thread 7
image 3 on thread 6
image 5 on thread 8
image 0 on thread 1
image 1 on thread 4
image 2 on thread 5
Total pixels: 385425

Die Zeilen pro Bild erscheinen in der Reihenfolge, in der die Threads sie ausgeführt haben, und die Thread-IDs variieren von Lauf zu Lauf; nur die Summe steht fest. Das liegt in der Natur paralleler Schleifen: Durchläufe dürfen nicht von der Reihenfolge der anderen abhängen, und jeder gemeinsame Zustand braucht Synchronisierung (hier Interlocked.Add). Parallel.ForEach tut dasselbe über jede Collection, und .NET 6 fügt Parallel.ForEachAsync für async-Rümpfe hinzu.

Häufige Fehler

  • .Result oder .Wait() in async-Code. Blockiert einen Thread und kann in UI-Apps Deadlocks verursachen; nimm stattdessen await.
  • Den ursprünglichen Exception-Typ um .Result herum abfangen. Fehlschläge kommen in eine AggregateException verpackt an; await packt sie aus.
  • Fire and Forget. Task.Run(...) ohne Abwarten oder Speichern des Tasks verliert seine Exceptions.
  • Task.Run um I/O. Um eine async-Methode fügt es nur einen Umweg über den Thread-Pool hinzu; um eine blockierende Methode wie File.ReadAllText belegt es einen Pool-Thread für die gesamte Wartezeit. Warte die async-I/O-Methode direkt ab.
  • Den Token ignorieren. Einen CancellationToken zu übergeben bewirkt nichts, wenn die Arbeit ihn nicht prüft.
  • Nicht synchronisierter gemeinsamer Zustand in parallelem Code. Nimm Interlocked, lock oder gib jedem Task seine eigenen Daten.

Häufig gestellte Fragen

Was ist ein Task in C#?

Ein Task steht für eine Operation, die in der Zukunft fertig werden kann: Code, der im Thread-Pool läuft, ein Timer, eine I/O-Anfrage. Er lässt sich abwarten, meldet, ob er fertig wurde, gescheitert ist oder abgebrochen wurde, und Task<T> trägt zusätzlich ein Ergebnis. Tasks sind der Baustein unter async/await.

Was ist der Unterschied zwischen einem Task und einem Thread in C#?

Ein Thread ist ein Thread des Betriebssystems, den du selbst erzeugst und verwaltest, mit eigenem Stack; er ist teuer zu starten und gibt keinen Wert zurück. Ein Task beschreibt Arbeit, und Task.Run plant sie in einem gemeinsamen Pool wiederverwendbarer Threads ein; er kann ein Ergebnis zurückgeben, Exceptions weiterreichen, abgebrochen und abgewartet werden. Nimm Tasks, außer du brauchst einen eigenen langlebigen Thread mit speziellen Einstellungen.

Wann sollte ich Task.Run verwenden?

Für CPU-lastige Arbeit, die vom aktuellen Thread herunter soll: Bildverarbeitung, eine große Berechnung, eine große Datei im Speicher parsen, besonders um eine UI reaktionsfähig zu halten. Verpacke I/O nicht in Task.Run: async-I/O-Methoden wie ReadAllTextAsync geben den Thread während des Wartens bereits frei.

Wie breche ich in C# einen Task ab?

Erzeuge eine CancellationTokenSource, übergib ihren Token an die Arbeit und rufe Cancel() (oder CancelAfter(timeout)) auf der Quelle auf. Die Arbeit muss mitspielen: token.IsCancellationRequested prüfen oder token.ThrowIfCancellationRequested() in ihrer Schleife aufrufen und den Token an async-APIs wie Task.Delay weiterreichen. Einen abgebrochenen Task abzuwarten wirft OperationCanceledException.

Was ist der Unterschied zwischen Task.WhenAll und Task.WhenAny?

Task.WhenAll ist fertig, wenn jeder Task fertig ist, und gibt dir alle Ergebnisse. Task.WhenAny ist fertig, sobald der erste Task fertig ist, und gibt diesen Task zurück. WhenAny ist der übliche Weg, einen Timeout hinzuzufügen: Lass die Arbeit gegen Task.Delay antreten.

Warum wirft Task.Result eine AggregateException?

.Result und .Wait() verpacken jeden Fehlschlag in eine AggregateException, weil ein Task im Allgemeinen mehrere Exceptions enthalten kann. await packt sie aus und wirft die erste innere Exception direkt erneut, ein Grund, await zu bevorzugen. Wenn du blockieren musst, wirft auch .GetAwaiter().GetResult() die ursprüngliche Exception.

Coddy programming languages illustration

Lerne mit Coddy zu programmieren

LOS GEHT'S