Ich veröffentliche hier auf Habr das Original des Artikels, dessen Übersetzung in der Unternehmens- .
Die Notwendigkeit, etwas asynchron zu tun, ohne auf sofortige Ergebnisse zu warten, oder eine große Aufgabe auf mehrere ausführende Einheiten zu verteilen, bestand bereits vor der Einführung von Computern. Mit ihrem Aufkommen wurde dieses Bedürfnis sehr spürbar. Jetzt, im Jahr 2019, während ich diesen Artikel auf einem Laptop mit einem 8-Kern-Intel-Core-Prozessor schreibe, auf dem gleichzeitig Hunderte von Prozessen und noch mehr Threads laufen. Nebenbei liegt ein schon etwas ramponiertes Handy, das ich vor ein paar Jahren gekauft habe, mit einem 8-Kern-Prozessor. Auf thematischen Ressourcen gibt es viele Artikel und Videos, in denen die Autoren von den Flaggschiff-Smartphones dieses Jahres schwärmen, die 16-Kern-Prozessoren verwenden. MS Azure bietet eine virtuelle Maschine mit einem 128-Kern-Prozessor und 2 TB RAM für weniger als 20 $/Stunde an. Leider ist es unmöglich, das Maximum herauszuholen und diese Leistung zu zähmen, ohne die Interaktion der Threads zu steuern.
Terminologie
Prozess (Process) — ein OS-Objekt, isolierter Adressraum, enthält Threads.
Thread (Thread) — ein OS-Objekt, die kleinste Ausführungseinheit, Teil eines Prozesses, Threads teilen sich den Speicher und andere Ressourcen innerhalb des Prozesses.
Multitasking — Eigenschaft des OS, mehrere Prozesse gleichzeitig auszuführen
Multikernigkeit — Eigenschaft des Prozessors, mehrere Kerne zur Datenverarbeitung zu nutzen
Multiprozessorität — Eigenschaft des Computers, physisch mit mehreren Prozessoren gleichzeitig zu arbeiten
Multithreading — Eigenschaft des Prozesses, die Verarbeitung von Daten auf mehrere Threads zu verteilen.
Parallelität — physische gleichzeitige Ausführung mehrerer Aktionen in einem Zeitintervall
Asynchronität — Ausführung einer Operation ohne Warten auf den Abschluss dieser Verarbeitung, das Ergebnis kann jedoch später verarbeitet werden.
Metapher
Nicht alle Definitionen sind gut, einige benötigen zusätzliche Erklärungen, deshalb füge ich zur formal eingeführten Terminologie eine Metapher über die Zubereitung des Frühstücks hinzu. Die Zubereitung des Frühstücks in dieser Metapher — process.
Beim Zubereiten des Frühstücks am Morgen gehe ich (CPU) in die Küche (Computer). Ich habe 2 Hände (Kerne). In der Küche gibt es eine Reihe von Geräten (H, High): Herd, Wasserkocher, Toaster, Kühlschrank. Ich drehe das Gas auf, stelle die Pfanne darauf und gieße Öl hinein, ohne auf das Erhitzen zu warten (asynchron, Non-Blocking-IO-Wait), ich hole die Eier aus dem Kühlschrank und schlage sie in eine Schüssel, danach schlage ich sie mit einer Hand (Thread#1), während ich mit der anderen (Thread#2) die Schüssel halte (Geteilte Ressource). Ich würde jetzt gern den Wasserkocher einschalten, aber ich habe nicht genug Hände (Thread Starvation) In der Zwischenzeit erhitzt sich die Pfanne (Ergebnisverarbeitung), in die ich das Geschlagene gieße. Ich erreiche den Wasserkocher und schalte ihn ein und schaue stumpf zu, wie das Wasser darin kocht (Blocking-IO-Wait), obwohl ich in der Zeit die Schüssel, in der ich das Omelett geschlagen habe, hätte abwaschen können.
Ich habe ein Omelett mit nur 2 Händen zubereitet, mehr habe ich nicht, aber während ich das Omelett geschlagen habe, fanden gleichzeitig 3 Operationen statt: das Schlagen des Omeletts, das Halten der Schüssel, das Erhitzen der Pfanne. Die CPU ist der schnellste Teil des Computers, IO hingegen ist das, was am häufigsten bremst. Deshalb ist es oft eine effektive Lösung, die CPU zu beschäftigen, während Daten von IO empfangen werden.
Fortsetzend mit der Metapher:
- Wenn ich während des Kochens des Omeletts auch noch versuchen würde, mich umzuziehen, wäre das ein Beispiel für Multitasking. Ein wichtiger Punkt: Computer machen das viel besser als Menschen.
- Eine Küche mit mehreren Köchen, wie in einem Restaurant – ein Multikern-Computer.
- Viele Restaurants in einem Foodcourt eines Einkaufszentrums – ein Rechenzentrum.
Tools .NET
In der Arbeit mit Threads ist .NET, wie in vielen anderen Bereichen, gut. Mit jeder neuen Version werden immer mehr neue Werkzeuge für die Arbeit mit ihnen bereitgestellt, neue Abstraktionsschichten über den Betriebssystem-Threads. Bei der Arbeit an der Erstellung von Abstraktionen verwenden die Entwickler des Frameworks einen Ansatz, der es ermöglicht, bei Verwendung einer hochgradigen Abstraktion auf eine oder mehrere Ebenen darunter herunterzugehen. Meistens ist das nicht notwendig, mehr noch, es eröffnet die Möglichkeit, sich mit einer Schrotflinte in den Fuß zu schießen, aber manchmal, in seltenen Fällen, kann es die einzige Möglichkeit sein, ein Problem zu lösen, das auf der aktuellen Abstraktionsebene nicht gelöst werden kann.
Mit Werkzeugen meine ich sowohl die Programmierschnittstellen (APIs), die vom Framework und von Drittanbieter-Paketen bereitgestellt werden, als auch komplette Softwarelösungen, die das Finden von Problemen im Zusammenhang mit mehrthreadigem Code vereinfachen.
Thread starten
Die Klasse Thread ist die grundlegendste in .NET für die Arbeit mit Threads. Der Konstruktor akzeptiert einen der beiden Delegaten:
- ThreadStart — ohne Parameter
- ParametrizedThreadStart — mit einem Parameter vom Typ object.
Der Delegat wird in dem neu erstellten Thread nach dem Aufruf der Methode Start ausgeführt. Wenn im Konstruktor ein Delegat vom Typ ParametrizedThreadStart übergeben wurde, muss beim Aufruf von Start ein Objekt übergeben werden. Dieser Mechanismus dient dazu, lokale Informationen an den Thread zu übergeben. Es ist zu beachten, dass die Erstellung eines Threads eine kostspielige Operation ist, und der Thread selbst ein schweres Objekt darstellt, mindestens weil 1 MB Speicher für den Stack reserviert wird und die Interaktion mit der API des Betriebssystems erfordert.
new Thread(...).Start(...);
Die Klasse ThreadPool repräsentiert das Konzept eines Pools. Im .NET ist der Threadpool ein Meisterwerk der Ingenieurskunst, und die Entwickler von Microsoft haben viel Aufwand betrieben, um sicherzustellen, dass er in den unterschiedlichsten Szenarien optimal funktioniert.
Allgemeines Konzept:
Seit dem Start erstellt die Anwendung im Hintergrund mehrere Threads als Reserve und ermöglicht es, diese zu nutzen. Wenn Threads oft und in großer Zahl verwendet werden, weitet sich der Pool aus, um den Anforderungen des aufrufenden Codes gerecht zu werden. Wenn zu einem bestimmten Zeitpunkt im Pool keine freien Threads vorhanden sind, wartet er entweder auf die Rückkehr eines Threads oder erstellt einen neuen. Daraus folgt, dass der Threadpool hervorragend für kurze Aufgaben geeignet ist und weniger geeignet ist für Prozesse, die während der gesamten Laufzeit der Anwendung als Dienste arbeiten.
Um einen Thread aus dem Pool zu verwenden, gibt es die Methode QueueUserWorkItem, die einen Delegaten vom Typ WaitCallback akzeptiert, der in der Signatur mit ParametrizedThreadStart übereinstimmt, und der übergebene Parameter erfüllt die gleiche Funktion.
ThreadPool.QueueUserWorkItem(...);
Weniger bekannte Methode des Threadpools RegisterWaitForSingleObject dient der Organisation nicht-blockierender I/O-Operationen. Der Delegat, der an diese Methode übergeben wird, wird aufgerufen, wenn das WaitHandle, das der Methode übergeben wurde, „freigegeben“ (Released) wird.
ThreadPool.RegisterWaitForSingleObject(...)
In .NET gibt es einen threading Timer, der sich von den Timern in WinForms/WPF unterscheidet, da sein Handler in einem aus dem Pool entnommenen Thread aufgerufen wird.
System.Threading.Timer
Es gibt auch eine recht exotische Methode, um einen Delegaten zur Ausführung in einen Pool-Thread zu senden — die Methode BeginInvoke.
DelegateInstance.BeginInvoke
Ich möchte kurz auf die Funktion eingehen, zu deren Aufruf viele der oben genannten Methoden führen – CreateThread aus Kernel32.dll der Win32 API. Es gibt einen Weg, diese Funktion durch den Mechanismus externen Methoden aufzurufen. Ich habe einen solchen Aufruf nur einmal in einem erschreckenden Beispiel von Legacy-Code gesehen, und die Motivation des Autors, das auf diese Weise zu tun, bleibt mir ein Rätsel.
Kernel32.dll CreateThread
Betrachten und Debuggen von Threads
Die von Ihnen persönlich, von Drittanbieter-Komponenten und vom .NET-Pool erstellten Threads können im Fenster 'Threads' von Visual Studio angezeigt werden. Dieses Fenster zeigt Informationen zu Threads nur an, wenn die Anwendung im Debugging-Modus und im Break-Modus ist. Hier können Sie bequem den Stack, die Namen und die Prioritäten jedes Threads einsehen und das Debugging auf einen bestimmten Thread umschalten. Mit der Eigenschaft Priority der Klasse Thread können Sie die Priorität eines Threads festlegen, die vom Betriebssystem und vom CLR als Empfehlung beim Teilen der Prozessorzeit zwischen Threads wahrgenommen wird.

Task Parallel Library
Die Task Parallel Library (TPL) wurde in .NET 4.0 eingeführt. Heute ist sie der Standard und das Hauptwerkzeug für die Arbeit mit Asynchronität. Jeder Code, der ältere Ansätze verwendet, gilt als Legacy. Die Haupteinheit der TPL ist die Klasse Task aus dem Namensraum System.Threading.Tasks. Task stellt eine Abstraktion über den Thread dar. Mit der neuen Version der Sprache C# haben wir eine elegante Möglichkeit erhalten, mit Tasks zu arbeiten – die async/await-Operatoren. Diese Konzepte ermöglichten es, asynchronen Code so zu schreiben, als wäre er einfach und synchron, was auch Personen, die wenig über die internale Handhabung von Threads verstehen, erlaubte, Anwendungen zu schreiben, die während langwieriger Operationen nicht hängen bleiben. Die Verwendung von async/await ist ein Thema für einen oder sogar mehrere Artikel, aber ich werde versuchen, die Essenz in wenigen Sätzen zusammenzufassen:
- async ist ein Modifikator für Methoden, die Task oder void zurückgeben
- await ist der Operator für blockierendes Warten auf einen Task.
Einmal mehr: Der await-Operator lässt, allgemeine Aussage (es gibt Ausnahmen), den aktuellen Ausführungsthread weiterlaufen, und sobald die Task abgeschlossen ist, wird der Thread (korrekter wäre es, Kontext zu sagen, aber dazu später mehr) wieder frei und führt die Methode weiter aus. Innerhalb von .NET ist dieser Mechanismus ähnlich wie yield return implementiert, wenn die geschriebene Methode in eine gesamte Klasse umgewandelt wird, die eine Zustandsmaschine darstellt und abhängig von diesen Zuständen in einzelnen Abschnitten ausgeführt werden kann. Wer interessiert ist, kann beliebigen einfachen Code mit asyn/await schreiben, kompilieren und die Assembly mit JetBrains dotPeek einschließlich des Compiler Generated Code ansehen.
Betrachten wir die Optionen zum Starten und Verwenden einer Task. Anhand des folgenden Codes erstellen wir eine neue Task, die nichts Nützliches tut (Thread.Sleep(10000)), aber im echten Leben sollte dies eine anspruchsvolle, CPU-intensiv arbeitende Aufgabe sein.
using TCO = System.Threading.Tasks.TaskCreationOptions;
public static async void VoidAsyncMethod() {
var cancellationSource = new CancellationTokenSource();
await Task.Factory.StartNew(
// Der Aktionscode wird im anderen Kontext ausgeführt
() => Thread.Sleep(10000),
cancellationSource.Token,
TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
scheduler
);
// Der Code nach await wird im erfassten Kontext ausgeführt
}
Die Task wird mit einer Reihe von Optionen erstellt:
- LongRunning — ein Hinweis darauf, dass die Aufgabe nicht schnell abgeschlossen wird und es möglicherweise besser wäre, keinen Thread aus dem Pool zu nehmen, sondern einen separaten für diese Task zu erstellen, um die anderen nicht zu beeinträchtigen.
- AttachedToParent — Tasks können in Hierarchien organisiert werden. Wenn diese Option verwendet wurde, kann sich die Task in einem Zustand befinden, in dem sie selbst abgeschlossen ist und auf die Ausführung ihrer Nachkommen wartet.
- PreferFairness — bedeutet, dass es gut wäre, vorher gestartete Tasks vor später gestarteten auszuführen. Aber das ist lediglich eine Empfehlung, und das Ergebnis ist nicht garantiert.
Der zweite Parameter in der Methode ist der CancellationToken. Für eine korrekte Behandlung der Abbruchoperation nach ihrem Start muss der ausgeführte Code mit Statusüberprüfungen des CancellationToken angereichert sein. Fehlen diese Überprüfungen, kann die Methode Cancel, die auf dem CancellationTokenSource-Objekt aufgerufen wird, die Ausführung der Task nur bis zu ihrem Start stoppen.
Der letzte Parameter ist ein Objekt vom Typ TaskScheduler. Diese Klasse und ihre Abkömmlinge sind dafür gedacht, die Strategien zur Verteilung von Tasks auf Threads zu verwalten; standardmäßig wird der Task auf einem zufälligen Thread aus dem Pool ausgeführt.
Dem erstellten Task wurde der Await-Operator zugewiesen, was bedeutet, dass der nachfolgende Code, sofern vorhanden, im selben Kontext (was oft bedeutet, dass er im selben Thread ausgeführt wird) wie der Code vor Await ausgeführt wird.
Die Methode ist als async void gekennzeichnet, was bedeutet, dass die Verwendung des Await-Operators darin erlaubt ist, aber der aufrufende Code die Ausführung nicht abwarten kann. Wenn diese Möglichkeit erforderlich ist, muss die Methode einen Task zurückgeben. Async void-Methoden sind ziemlich häufig: In der Regel handelt es sich um Ereignishandler oder andere Methoden, die nach dem Prinzip „ausführen und vergessen“ (fire and forget) arbeiten. Wenn man nicht nur das Warten auf die Ausführung ermöglichen, sondern auch ein Ergebnis zurückgeben möchte, sollte man Task verwenden.
Auf dem Task, den die Methode StartNew zurückgegeben hat, kann man, wie auch bei jedem anderen, die Methode ConfigureAwait mit dem Parameter false aufrufen. Dann wird die Ausführung nach Await nicht im gefangenem Kontext, sondern in einem beliebigen fortgesetzt. Dies sollte immer getan werden, wenn der Kontext der Ausführung für den Code nach Await nicht entscheidend ist. Dies ist auch eine Empfehlung von MS beim Schreiben von Code, der in Form einer Bibliothek bereitgestellt wird.
Lassen Sie uns noch einen Moment innehalten und betrachten, wie man das Ende der Task-Ausführung abwarten kann. Im Folgenden ein Beispielcode mit Kommentaren, wann das Warten bedingt gut und wann bedingt schlecht gemacht wurde.
public static async void AnotherMethod() {
int result = await AsyncMethod(); // gut
result = AsyncMethod().Result; // schlecht
AsyncMethod().Wait(); // schlecht
IEnumerable tasks = new Task[] {
AsyncMethod(), OtherAsyncMethod()
};
await Task.WhenAll(tasks); // gut
await Task.WhenAny(tasks); // gut
Task.WaitAll(tasks.ToArray()); // schlecht
}
Im ersten Beispiel warten wir auf die Ausführung des Tasks, ohne den aufrufenden Thread zu blockieren; die Verarbeitung des Ergebnisses erfolgt erst, wenn es verfügbar ist, bis dahin ist der aufrufende Thread sich selbst überlassen.
Im zweiten Fall blockieren wir den aufrufenden Thread, bis das Ergebnis der Methode berechnet wurde. Das ist nicht nur schlecht, weil wir einen so wertvollen Programmentsor, den Thread, mit nutzlosem Warten belegen, sondern auch, weil, falls im Code der Methode, die wir aufrufen, ein await vorhanden ist und der Synchronisationskontext eine Rückkehr zum aufrufenden Thread nach dem await voraussetzt, wir ein Deadlock erhalten: Der aufrufende Thread wartet darauf, dass das Ergebnis der asynchronen Methode berechnet wird, während die asynchrone Methode vergeblich versucht, ihre Ausführung im aufrufenden Thread fortzusetzen.
Ein weiterer Nachteil dieses Ansatzes ist die komplizierte Fehlerbehandlung. Das liegt daran, dass Fehler in asynchronem Code bei der Verwendung von async/await sehr einfach zu behandeln sind – sie verhalten sich so, als wäre der Code synchron. Während im Gegensatz dazu, wenn wir das synchrones Warten auf ein Task anwenden, die ursprüngliche Ausnahme in AggregateException eingekapselt wird. Daher muss man den Typ von InnerException untersuchen und selbst eine if-Kette innerhalb eines catch-Blocks schreiben oder die catch when-Konstruktion verwenden, anstelle der in C# üblichen Kette von catch-Blöcken.
Die dritten und letzten Beispiele sind ebenfalls aus dem gleichen Grund schlecht und weisen die gleichen Probleme auf.
Die Methoden WhenAny und WhenAll sind äußerst nützlich, um auf eine Gruppe von Tasks zu warten. Sie wickeln die Gruppe von Tasks in einen ein, der entweder beim ersten Eintreten eines Tasks aus der Gruppe oder wenn alle ihre Ausführung abgeschlossen haben, ausgelöst wird.
Threads anhalten
Aus verschiedenen Gründen kann es notwendig werden, einen Thread nach seinem Start anzuhalten. Dafür gibt es verschiedene Möglichkeiten. Die Klasse Thread hat zwei Methoden mit passenden Namen: das sind Abort und Interrupt. Die erste Methode wird aufgrund ihrer Risiken stark von der Verwendung abgeraten, da nach ihrem Aufruf zu jedem beliebigen Zeitpunkt, während der Bearbeitung einer beliebigen Anweisung, eine Ausnahme ausgelöst wird. ThreadAbortedException. Erwarten Sie etwa, dass eine solche Ausnahme beim Inkrementieren einer ganzzahligen Variablen auftritt? Wenn Sie diese Methode verwenden, kann dies durchaus eine reale Situation sein. Wenn es notwendig ist, zu verhindern, dass CLR in einem bestimmten Codeabschnitt eine solche Ausnahme auslöst, kann man diesen Codebereich in Aufrufe von Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Solche Aufrufe resultieren aus dem Code, der im finally-Block geschrieben ist. Aus diesem Grund finden sich in den Tiefen des Framework-Codes Blöcke mit leerem try, aber nicht leerem finally. Microsoft empfiehlt die Verwendung dieser Methode so stark, dass sie nicht in .net core aufgenommen wurde.
Die Methode Interrupt funktioniert vorhersehbarer. Sie kann einen Thread durch eine Ausnahme unterbrechen ThreadInterruptedException nur in den Momenten, in denen sich der Thread im Wartezustand befindet. Er wechselt in diesen Zustand, wenn er auf ein WaitHandle, Lock wartet oder nach dem Aufruf von Thread.Sleep.
Beide oben beschriebenen Varianten sind schlecht wegen ihrer Unberechenbarkeit. Eine Lösung ist die Verwendung der Struktur CancellationToken und der Klasse CancellationTokenSource. Der Kernpunkt ist folgender: Es wird ein Exemplar der Klasse CancellationTokenSource erstellt und nur derjenige, der es besitzt, kann die Operation anhalten, indem er die Methode Cancel. In die Operation wird nur der CancellationToken übergeben. Die Besitzer des CancellationToken können die Operation nicht selbst abbrechen, sondern nur überprüfen, ob die Operation abgebrochen wurde. Dazu gibt es die boolesche Eigenschaft IsCancellationRequested und die Methode ThrowIfCancelRequested. Letztere wird eine Ausnahme TaskCancelledException generieren, wenn die Methode Cancel auf dem dazugehörigen CancellationToken-Exemplar der CancellationTokenSource aufgerufen wurde. Genau diese Methode empfehle ich zu verwenden. Sie bietet eine bessere Kontrolle darüber, wann die Ausnahme die Operation unterbrechen kann.
Die brutalste Art, einen Thread zu stoppen, ist der Aufruf der Win32 API-Funktion TerminateThread. Das Verhalten von CLR nach dem Aufruf dieser Funktion kann unberechenbar sein. Auf MSDN steht über diese Funktion Folgendes: “TerminateThread ist eine gefährliche Funktion, die nur in den extremsten Fällen verwendet werden sollte.”
Die Umwandlung von Legacy-API in ein Task-basiertes System mittels der Methode FromAsync
Wenn Sie das Glück hatten, an einem Projekt zu arbeiten, das nach der Einführung von Tasks begonnen wurde und den meisten Entwicklern keine stille Angst mehr bereitete, dann müssen Sie sich nicht mit einer Vielzahl von alten APIs auseinandersetzen, sowohl von Drittanbietern als auch von Ihrem Team in der Vergangenheit erstellten. Glücklicherweise hat das .NET Framework-Entwicklungsteam auf uns geachtet, obwohl das Ziel vielleicht war, sich selbst zu schützen. Wie dem auch sei, .NET bietet eine Reihe von Tools für die schmerzfreie Umwandlung von Code, der mit alten Ansätzen der asynchronen Programmierung geschrieben wurde, in neue. Eines davon ist die FromAsync-Methode der TaskFactory. Im folgenden Codebeispiel umhülle ich alte asynchrone Methoden der WebRequest-Klasse in Tasks mithilfe dieser Methode.
object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
wr.BeginGetResponse,
wr.EndGetResponse
);
Das ist nur ein Beispiel und Sie werden wahrscheinlich nicht mit eingebauten Typen verfahren, aber jedes alte Projekt strotzt vor Methoden BeginDoSomething, die IAsyncResult zurückgeben, und Methoden EndDoSomething, die diese akzeptieren.
Die Umwandlung von Legacy-APIs in Task-basierte mit der TaskCompletionSource-Klasse
Ein weiteres wichtiges Werkzeug, das es zu beachten gilt, ist die Klasse TaskCompletionSource. In Bezug auf Funktionen, Zweck und Arbeitsweise kann es an die Methode RegisterWaitForSingleObject der ThreadPool-Klasse erinnern, über die ich zuvor geschrieben habe. Mit dieser Klasse lassen sich alte asynchrone APIs bequem in Tasks einwickeln.
Sie werden sagen, dass ich bereits von der FromAsync-Methode der TaskFactory erwähnt habe, die für diese Zwecke gedacht ist. Hier muss die gesamte Geschichte der Entwicklung asynchroner Modelle in .NET, die Microsoft in den letzten 15 Jahren angeboten hat, in Erinnerung gerufen werden: Vor dem Task-Based Asynchronous Pattern (TAP) gab es das Asynchronous Programming Pattern (APP), das Methoden beinhaltete. BeginDoSomething, das IAsyncResult zurückgibt, und Methoden EndDoSomething, die es akzeptieren, und für die Legacy dieser Jahre ist die FromAsync-Methode dafür hervorragend geeignet. Aber im Laufe der Zeit wurde sie durch das Event Based Asynchronous Pattern (EAP), welches vorsah, dass bei Abschluss einer asynchronen Operation ein Ereignis ausgelöst wird, abgelöst.
TaskCompletionSource eignet sich hervorragend für die Einbettung in Tasks von Legacy-APIs, die um ein ereignisgesteuertes Modell herum aufgebaut sind. Die Funktionsweise ist folgende: Ein Objekt dieser Klasse hat eine öffentliche Eigenschaft vom Typ Task, dessen Zustand über die Methoden SetResult, SetException usw. verwaltet werden kann. An den Stellen, an denen der await-Operator auf diesen Task angewendet wurde, wird er je nach angewendetem TaskCompletionSource-Methode entweder abgeschlossen oder mit einer Ausnahme abgebrochen. Wenn das immer noch unklar ist, sehen wir uns dieses Codebeispiel an, wo eine alte API aus der EAP-Zeit in einen Task mithilfe von TaskCompletionSource verpackt wird: Auslösen des Ereignisses versetzt den Task in den Zustand Completed, und die Methode, die den await-Operator auf diesen Task angewendet hat, wird fortgesetzt und erhält das Objekt. result.
public static Task DoAsync(this SomeApiInstance someApiObj) {
var completionSource = new TaskCompletionSource();
someApiObj.Done +=
result => completionSource.SetResult(result);
someApiObj.Do();
return completionSource.Task;
}
Tipps & Tricks zu TaskCompletionSource
Die Einbettung alter APIs ist nicht alles, was man mit TaskCompletionSource erreichen kann. Die Verwendung dieser Klasse eröffnet interessante Möglichkeiten zur Gestaltung verschiedener APIs auf Basis von Tasks, die keine Threads beanspruchen. Und wie wir wissen, sind Threads eine teure Ressource, und ihre Anzahl ist begrenzt (hauptsächlich durch den RAM). Diese Begrenzung lässt sich leicht erreichen, wenn man beispielsweise eine stark belastete Webanwendung mit komplexer Geschäftslogik entwickelt. Lassen Sie uns die Möglichkeiten betrachten, von denen ich spreche, indem wir eine solche Technik wie Long-Polling umsetzen.
Kurz gesagt, der Punkt des Tricks ist folgender: Sie müssen Informationen über bestimmte Ereignisse, die auf der API-Seite stattfinden, von der API abrufen. Aus irgendeinem Grund kann die API jedoch nicht über ein Ereignis informieren, sondern nur den Zustand zurückgeben. Ein Beispiel dafür sind alle APIs, die auf HTTP basieren, bis zur Einführung von WebSocket oder wenn diese Technologie aus irgendeinem Grund nicht verwendet werden kann. Der Client kann den HTTP-Server fragen. Der HTTP-Server kann jedoch nicht eigenständig die Kommunikation mit dem Client initiieren. Eine einfache Lösung besteht darin, den Server zeitgesteuert abzufragen, doch dies führt zu einer zusätzlichen Belastung des Servers und einer durchschnittlichen Verzögerung von TimerInterval / 2. Um dies zu umgehen, wurde ein Trick namens Long Polling erfunden, der die Antwort vom Server verzögert, bis entweder das Timeout abgelaufen ist oder ein Ereignis eintritt. Wenn ein Ereignis eintritt, wird es verarbeitet, andernfalls wird die Anfrage erneut gesendet.
while(!eventOccures && !timeoutExceeded) {
CheckTimout();
CheckEvent();
Thread.Sleep(1);
}
Diese Lösung erweist sich jedoch als katastrophal, sobald die Anzahl der Clients, die auf ein Ereignis warten, zunimmt, denn jeder dieser wartenden Clients belegt einen gesamten Thread. Zudem ergibt sich eine zusätzliche Verzögerung von 1ms beim Eintreten des Ereignisses, was zwar oft nicht entscheidend ist, aber warum sollte man die Software schlechter machen, als sie sein könnte? Wenn Sie jedoch Thread.Sleep(1) entfernen, belasten Sie einen Kern des Prozessors unnötig zu 100%, indem Sie in einer sinnlosen Schleife herumkreisen. Mit TaskCompletionSource können Sie diesen Code leicht umgestalten und alle oben genannten Probleme lösen:
class LongPollingApi {
private Dictionary<int, TaskCompletionSource> tasks;
public async Task AcceptMessageAsync(int userId, int duration) {
var cs = new TaskCompletionSource();
tasks[userId] = cs;
await Task.WhenAny(Task.Delay(duration), cs.Task);
return cs.Task.IsCompleted ? cs.Task.Result : null;
}
public void SendMessage(int userId, Msg m) {
if (tasks.TryGetValue(userId, out var completionSource))
completionSource.SetResult(m);
}
}
Dieser Code ist nicht produktionsbereit, sondern nur eine Demonstration. Für den Einsatz in realen Szenarien muss mindestens auch die Situation behandelt werden, wenn eine Nachricht zu einem Zeitpunkt eintrifft, an dem sie nicht erwartet wird: In diesem Fall sollte die Methode AcceptMessageAsync einen bereits abgeschlossenen Task zurückgeben. Wenn dies der häufigste Fall ist, könnte man auch über die Verwendung von ValueTask nachdenken.
Beim Empfang einer Anfrage zum Nachrichtenversand erstellen wir ein TaskCompletionSource und fügen es in ein Wörterbuch ein. Danach warten wir darauf, was zuerst passiert: der festgelegte Zeitraum läuft ab oder eine Nachricht wird empfangen.
ValueTask: Warum und wie
Die Operatoren async/await sowie der Operator yield return erzeugen aus einer Methode einen Zustandsautomaten, was die Erstellung eines neuen Objekts zur Folge hat. Dies ist fast immer unwichtig, kann in seltenen Fällen jedoch ein Problem verursachen. Ein solches Beispiel könnte eine Methode sein, die wirklich häufig aufgerufen wird, es geht um Dutzende oder Hunderte von Tausenden Aufrufen pro Sekunde. Wenn eine solche Methode so geschrieben ist, dass sie in den meisten Fällen das Ergebnis zurückgibt, ohne alle await-Methoden zu durchlaufen, bietet .NET ein Werkzeug, um dies zu optimieren — die Struktur ValueTask. Um dies zu veranschaulichen, betrachten wir ein Beispiel für seine Verwendung: Es gibt einen Cache, auf den wir sehr häufig zugreifen. Es gibt einige Werte darin, und wenn ja, geben wir sie einfach zurück. Wenn nicht, gehen wir zur langsamen IO, um sie zu bekommen. Letzteres möchten wir asynchron durchführen, was bedeutet, dass die gesamte Methode asynchron wird. Daher wäre die offensichtliche Variante, die Methode zu schreiben, die folgende:
public async Task GetById(int id) {
if (cache.TryGetValue(id, out string val))
return val;
return await RequestById(id);
}
Aus dem Wunsch heraus, ein wenig zu optimieren, und aus leichter Besorgnis darüber, was Roslyn beim Kompilieren dieses Codes erzeugen wird, kann man dieses Beispiel wie folgt umschreiben:
public Task GetById(int id) {
if (cache.TryGetValue(id, out string val))
return Task.FromResult(val);
return RequestById(id);
}
Die tatsächlich optimale Lösung in diesem Fall wäre, den Hot-Path zu optimieren, nämlich den Wert aus dem Wörterbuch ohne unnötige Allokationen und Belastungen für den GC zu beziehen. In den seltenen Fällen, in denen wir tatsächlich in die IO gehen müssen, bleibt alles mehr oder weniger wie gewohnt:
public ValueTask GetById(int id) {
if (cache.TryGetValue(id, out string val))
return new ValueTask(val);
return new ValueTask(RequestById(id));
}
Lassen Sie uns dieses Codefragment genauer untersuchen: Bei Vorhandensein eines Wertes im Cache erstellen wir eine Struktur, andernfalls wird der tatsächliche Task in einen signifikanten gewickelt. Der aufrufende Code ist egal, auf welchem Weg dieser Code ausgeführt wurde: ValueTask verhält sich syntaktisch in C# wie ein normaler Task in diesem Fall.
TaskScheduler: Verwaltung der Ausführungsstrategien von Tasks
Die nächste API, die wir betrachten möchten, ist die Klasse TaskScheduler und seine Ableitungen. Ich habe bereits erwähnt, dass es in TPL die Möglichkeit gibt, Strategien zur Verteilung von Tasks auf Threads zu verwalten. Solche Strategien werden in den Nachfolgern der Klasse TaskScheduler definiert. Praktisch jede Strategie, die benötigt wird, wird in der Bibliothek gefunden ParallelExtensionsExtras, die von Microsoft entwickelt wurde, aber nicht Bestandteil von .NET ist und als NuGet-Paket bereitgestellt wird. Lassen Sie uns einige davon kurz betrachten:
- CurrentThreadTaskScheduler — führt Tasks im aktuellen Thread aus
- LimitedConcurrencyLevelTaskScheduler — begrenzt die Anzahl der gleichzeitig ausgeführten Tasks durch den Parameter N, der im Konstruktor übergeben wird
- OrderedTaskScheduler — wird als LimitedConcurrencyLevelTaskScheduler(1) definiert, daher werden Aufgaben sequenziell ausgeführt.
- WorkStealingTaskScheduler — implementiert zapproach zur Verteilung von Aufgaben. Es fungiert im Wesentlichen als separater ThreadPool. Es löst das Problem, dass der .NET ThreadPool eine statische Klasse ist, die für alle Anwendungen gilt, was bedeutet, dass seine Überlastung oder fehlerhafte Nutzung in einem Teil des Programms zu Nebenwirkungen in einem anderen führen kann. Darüber hinaus ist es äußerst schwierig, die Ursachen solcher Fehler zu verstehen. Daher könnte es notwendig sein, separate WorkStealingTaskScheduler in jenen Teilen des Programms zu verwenden, in denen der Einsatz des ThreadPool aggressiv und unvorhersehbar sein kann.
- QueuedTaskScheduler — ermöglicht die Ausführung von Aufgaben gemäß Warteschlangenregelungen mit Prioritäten
- ThreadPerTaskScheduler — erstellt einen separaten Thread für jede Task, die darauf ausgeführt wird. Kann nützlich sein für Aufgaben, die unvorhersehbar lange dauern.
Es gibt eine gute, detaillierte Über TaskScheduler im Microsoft-Blog.
Für eine bequeme Fehlersuche im Zusammenhang mit Tasks gibt es in Visual Studio das Fenster "Tasks". In diesem Fenster kann der aktuelle Status der Aufgabe angezeigt werden und man kann zur gerade ausgeführten Codezeile springen.

