Ich veröffentliche auf Habr das Original des Artikels, dessen Übersetzung im Unternehmens... .
Die Notwendigkeit, etwas asynchron zu tun, ohne auf das Ergebnis hier und jetzt zu warten, oder größere Aufgaben auf mehrere ausführende Einheiten aufzuteilen, gab es bereits vor der Computerzeit. Mit deren Erscheinen wurde diese Notwendigkeit sehr spürbar. Jetzt, im Jahr 2019, tippe ich diesen Artikel auf einem Laptop mit einem 8-Kern Intel Core Prozessor, auf dem gleichzeitig Hunderte von Prozessen laufen, und noch mehr Threads. Daneben liegt ein etwas strapaziertes, vor ein paar Jahren gekauftes Telefon, das mit einem 8-Kern Prozessor ausgestattet ist. In fachspezifischen Ressourcen gibt es zahlreiche Artikel und Videos, in denen die Autoren von den Flaggschiff-Smartphones dieses Jahres schwärmen, die 16-Kern-Prozessoren verbaut haben. MS Azure bietet für weniger als 20 $/Stunde eine virtuelle Maschine mit einem 128-Kern-Prozessor und 2 TB RAM an. Leider ist es nicht möglich, das Maximum herauszuholen und diese Macht zu zähmen, ohne die Interaktion der Threads zu steuern.
Terminologie
Prozess (Process) — Objekt des Betriebssystems, isolierter Adressraum, enthält Threads.
Thread (Thread) — ein Objekt des Betriebssystems, die kleinste Ausführungseinheit, in der Prozesse Speicher und andere Ressourcen untereinander teilen.
Multitasking — eine Eigenschaft des Betriebssystems, die Möglichkeit mehrere Prozesse gleichzeitig auszuführen.
Multi-Core — eine Fähigkeit des Prozessors, mehrere Kerne zur Datenverarbeitung zu nutzen.
Multiprozessorsystem — eine Fähigkeit des Computers, physisch mit mehreren Prozessoren gleichzeitig zu arbeiten.
Multithreading — eine Eigenschaft eines Prozesses, die Möglichkeit, die Datenverarbeitung über mehrere Threads zu verteilen.
Parallelität — die Ausführung mehrerer Aktionen physisch gleichzeitig zu einem Zeitpunkt.
Asynchronität — die Ausführung einer Operation ohne das Warten auf den Abschluss dieser Bearbeitung; das Ergebnis kann später verarbeitet werden.
Metapher
Nicht alle Definitionen sind gut, und einige benötigen zusätzliche Erklärungen. Deshalb füge ich zur formal eingeführten Terminologie eine Metapher über das Frühstück zubereiten hinzu. Das Zubereiten von Frühstück in dieser Metapher ist ein Prozess.
Wenn ich morgens Frühstück mache, 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 (IOIch nehme den Herd, den Wasserkocher, den Toaster und den Kühlschrank. Ich stelle das Gas an, setze die Pfanne darauf und gieße Öl hinein, ohne darauf zu warten, dass es sich erhitzt.asynchron, Non-Blocking-IO-Warten, ich hole die Eier aus dem Kühlschrank und schlage sie in eine Schüssel, während ich mit einer Hand (Thread#1) rühre, und mit der anderen (Thread#2) die Schüssel festhalte (Shared Resource). Jetzt könnte ich noch den Wasserkocher einschalten, aber mir fehlen die Hände (Thread Starvation). Währenddessen wird die Pfanne heiß (Ergebnisverarbeitung), in die ich das Geschlagene gieße. Ich erreiche den Wasserkocher und schalte ihn ein und schaue einfach zu, wie das Wasser darin kocht (Blocking-IO-Warten), obwohl ich in der Zwischenzeit das Geschirr hätte spülen können, in dem ich das Omelett geschlagen habe.
Ich habe ein Omelett zubereitet, indem ich nur 2 Hände benutzt habe, mehr habe ich nicht, aber während des Rührens des Omeletts fanden gleichzeitig 3 Vorgänge statt: das Rühren des Omeletts, das Halten der Schüssel und das Erhitzen der Pfanne. Die CPU ist der schnellste Teil des Computers, während IO oft die Bremsen verursacht. Daher ist es oft eine effektive Lösung, die CPU mit etwas zu beschäftigen, während die Daten von IO abgerufen werden.
Fortsetzung der Metapher:
- Wenn ich beim Kochen eines Omeletts auch noch versuchen würde, mich umzuziehen, wäre das ein Beispiel für Multitasking. Ein wichtiger Punkt: Computer können das viel besser als Menschen.
- Eine Küche mit mehreren Köchen, wie in einem Restaurant – das ist ein Multikern-Computer.
- Viele Restaurants in der Food Court eines Einkaufszentrums – das ist ein Rechenzentrum.
Tools .NET
In der Arbeit mit Threads ist .NET, wie in vielen anderen Bereichen, ausgezeichnet. Mit jeder neuen Version stellt es immer mehr neue Werkzeuge für die Arbeit mit ihnen zur Verfügung, neue Abstraktionsschichten über den Betriebssystem-Threads. Bei der Entwicklung von Abstraktionen verwenden die Framework-Entwickler einen Ansatz, der es ermöglicht, bei der Nutzung von hochgradigen Abstraktionen auf eine oder mehrere niedrigere Ebenen herunterzugehen. Meistens ist das nicht nötig; mehr noch, es birgt die Möglichkeit, sich mit einem Schrotgewehr selbst ins Bein 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 von dem Framework bereitgestellten Programmierschnittstellen (APIs) als auch komplette Softwarelösungen, die die Fehlersuche bei multithreadigem Code erleichtern.
Thread starten
Die Klasse Thread ist die grundlegendste Klasse in .NET zur Arbeit mit Threads. Der Konstruktor akzeptiert einen von zwei 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 des Typs ParametrizedThreadStart übergeben wurde, muss beim Aufruf der Methode Start ein Objekt übergeben werden. Dieser Mechanismus dient dazu, lokale Informationen an den Thread zu übergeben. Es ist wichtig zu beachten, dass die Erstellung eines Threads eine kostenintensive Operation ist, und der Thread selbst ist ein schwergewichtiges Objekt, da mindestens 1 MB Speicher für den Stack reserviert werden und eine Interaktion mit der Betriebssystem-API erforderlich ist.
new Thread(...).Start(...);
Die Klasse ThreadPool stellt das Konzept des Pools dar. Im .NET-Framework ist der Thread-Pool 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 Reserve-Threads und ermöglicht deren Nutzung. Wenn diese Threads häufig und in großer Menge verwendet werden, wird der Pool erweitert, um den Anforderungen des aufrufenden Codes gerecht zu werden. Wenn zu einem bestimmten Zeitpunkt im Pool keine freien Threads verfügbar sind, wartet er entweder auf die Rückgabe eines Threads oder erstellt einen neuen. Daraus folgt, dass der Threadpool hervorragend für kurze Aktionen geeignet ist, jedoch schlecht für Operationen, die als Dienste während der gesamten Laufzeit der Anwendungen arbeiten.
Um einen Thread aus dem Pool zu verwenden, gibt es die Methode QueueUserWorkItem, die einen Delegaten vom Typ WaitCallback erwartet, der in seiner Signatur mit ParametrizedThreadStart übereinstimmt. Der übergebene Parameter erfüllt die gleiche Funktion.
ThreadPool.QueueUserWorkItem(...);
Weniger bekannte Methode des Thread-Pools, RegisterWaitForSingleObject, dient der Organisation nicht-blockierender IO-Operationen. Der an diese Methode übergebene Delegat wird aufgerufen, wenn das übergebene WaitHandle "freigegeben" (Released) wird.
ThreadPool.RegisterWaitForSingleObject(...)
In .NET gibt es einen Streaming-Timer, der sich von den Timern in WinForms/WPF unterscheidet, da sein Handler in einem Thread aus dem Pool aufgerufen wird.
System.Threading.Timer
Es gibt auch eine recht exotische Möglichkeit, einen Delegate zur Ausführung in einen Thread aus dem Pool zu senden – die Methode BeginInvoke.
DelegateInstance.BeginInvoke
Ich möchte auch kurz auf die Funktion eingehen, die viele der oben genannten Methoden zusammenfasst – CreateThread aus der Kernel32.dll der Win32-API. Es gibt einen Weg, diese Funktion mithilfe des externen Methodenmechanismus aufzurufen. Ich habe einen solchen Aufruf nur einmal in einem schrecklichen Beispiel von Legacy-Code gesehen, und die Motivation des Autors, es auf diese Weise zu tun, bleibt mir ein Rätsel.
Kernel32.dll CreateThread
Überwachung und Debugging von Threads
Die von Ihnen persönlich erstellten Threads sowie alle Drittanbieterkomponenten und der .NET-Pool können im Fenster "Threads" von Visual Studio angezeigt werden. Dieses Fenster zeigt Informationen zu den Threads nur an, wenn die Anwendung sich im Debugging und im Break-Modus befindet. Hier können Sie bequem den Stack, die Namen und die Prioritäten jedes Threads einsehen und die Debugging-Sitzung auf einen bestimmten Thread umschalten. Mit der Eigenschaft "Priority" der Klasse "Thread" können Sie die Priorität des Threads festlegen, die von OC und CLR als Empfehlung bei der Aufteilung der Prozessorzeit zwischen den Threads gewertet wird.

Task Parallel Library
Die Task Parallel Library (TPL) wurde mit .NET 4.0 eingeführt und ist heute der Standard und das Hauptwerkzeug für die Arbeit mit Asynchronität. Jeder Code, der auf ältere Ansätze zurückgreift, gilt als Legacy. Die zentrale Komponente der TPL ist die Klasse Task aus dem Namensraum System.Threading.Tasks. Ein Task stellt eine Abstraktion über einen Thread dar. Mit der neuen Version der Programmiersprache C# haben wir eine elegante Methode erhalten, um mit Tasks zu arbeiten – die async/await-Operatoren. Diese Konzepte ermöglichen es, asynchronen Code so zu schreiben, als wäre er einfach und synchron, was es selbst Menschen mit begrenztem Verständnis der Interna von Threads ermöglicht, Anwendungen zu entwickeln, die bei langwierigen Operationen nicht einfrieren. Die Nutzung von async/await wäre ein Thema für einen oder sogar mehrere Artikel, aber ich werde versuchen, die Essenz in einigen Sätzen zusammenzufassen:
- async ist ein Modifikator für Methoden, die Task oder void zurückgeben.
- await ist der Operator für das nicht-blockierende Warten auf einen Task.
Noch einmal: Der Operator await gibt normalerweise den aktuellen Ausführungsthread frei (es gibt Ausnahmen), und wenn die Task abgeschlossen ist und der Thread (besser gesagt der Kontext, aber dazu später) wieder verfügbar ist, wird die Ausführung der Methode fortgesetzt. Innerhalb von .NET wird dieser Mechanismus ähnlich wie yield return umgesetzt, wobei die geschriebene Methode in eine gesamte Klasse umgewandelt wird, die als Zustandsmaschine fungiert und in Abhängigkeit von diesen Zuständen in einzelnen Teilen ausgeführt werden kann. Interessierte können einfachen Code mit async/await schreiben, kompilieren und das Ergebnis mit JetBrains dotPeek unter aktivierter Compiler Generated Code-Funktion anzeigen.
Betrachten wir die Möglichkeiten zur Ausführung und Nutzung einer Task. Im folgenden Beispiel erstellen wir eine neue Task, die nichts Nützliches tut (Thread.Sleep(10000)), aber im wirklichen Leben sollte es sich um eine komplexe CPU-intensive Aufgabe handeln.
using TCO = System.Threading.Tasks.TaskCreationOptions;
public static async void VoidAsyncMethod() {
var cancellationSource = new CancellationTokenSource();
await Task.Factory.StartNew(
// Der Code wird in einem 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
}
Eine Aufgabe wird mit einer Reihe von Optionen erstellt:
- LongRunning — ein Hinweis darauf, dass die Aufgabe nicht schnell ausgeführt wird. Daher könnte es sinnvoll sein, keinen Thread aus dem Pool zu nehmen, sondern einen eigenen für diese Aufgabe zu erstellen, um die anderen nicht zu beeinträchtigen.
- AttachedToParent — Aufgaben können in Hierarchien angeordnet werden. Wenn diese Option genutzt wird, kann die Aufgabe in einem Zustand sein, in dem sie selbst abgeschlossen ist und auf die Ausführung von untergeordneten Aufgaben wartet.
- PreferFairness — bedeutet, dass es empfehlenswert ist, zuerst die Aufgaben auszuführen, die früher zur Ausführung gesendet wurden, bevor die später gesendeten abgearbeitet werden. Dies ist jedoch nur eine Empfehlung und das Ergebnis ist nicht garantiert.
Das zweite Argument der Methode ist ein CancellationToken. Um die Abbruchoperation nach deren Start korrekt zu verarbeiten, muss der auszuführende Code mit Überprüfungen des Status des CancellationToken versehen sein. Andernfalls kann die auf dem Objekt CancellationTokenSource aufgerufene Cancel-Methode die Ausführung der Task nur bis zu dessen Start anhalten.
Das letzte übergebene Argument ist ein Objekt vom Typ TaskScheduler. Diese Klasse und ihre Unterklassen sind dafür zuständig, die Verteilung von Tasks auf Threads zu steuern; standardmäßig wird der Task auf einem beliebigen Thread aus dem Pool ausgeführt.
Auf den erstellten Task wird der await-Operator angewendet, was bedeutet, dass der nachfolgende Code, sofern vorhanden, im selben Kontext (häufig auf demselben Thread) ausgeführt wird wie der Code vor await.
Die Methode ist als async void markiert, was bedeutet, dass sie den await-Operator verwenden kann, aber der aufrufende Code nicht auf die Ausführung warten kann. Wenn diese Funktion erforderlich ist, sollte die Methode Task zurückgeben. Methoden, die als async void markiert sind, sind ziemlich häufig: in der Regel handelt es sich um Ereignis-Handler oder andere Methoden, die dem Prinzip 'ausführen und vergessen' (fire and forget) folgen. Wenn es notwendig ist, nicht nur die Möglichkeit zu geben, auf das Ende der Ausführung zu warten, sondern auch ein Ergebnis zurückzugeben, sollte Task verwendet werden.
Auf dem Task, den die Methode StartNew zurückgegeben hat, sowie auf jedem anderen, kann die Methode ConfigureAwait mit dem Parameter false aufgerufen werden. Dann wird die Ausführung nach await nicht im erfassten Kontext fortgesetzt, sondern in einem beliebigen. Dies sollte immer getan werden, wenn der Kontext der Ausführung für den Code nach await nicht von Bedeutung ist. Dies wird auch von Microsoft als Empfehlung beim Schreiben von Code gegeben, der in Form einer Bibliothek bereitgestellt wird.
Lassen Sie uns einen Moment darüber nachdenken, wie man auf das Ende der Ausführung eines Tasks warten kann. Unten finden Sie ein Codebeispiel mit Kommentaren, wann das Warten ganz gut und wann es weniger gut umgesetzt ist.
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 der Aufgabe, ohne den aufrufenden Thread zu blockieren. Wir kehren zur Bearbeitung des Ergebnisses zurück, sobald es vorliegt; bis dahin steht der aufrufende Thread zur Verfügung.
Im zweiten Fall blockieren wir den aufrufenden Thread, bis das Ergebnis der Methode berechnet wird. Dies ist nicht nur schlecht, weil wir einen Thread, der eine wertvolle Ressource des Programms darstellt, mit unnötigem Warten belegen, sondern auch, weil wir einen Deadlock erhalten können: Der aufrufende Thread wartet auf das Ergebnis der asynchronen Methode, während die asynchrone Methode vergeblich versucht, ihre Ausführung im aufrufenden Thread fortzusetzen.
Ein weiterer Nachteil dieses Ansatzes ist die komplizierte Fehlerbehandlung. Fehler in asynchronem Code mit async/await zu behandeln, ist sehr einfach – sie verhalten sich so, als wäre der Code synchron. Wenn wir jedoch synchrone Wartezeiten auf eine Task anwenden, wird die ursprüngliche Ausnahme in eine AggregateException verpackt. Daher müssen wir den Typ der InnerException untersuchen und eine if-Kette innerhalb eines catch-Blocks schreiben oder die catch when-Konstruktion verwenden, anstatt die in der C#-Welt gewohnte Kette von catch-Blöcken zu nutzen.
Das dritte und letzte Beispiel ist ebenfalls aus demselben Grund mangelhaft und weist all die gleichen Probleme auf.
Die Methoden WhenAny und WhenAll sind extrem nützlich, um auf eine Gruppe von Tasks zu warten, da sie eine Gruppe von Tasks in einen einziges Task einwickeln, der entweder beim ersten Abschluss eines Tasks aus der Gruppe oder beim Abschluss aller Tasks ausgelöst wird.
Threads anhalten
Aus verschiedenen Gründen kann es notwendig sein, einen Thread nach seinem Start zu stoppen. Dafür gibt es eine Reihe von Möglichkeiten. Die Klasse Thread verfügt über zwei Methoden mit passenden Namen — das sind Abort und Interrupt. Die erste Methode wird dringend davon abgeraten, da sie zu jeder zufälligen Zeit eine Ausnahme auslösen kann, während eine beliebige Anweisung verarbeitet wird. ThreadAbortedException. Sie erwarten doch nicht, dass eine solche Ausnahme beim Inkrementieren einer Ganzzahlvariable ausgelöst wird, oder? Mit dieser Methode kann das jedoch eine sehr reale Situation sein. Wenn es notwendig ist, die CLR daran zu hindern, eine solche Ausnahme in einem bestimmten Codeabschnitt zu generieren, kann man ihn in Aufrufe von Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Solche Aufrufe umschließen jeden Code, der im finally-Block geschrieben ist. Aus diesem Grund finden sich im Rahmen des Codes des Frameworks Abschnitte mit leerem try, aber nicht leerem finally. Microsoft empfiehlt die Verwendung dieser Methode so stark, dass sie nicht in .NET Core integriert wurde.
Die Methode Interrupt funktioniert vorhersagbarer. Sie kann einen Thread nur dann mit einer Ausnahme ThreadInterruptedException unterbrechen, wenn sich der Thread im Wartezustand befindet. In diesen Zustand gelangt er, wenn er auf ein WaitHandle wartet, bei einem Lock oder nach dem Aufruf von Thread.Sleep.
Beide oben beschriebenen Varianten sind aufgrund ihrer Unvorhersehbarkeit problematisch. Die Lösung besteht darin, eine Struktur zu verwenden. CancellationToken und Klasse CancellationTokenSource. Die Kernidee ist wie folgt: Es wird eine Instanz der Klasse CancellationTokenSource erstellt, und nur derjenige, der sie besitzt, kann den Vorgang stoppen, indem er die Methode Cancel. In den Vorgang wird nur der CancellationToken übergeben. Die Inhaber von CancellationToken können den Vorgang nicht selbst abbrechen, sondern nur überprüfen, ob der Vorgang abgebrochen wurde. Dafür gibt es eine boolesche Eigenschaft IsCancellationRequested und die Methode ThrowIfCancelRequested. Letzteres wird eine Ausnahme TaskCancelledException auslösen, wenn die Methode Cancel auf der zugehörigen Instanz von CancellationTokenSource aufgerufen wurde. Genau diese Methode empfehle ich, da sie einen vollständigen Überblick darüber gibt, zu welchen Zeitpunkten die Ausnahme den Vorgang unterbrechen kann.
Die extremste Möglichkeit, einen Thread zu stoppen, ist der Aufruf der Win32 API-Funktion TerminateThread. Das Verhalten von CLR nach dem Aufruf dieser Funktion kann unvorhersehbar 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 taskbasierte über die Methode FromAsync
Wenn Sie das Glück hatten, an einem Projekt zu arbeiten, das bereits nach der Einführung von Tasks gestartet wurde und somit der große Schrecken vieler Entwickler vermieden wurde, müssen Sie nicht mit einer Vielzahl alter APIs umgehen, sowohl von Dritten als auch von Ihrem eigenen Team in der Vergangenheit erstellt. Glücklicherweise hat das Entwicklungsteam des .NET Framework für uns gesorgt, auch wenn möglicherweise das Ziel war, sich selbst zu helfen. Wie dem auch sei, .NET bietet verschiedene Werkzeuge, um Code aus alten Ansätzen der asynchronen Programmierung schmerzlos in neue Ansätze zu überführen. Eines davon ist die Methode FromAsync der TaskFactory. Im folgenden Beispiel umschließe ich alte asynchrone Methoden der Klasse WebRequest in einen Task mithilfe dieser Methode.
object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
wr.BeginGetResponse,
wr.EndGetResponse
);
Dies ist nur ein Beispiel, und es ist unwahrscheinlich, dass Sie dies mit eingebauten Typen tun müssen, aber jedes alte Projekt ist einfach übersät mit Methoden wie BeginDoSomething, die IAsyncResult zurückgeben, und Methoden wie EndDoSomething, die diese annehmen.
Umwandlung von Legacy-APIs in Task-basierten Code mit der Klasse TaskCompletionSource
Ein weiteres wichtiges Werkzeug, das berücksichtigt werden sollte, ist die Klasse TaskCompletionSource. In Bezug auf Funktionen, Zweck und Funktionsweise erinnert es etwas an die Methode RegisterWaitForSingleObject der ThreadPool-Klasse, über die ich zuvor schrieb. Mit dieser Klasse können alte asynchrone APIs einfach und bequem in Tasks eingebettet werden.
Sie werden sagen, dass ich bereits über die Methode FromAsync der TaskFactory-Klasse gesprochen 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: Bis zum Task-Based Asynchronous Pattern (TAP) gab es das Asynchronous Programming Pattern (APP), das sich mit Methoden. BeginDoSomething, das zurückgibt IAsyncResult und Methoden EndDoSomething, das dieses entgegennimmt, und für die Legacy-Modelle dieser Jahre eignet sich die Methode FromAsync hervorragend, aber mit der Zeit wurde sie durch das Event-Based Asynchronous Pattern (EAP), das vorsieht, dass nach Abschluss einer asynchronen Operation ein Ereignis ausgelöst wird, abgelöst.
TaskCompletionSource eignet sich hervorragend zum Wrapping von Tasks in Legacy-APIs, die auf einem ereignisgesteuerten Modell basieren. Das Prinzip dahinter ist folgendes: Ein Objekt dieser Klasse hat eine öffentliche Eigenschaft vom Typ Task, deren Zustand über die Methoden SetResult, SetException usw. gesteuert werden kann. In den Stellen, an denen der await-Operator auf diesen Task angewendet wird, wird er entweder abgeschlossen oder mit einer Ausnahme abgebrochen, abhängig von der Methode, die auf TaskCompletionSource aufgerufen wurde. Falls das noch unklar ist, schauen wir uns ein Beispiel an, das zeigt, wie eine alte API aus der Zeit des EAP in einen Task mithilfe von TaskCompletionSource gewrappt wird: Bei Auslösung eines Ereignisses wird der Task in den Status Completed überführt, und die Methode, die den await-Operator auf diesen Task angewendet hat, setzt ihre Ausführung mit dem erhaltenen Objekt fort. 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 Wrapper alter APIs sind nicht alles, was Sie mit TaskCompletionSource erreichen können. Die Nutzung dieser Klasse eröffnet interessante Möglichkeiten zur Gestaltung verschiedener APIs, die auf Tasks basieren, die keine Threads belegen. Und wie wir wissen, sind Threads eine teure Ressource, und ihre Anzahl ist begrenzt (hauptsächlich durch den verfügbaren RAM). Diese Einschränkung kann leicht erreicht werden, wenn man beispielsweise eine hochbelastete Webanwendung mit komplexer Geschäftslogik entwickelt. Lassen Sie uns die Möglichkeiten betrachten, auf die ich hinweise, indem wir einen Trick wie Long-Polling implementieren.
Kurz gesagt, das Prinzip ist folgendes: Sie müssen über eine API Informationen zu bestimmten Ereignissen abrufen, die auf deren Seite stattfinden. Dabei kann die API aus bestimmten Gründen kein Ereignis mitteilen, sondern lediglich den aktuellen Status zurückgeben. Beispiele hierfür sind alle APIs, die auf HTTP basieren, bevor WebSocket eingeführt wurde, oder wenn aus irgendeinem Grund die Verwendung dieser Technologie nicht möglich ist. Der Client kann den HTTP-Server abfragen. Der HTTP-Server kann jedoch selbst keine Kommunikation mit dem Client initiieren. Eine einfache Lösung ist eine regelmäßige Abfrage des Servers mittels eines Timers, doch das erzeugt zusätzliche Last auf dem Server und eine durchschnittliche Verzögerung von TimerInterval / 2. Um dies zu umgehen, wurde ein Trick namens Long Polling erfunden, der eine Verzögerung der Serverantwort vorschlägt, bis entweder der Timeout erreicht ist oder ein Ereignis eintritt. Tritt ein Ereignis ein, wird es verarbeitet; wenn nicht, wird die Anfrage erneut gesendet.
while(!eventOccures && !timeoutExceeded) {
CheckTimout();
CheckEvent();
Thread.Sleep(1);
}
Eine solche Lösung wird katastrophal sein, sobald die Anzahl der Kunden, die auf ein Ereignis warten, steigt, da jeder Kunde in der Warteschlange einen kompletten Thread beansprucht. Zudem entstehen zusätzliche Verzögerungen von 1 ms bei der Auslösung des Ereignisses. Dies ist häufig nicht signifikant, aber warum die Software schlechter machen, als sie sein könnte? Wenn wir jedoch Thread.Sleep(1) entfernen, belasten wir unnötig einen CPU-Kern zu 100 % durch ein sinnloses Leerlaufverfahren. Mit Hilfe von TaskCompletionSource lässt sich dieser Code jedoch leicht umgestalten, um alle oben genannten Probleme zu 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 lediglich eine Demonstration. Um ihn in echten Szenarien verwenden zu können, muss mindestens die Situation behandelt werden, wenn eine Nachricht zu einem Zeitpunkt eintrifft, an dem sie nicht erwartet wird: In diesem Fall sollte die Methode AsseptMessageAsync bereits ein abgeschlossenes Task zurückgeben. Falls dies das häufigste Szenario ist, könnte auch der Einsatz von ValueTask in Betracht gezogen werden.
Wenn eine Anfrage für eine Nachricht eingeht, erstellen wir eine TaskCompletionSource und fügen sie in ein Dictionary ein. Danach warten wir darauf, was zuerst passiert: ob der festgelegte Zeitrahmen abläuft oder ob eine Nachricht empfangen wird.
ValueTask: Warum und wie
Die async/await-Operatoren sowie der yield return-Operator erzeugen aus einer Methode einen Zustandsautomaten, was die Erstellung eines neuen Objekts zur Folge hat. Dies ist normalerweise unerheblich, kann in seltenen Fällen jedoch problematisch werden. Ein solches Szenario tritt auf, wenn eine Methode sehr häufig aufgerufen wird, im Bereich von Zehntausenden bis Hunderttausenden 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, stellt .NET ein Werkzeug zur Verfügung, um dies zu optimieren – die Struktur ValueTask. Um das zu verdeutlichen, betrachten wir ein Beispiel für die Verwendung: Es gibt einen Cache, auf den wir sehr häufig zugreifen. Wenn einige Werte vorhanden sind, geben wir diese einfach zurück; wenn nicht, gehen wir für sie auf langsamen IO. Letzteres möchten wir asynchron durchführen, was bedeutet, dass die gesamte Methode asynchron wird. Ein offensichtlicher Ansatz zur Implementierung dieser Methode wäre der 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 einer leichten Besorgnis darüber, was Roslyn beim Kompilieren dieses Codes generiert, kann dieses Beispiel wie folgt umgeschrieben werden:
public Task GetById(int id) {
if (cache.TryGetValue(id, out string val))
return Task.FromResult(val);
return RequestById(id);
}
In diesem Fall wäre es tatsächlich optimal, den Hot-Path zu optimieren, also den Wert aus dem Dictionary ohne unnötige Allokationen und Belastung für den GC zu erhalten. In den seltenen Fällen, in denen wir dennoch IO für Daten benötigen, bleibt alles mehr oder weniger beim Alten.
public ValueTask GetById(int id) {
if (cache.TryGetValue(id, out string val))
return new ValueTask(val);
return new ValueTask(RequestById(id));
}
Lassen Sie uns diesen Codeabschnitt näher betrachten: Wenn ein Wert im Cache vorhanden ist, erstellen wir eine Struktur; andernfalls wird die tatsächliche Aufgabe gewickelt. Für den aufrufenden Code spielt es keine Rolle, welchen Weg dieser Code genommen hat: Ein ValueTask verhält sich syntaktisch in C# wie eine normale Task in diesem Fall.
TaskScheduler: Verwaltung von Task-Startstrategien
Das nächste API, das wir betrachten möchten, ist die Klasse TaskScheduler und deren Ableitungen. Wie bereits erwähnt, bietet TPL die Möglichkeit, 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, ist in der Bibliothek zu finden. ParallelExtensionsExtras, entwickelt von Microsoft, jedoch nicht Teil von .NET, sondern als NuGet-Paket bereitgestellt. 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, sodass die Aufgaben nacheinander ausgeführt werden.
- WorkStealingTaskScheduler — implementiert Der Ansatz zur Aufgabenverteilung. Er stellt im Wesentlichen einen separaten ThreadPool dar. Damit wird das Problem gelöst, dass der .NET ThreadPool eine statische Klasse ist, die für alle Anwendungen gemeinsam genutzt wird. Eine Überlastung oder unsachgemäße Verwendung in einem Teil des Programms kann daher zu Nebenwirkungen in einem anderen Teil führen. Darüber hinaus ist es äußerst schwierig, die Ursachen solcher Fehler zu verstehen. Somit kann es notwendig sein, separate WorkStealingTaskScheduler in den Programmteilen zu verwenden, wo die Nutzung des ThreadPools aggressiv und unvorhersehbar sein kann.
- QueuedTaskScheduler — ermöglicht die Ausführung von Aufgaben gemäß einer Warteschlangenregel mit Prioritäten.
- ThreadPerTaskScheduler — erstellt einen separaten Thread für jede Aufgabe, die darauf ausgeführt wird. Kann nützlich sein für Aufgaben, die unvorhersehbar lange dauern.
Es gibt einen guten detaillierten Artikel über TaskScheduler im Blog von Microsoft.
Für eine bequeme Fehlersuche bei allem, was mit Aufgaben zu tun hat, gibt es in Visual Studio das Fenster Aufgaben. In diesem Fenster können Sie den aktuellen Status der Aufgabe sehen und zur gerade ausgeführten Codezeile wechseln.

PLinq und die Klasse Parallel
Neben Tasks und allem, was damit in .NET gesagt wurde, gibt es noch zwei interessante Werkzeuge: PLinq (Linq2Parallel) und die Parallel-Klasse. Erstere verspricht die parallele Ausführung aller Linq-Operationen auf mehreren Threads. Die Anzahl der Threads kann mithilfe der Erweiterungsmethode WithDegreeOfParallelism konfiguriert werden. Leider reicht PLinq im Standardbetrieb oft nicht aus, um substantielle Geschwindigkeitsgewinne zu erzielen, da es meist nicht genügend Informationen über die Interna Ihrer Datenquelle hat. Auf der anderen Seite ist der Preis für einen Versuch sehr gering: Sie müssen nur die Methode AsParallel vor Ihrer Linq-Methodenkette aufrufen und Performance-Tests durchführen. Darüber hinaus gibt es die Möglichkeit, PLinq zusätzliche Informationen über die Beschaffenheit Ihrer Datenquelle mittels des Partitionsmechanismus zu übergeben. Weitere Informationen finden Sie hier. und .
Die statische Klasse Parallel bietet Methoden zum parallelen Durchlaufen von Sammlungen mit Foreach, zur Durchführung von For-Schleifen und zum parallelen Aufruf mehrerer Delegaten. Der aktuelle Thread wird angehalten, bis die Berechnungen abgeschlossen sind. Die Anzahl der Threads kann konfiguriert werden, indem man ParallelOptions als letzten Parameter übergibt. Mit diesen Optionen können auch TaskScheduler und CancellationToken angegeben werden.
Fazit
Als ich begann, diesen Artikel basierend auf meinem Vortrag und den Informationen, die ich seitdem gesammelt habe, zu schreiben, hatte ich nicht erwartet, dass es so umfangreich werden würde. Jetzt, wo mein Texteditor mir vorwurfsvoll mitteilt, dass die 15. Seite erreicht ist, ziehe ich ein vorläufiges Fazit. Weitere Tricks, APIs, visuelle Werkzeuge und Stolpersteine werden im nächsten Artikel behandelt.
Fazit:
- Es ist wichtig, die Werkzeuge für die Arbeit mit Threads, asynchroner Programmierung und Parallelität zu kennen, um die Ressourcen moderner PCs effizient zu nutzen.
- .NET bietet viele verschiedene Werkzeuge für diese Zwecke.
- Nicht alle sind von Anfang an verfügbar gewesen, daher gibt es oft Legacy-Code, aber es gibt Möglichkeiten, alte APIs ohne großen Aufwand zu konvertieren.
- Die Arbeit mit Threads in .NET erfolgt über die Klassen Thread und ThreadPool.
- Die Methoden Thread.Abort, Thread.Interrupt und die Win32 API-Funktion TerminateThread sind riskant und werden nicht empfohlen. Stattdessen ist es besser, den CancellationToken-Mechanismus zu verwenden.
- Threads sind wertvolle Ressourcen, und ihre Anzahl ist begrenzt. Es ist wichtig, Situationen zu vermeiden, in denen Threads auf Ereignisse warten. Hierfür ist die Verwendung der Klasse TaskCompletionSource nützlich.
- Die leistungsfähigsten und fortschrittlichsten Werkzeuge in .NET zur Handhabung von Parallelität und Asynchronität sind Tasks.
- Die C# async/await-Operatoren implementieren das Konzept des nicht-blockierenden Wartens.
- Die Verteilung von Tasks auf Threads kann mithilfe von abgeleiteten Klassen des TaskSchedulers gesteuert werden.
- Die Struktur ValueTask kann nützlich sein, um Hot-Paths und den Speichertraffic zu optimieren.
- Die Fenster für Tasks und Threads in Visual Studio bieten viele nützliche Informationen zur Fehlersuche bei multithreaded oder asynchronem Code.
- PLinq ist ein großartiges Werkzeug, aber es könnte an Informationen über Ihre Datenquelle mangeln. Dies kann jedoch durch Partitionierung behoben werden.
- Die Fortsetzung folgt…
Quelle: habr.com