PLinq und die Klasse Parallel
Neben den Tasks und allem, was damit gesagt wurde, gibt es in .NET noch zwei interessante Werkzeuge: PLinq (Linq2Parallel) und die Klasse Parallel. Ersteres verspricht parallele Ausführung aller Linq-Operationen über mehrere Threads. Die Anzahl der Threads kann mit der Erweiterungsmethode WithDegreeOfParallelism konfiguriert werden. Leider reicht PLinq im Standardarbeitsmodus häufig nicht aus, um nützliche Informationen über die Interna Ihrer Datenquelle bereitzustellen, um signifikante Geschwindigkeit gewinnen zu können, andererseits ist der Preis für den Versuch sehr gering: Man muss nur die Methode AsParallel vor der Linq-Methodenkette aufrufen und Leistungstests durchführen. Darüber hinaus gibt es die Möglichkeit, PLinq zusätzliche Informationen über die Natur Ihrer Datenquelle über den Mechanismus Partitions zu übergeben. Mehr dazu ist hier zu lesen und .
Die statische Klasse Parallel bietet Methoden zur parallelen Iteration von Sammlungen mit Foreach, zur Ausführung von For-Schleifen und zur gleichzeitigen Ausführung mehrerer Delegaten in Parallel Invoke. Die Ausführung des aktuellen Threads wird angehalten, bis die Berechnungen abgeschlossen sind. Die Anzahl der Threads kann konfiguriert werden, indem ParallelOptions als letztes Argument übergeben wird. Mit Optionen kann auch der TaskScheduler und der CancellationToken angegeben werden.
Das DBMS Tarantool ist ein attraktives, zukunftsträchtiges Produkt zur Erstellung von hochbelasteten Anwendungen.
Als ich anfing, diesen Artikel basierend auf meinem Vortrag und den Informationen, die ich während meiner Arbeit danach gesammelt habe, zu schreiben, erwartete ich nicht, dass es so viel werden würde. Jetzt, wo der Texteditor, in dem ich diesen Artikel tippe, mir anklagend sagt, dass die 15. Seite erreicht ist, fasse ich meine Zwischenbilanz zusammen. Weitere Tricks, APIs, visuelle Werkzeuge und Stolpersteine werden im nächsten Artikel behandelt.
Fazit:
- Man muss die Werkzeuge für die Arbeit mit Threads, Asynchronität und Parallelität kennen, um die Ressourcen moderner PCs effektiv zu nutzen.
- In .NET gibt es viele verschiedene Werkzeuge für diese Zwecke.
- Nicht alle sind auf einmal entstanden, deshalb kann man oft auf Legacy treffen; es gibt jedoch Möglichkeiten, alte APIs ohne großen Aufwand zu konvertieren.
- Die Arbeit mit Threads in .NET wird durch die Klassen Thread und ThreadPool dargestellt.
- Die Methoden Thread.Abort, Thread.Interrupt und die Win32-API-Funktion TerminateThread sind gefährlich und werden nicht empfohlen. Stattdessen sollte man besser den Mechanismus der CancellationToken verwenden.
- Ein Thread ist eine wertvolle Ressource, deren Anzahl begrenzt ist. Man muss Situationen vermeiden, in denen Threads auf Ereignisse warten. Dazu ist es praktisch, die Klasse TaskCompletionSource zu verwenden.
- Das leistungsstärkste und fortschrittlichste Werkzeug von .NET für die Arbeit mit Parallelität und Asynchronität sind Tasks.
- Die Operatoren c# async/await implementieren das Konzept des nicht blockierenden Wartens.
- Die Verteilung von Tasks auf Threads kann durch abgeleitete Klassen von TaskScheduler verwaltet werden.
- Die Struktur ValueTask kann bei der Optimierung von Hot-Paths und dem Speichertraffic hilfreich sein.
- Die Fenster Tasks und Threads in Visual Studio bieten viele nützliche Informationen zur Fehlersuche bei mehrfädigem oder asynchronem Code.
- PLinq ist ein tolles Werkzeug, aber es könnte nicht genügend Informationen über Ihre Datenquelle enthalten; allerdings kann dies durch das Partitionierungsmechanismus behoben werden.
- Fortsetzung folgt…
Quelle: habr.com
