.NET: Tools zum Arbeiten mit Multithreading und Asynchronität. Teil 1

Ich veröffentliche den Originalartikel über Habr, dessen Übersetzung im Codingsight- Blog veröffentlicht ist.
Der zweite Teil ist hier verfügbar .

Die Notwendigkeit, etwas asynchron zu tun, ohne hier und jetzt auf das Ergebnis zu warten, oder viel Arbeit zwischen mehreren Einheiten zu teilen, die es ausführen, war schon vor dem Aufkommen der Computer. Mit ihrem Aussehen ist ein solches Bedürfnis sehr greifbar geworden. Jetzt, im Jahr 2019, tippen Sie diesen Artikel auf einem Laptop mit einem Intel Core-Prozessor mit 8 Kernen, auf dem nicht hundert Prozesse gleichzeitig funktionieren, sondern noch mehr Threads. Daneben liegt ein leicht angeschlagenes Telefon, das vor ein paar Jahren gekauft wurde und einen 8-Kern-Prozessor an Bord hat. Die thematischen Ressourcen sind voll von Artikeln und Videos, in denen ihre Autoren die diesjährigen Flaggschiff-Smartphones bewundern, auf denen sie 16-Kern-Prozessoren einsetzen. Für weniger als 20 US-Dollar pro Stunde bietet MS Azure eine virtuelle Maschine mit 128 Kernprozessoren und 2 TB RAM. Leider ist es unmöglich, diese Leistung zu maximieren und einzudämmen, ohne das Zusammenspiel der Strömungen steuern zu können.

Terminologie


Prozess - Ein Betriebssystemobjekt, ein isolierter Adressraum, enthält Threads.
Thread (Thread) - Ein Betriebssystemobjekt, die kleinste Ausführungseinheit, Teil eines Prozesses, Threads, die Speicher und andere Ressourcen innerhalb des Prozesses gemeinsam nutzen.
Multitasking ist eine Betriebssystemfunktion, mit der mehrere Prozesse gleichzeitig ausgeführt werden können
Multicore - eine Eigenschaft des Prozessors, die Fähigkeit, mehrere Kerne für die Datenverarbeitung zu verwenden
Multiprocessing - eine Eigenschaft eines Computers, die Fähigkeit, gleichzeitig physisch mit mehreren Prozessoren zu arbeiten
Multithreading ist eine Eigenschaft eines Prozesses, die Fähigkeit, die Datenverarbeitung auf mehrere Threads zu verteilen.
Parallelität - mehrere Aktionen gleichzeitig pro Zeiteinheit ausführen
Asynchronität - Ausführung einer Operation, ohne auf das Ende dieser Verarbeitung zu warten. Das Ergebnis der Ausführung kann später verarbeitet werden.

Metapher


Nicht alle Definitionen sind gut und einige bedürfen einer zusätzlichen Erklärung. Daher werde ich der formal eingeführten Terminologie eine Metapher für das Kochen des Frühstücks hinzufügen. Das Frühstück in dieser Metapher zu kochen ist ein Prozess.

Morgens beim Kochen koche ich ( CPU ) in die Küche ( Computer ). Ich habe 2 Hände ( Kerne ). Die Küche verfügt über eine Reihe von Geräten ( IO ): Backofen, Wasserkocher, Toaster, Kühlschrank. Ich schalte das Gas ein, stelle eine Pfanne darauf und gieße Öl hinein, ohne zu warten, bis es sich erwärmt ( asynchron, Non-Blocking-IO-Wait ). Ich nehme die Eier aus dem Kühlschrank, zerbreche sie in einen Teller und schlage sie dann mit einer Hand ( Faden Nr. 1) ) und der zweite ( Thread # 2 ) Ich halte die Platte (Shared Resource). Jetzt würde ich immer noch den Wasserkocher einschalten, aber es sind nicht genügend Hände vorhanden ( Fadenhunger ). Während dieser Zeit wird die Pfanne erhitzt (Verarbeitung des Ergebnisses), wo ich gieße, was ich geschlagen habe. Ich greife nach dem Wasserkocher und schalte ihn ein und beobachte dumm, wie das Wasser darin kocht ( Blocking-IO-Wait ), obwohl ich den Teller während dieser Zeit waschen konnte, wo ich das Omelett schlug.

Ich habe ein Omelett mit nur 2 Händen gekocht, aber ich habe nicht mehr, aber gleichzeitig wurden 3 Operationen ausgeführt, als ein Omelett geschlagen wurde: ein Omelett schlagen, einen Teller halten, eine Pfanne erhitzen. Die CPU ist der schnellste Teil des Computers, E / A ist das häufiger verlangsamt alles, so oft ist eine effektive Lösung, etwas CPU zu nehmen, während Daten von E / A empfangen werden.

Fortsetzung der Metapher:

  • Wenn ich bei der Zubereitung eines Omeletts auch versuchen würde, mich umzuziehen, wäre dies ein Beispiel für Multitasking. Eine wichtige Nuance: Computer sind damit viel besser als Menschen.
  • Eine Küche mit mehreren Köchen, beispielsweise in einem Restaurant, ist ein Multi-Core-Computer.
  • Viele Food Court Restaurants in einem Einkaufszentrum - Rechenzentrum

.NET Tools


Bei der Arbeit mit Threads ist .NET wie bei vielen anderen Dingen gut. Mit jeder neuen Version präsentiert er immer mehr neue Werkzeuge für die Arbeit mit ihnen, neue Abstraktionsebenen über Betriebssystem-Threads. Bei der Arbeit mit der Konstruktion von Abstraktionen verwenden die Framework-Entwickler den Ansatz, der die Möglichkeit lässt, bei der Verwendung von Abstraktionen auf hoher Ebene eine oder mehrere Ebenen darunter zu bleiben. In den meisten Fällen ist dies nicht erforderlich. Darüber hinaus besteht die Möglichkeit, dass eine Schrotflinte in den Fuß geschossen wird. In seltenen Fällen ist dies jedoch die einzige Möglichkeit, ein Problem zu lösen, das auf der aktuellen Abstraktionsebene nicht gelöst werden kann.

Mit Tools meine ich sowohl die Programmschnittstellen (APIs), die vom Framework und von Paketen von Drittanbietern bereitgestellt werden, als auch eine vollständige Softwarelösung, die die Suche nach Problemen im Zusammenhang mit Multithread-Code vereinfacht.

Stream starten


Die Thread-Klasse, die grundlegendste Klasse in .NET für die Arbeit mit Threads. Der Konstruktor akzeptiert einen von zwei Delegaten:

  • ThreadStart - Keine Parameter
  • ParametrizedThreadStart - mit einem Parameter vom Typ Objekt.

Der Delegat wird nach dem Aufruf der Start-Methode im neu erstellten Thread ausgeführt. Wenn ein Delegat vom Typ ParametrizedThreadStart an den Konstruktor übergeben wurde, muss ein Objekt an die Start-Methode übergeben werden. Dieser Mechanismus wird benötigt, um lokale Informationen in den Stream zu übertragen. Es ist anzumerken, dass das Erstellen eines Threads eine teure Operation ist und der Thread selbst ein schweres Objekt ist, zumindest weil dem Stapel 1 MB Speicher zugewiesen ist und eine Interaktion mit der Betriebssystem-API erforderlich ist.

new Thread(...).Start(...); 

Die ThreadPool-Klasse repräsentiert das Konzept eines Pools. In .NET ist der Thread-Pool eine technische Arbeit, und Microsoft-Entwickler haben große Anstrengungen unternommen, damit er in einer Vielzahl von Szenarien optimal funktioniert.

Allgemeines Konzept:

Von Anfang an erstellt die Anwendung im Hintergrund mehrere Threads in Reserve und bietet die Möglichkeit, sie zu verwenden. Wenn Threads häufig und in großer Anzahl verwendet werden, wird der Pool erweitert, um die Anforderungen des aufrufenden Codes zu erfüllen. Wenn zum richtigen Zeitpunkt keine freien Flows im Pool vorhanden sind, wird entweder auf die Rückkehr eines der Flows gewartet oder ein neuer erstellt. Daraus folgt, dass der Thread-Pool für einige kurze Aktionen großartig und für Vorgänge, die als Dienst in der gesamten Anwendung ausgeführt werden, schlecht geeignet ist.

Um einen Thread aus dem Pool zu verwenden, gibt es eine QueueUserWorkItem-Methode, die einen WaitCallback-Delegaten akzeptiert, der dieselbe Signatur wie ParametrizedThreadStart hat, und der an ihn übergebene Parameter führt dieselbe Funktion aus.

 ThreadPool.QueueUserWorkItem(...); 

Die weniger bekannte Thread-Pool-Methode RegisterWaitForSingleObject wird verwendet, um nicht blockierende E / A-Operationen zu organisieren. Der an diese Methode übergebene Delegat wird aufgerufen, wenn der an die Methode übergebene WaitHandle "Freigegeben" ist.

 ThreadPool.RegisterWaitForSingleObject(...) 

.NET verfügt über einen Stream-Timer und unterscheidet sich von WinForms / WPF-Timern dadurch, dass sein Handler in einem Stream aus dem Pool aufgerufen wird.

 System.Threading.Timer 

Es gibt auch eine ziemlich exotische Möglichkeit, einen Delegaten aus dem Pool an den Thread zu senden - die BeginInvoke-Methode.

 DelegateInstance.BeginInvoke 

Ich möchte auch auf die Weitergabe einer Funktion eingehen, die viele der oben genannten Methoden aufruft - CreateThread von der Kernel32.dll Win32-API. Dank des Mechanismus externer Methoden gibt es eine Möglichkeit, diese Funktion aufzurufen. Ich habe eine solche Herausforderung nur einmal in einem schrecklichen Beispiel für Legacy-Code gesehen, und die Motivation des Autors, genau das zu tun, ist mir immer noch ein Rätsel.

 Kernel32.dll CreateThread 

Anzeigen und Debuggen von Threads


Die Threads, die Sie persönlich von allen Komponenten von Drittanbietern und dem .NET-Pool erstellt haben, können im Threads Visual Studio-Fenster angezeigt werden. In diesem Fenster werden Informationen zu Flows nur angezeigt, wenn sich die Anwendung im Debugging befindet und sich im Unterbrechungsmodus (Unterbrechungsmodus) befindet. Hier können Sie bequem die Stapelnamen und Prioritäten jedes Threads anzeigen und das Debuggen auf einen bestimmten Thread umstellen. Mit der Priority-Eigenschaft der Thread-Klasse können Sie die Priorität des Threads festlegen, die OC und CLR als Empfehlung beim Aufteilen der CPU-Zeit zwischen Threads wahrnehmen.



Task parallele Bibliothek


Die Task Parallel Library (TPL) wurde in .NET 4.0 angezeigt. Jetzt ist es der Standard und das Hauptwerkzeug für die Arbeit mit Asynchronität. Jeder Code, der einen älteren Ansatz verwendet, wird als Legacy betrachtet. Die Grundeinheit von TPL ist die Task-Klasse aus dem Namespace System.Threading.Tasks. Aufgabe ist eine Abstraktion über einen Thread. Mit der neuen Version von C # haben wir eine elegante Möglichkeit, mit Task-async / await-Operatoren zu arbeiten. Diese Konzepte ermöglichten es, asynchronen Code so zu schreiben, als ob er einfach und synchron wäre. Dies ermöglichte es sogar Personen mit wenig Verständnis für die interne Küche von Threads, Anwendungen zu schreiben, die sie verwenden, Anwendungen, die bei langen Vorgängen nicht einfrieren. Die Verwendung von async / await ist ein Thema für einen oder sogar mehrere Artikel, aber ich werde versuchen, ein paar Sätze auf den Punkt zu bringen:

  • async ist ein Modifikator der Methode, die Task oder void zurückgibt
  • und warten ist die nicht blockierende Warteanweisung der Aufgabe.

Noch einmal: Der Operator await gibt im allgemeinen Fall (es gibt Ausnahmen) den aktuellen Ausführungsthread weiter frei, und wenn die Task ihre Ausführung beendet hat, kann der Thread (tatsächlich ist es korrekter, den Kontext zu sagen, aber dazu später mehr) die Methode weiter fortsetzen. In .NET wird dieser Mechanismus auf die gleiche Weise wie die Ertragsrückgabe implementiert, wenn eine geschriebene Methode in eine ganze Klasse umgewandelt wird, die eine Zustandsmaschine ist und abhängig von diesen Zuständen in separaten Teilen ausgeführt werden kann. Jeder Interessierte kann jeden einfachen Code mit asyn / await schreiben, kompilieren und die Assembly mit JetBrains dotPeek mit aktiviertem Compiler Generated Code anzeigen.

Berücksichtigen Sie die Optionen zum Starten und Verwenden von Task. Anhand des folgenden Codebeispiels erstellen wir eine neue Aufgabe, die nichts Nützliches tut ( Thread.Sleep (10000) ), aber im wirklichen Leben sollte es sich um eine komplexe CPU-Arbeit handeln.

 using TCO = System.Threading.Tasks.TaskCreationOptions; public static async void VoidAsyncMethod() { var cancellationSource = new CancellationTokenSource(); await Task.Factory.StartNew( // Code of action will be executed on other context () => Thread.Sleep(10000), cancellationSource.Token, TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness, scheduler ); // Code after await will be executed on captured context } 

Die Aufgabe wird mit einer Reihe von Optionen erstellt:

  • LongRunning ist ein Hinweis darauf, dass die Aufgabe nicht schnell erledigt wird. Daher ist es möglicherweise sinnvoll, nicht einen Thread aus dem Pool zu entfernen, sondern einen separaten Thread für diese Aufgabe zu erstellen, um die anderen nicht zu beschädigen.
  • AttachedToParent - Aufgaben können in einer Hierarchie angeordnet werden. Wenn diese Option verwendet wurde, befindet sich die Aufgabe möglicherweise in einem Zustand, in dem sie sich selbst abgeschlossen hat und darauf wartet, dass die untergeordneten Elemente abgeschlossen werden.
  • PreferFairness - bedeutet, dass es schön wäre, die zuvor zur Ausführung gesendeten Aufgaben vor den später gesendeten auszuführen. Dies ist jedoch nur eine Empfehlung und das Ergebnis kann nicht garantiert werden.

Der zweite Parameter der Methode hat CancellationToken übergeben. Um die Stornierung einer Operation nach ihrem Start korrekt zu verarbeiten, muss der ausgeführte Code mit Statusprüfungen von CancellationToken gefüllt werden. Wenn keine Überprüfungen vorhanden sind, kann die für das CancellationTokenSource-Objekt aufgerufene Cancel-Methode die Ausführung der Task erst vor dem Start stoppen.

Der letzte Parameter hat das Scheduler-Objekt vom Typ TaskScheduler übergeben. Diese Klasse und ihre Nachkommen dienen dazu, die Strategien für die Verteilung von Task'ov nach Thread zu steuern. Standardmäßig wird Task in einem zufälligen Thread aus dem Pool ausgeführt.

Der Warteoperator wird auf die erstellte Aufgabe angewendet. Dies bedeutet, dass der danach geschriebene Code, falls vorhanden, im selben Kontext ausgeführt wird (häufig bedeutet dies, dass er sich im selben Thread befindet) wie der Code vor dem Warten.

Die Methode ist als async void markiert. Dies bedeutet, dass Sie den Operator await verwenden können, der aufrufende Code jedoch nicht auf die Ausführung warten kann. Wenn diese Funktion erforderlich ist, sollte die Methode Task zurückgeben. Als asynchron ungültig gekennzeichnete Methoden sind weit verbreitet: In der Regel handelt es sich dabei um Ereignishandler oder andere Methoden, die nach dem Prinzip von Feuer und Vergessen arbeiten. Wenn Sie nicht nur die Möglichkeit geben müssen, bis zum Abschluss der Ausführung zu warten, sondern auch das Ergebnis zurückgeben müssen, müssen Sie Task verwenden.

Bei der Aufgabe, die die StartNew-Methode zurückgegeben hat, können Sie jedoch wie bei jeder anderen die ConfigureAwait-Methode mit dem Parameter false aufrufen. Die Ausführung nach dem Warten wird dann nicht im erfassten, sondern in einem beliebigen Kontext fortgesetzt. Dies sollte immer dann erfolgen, wenn der Ausführungskontext nach dem Warten für den Code nicht wichtig ist. Es ist auch eine Empfehlung von MS beim Schreiben von Code, dass dieser in einer Bibliotheksform verpackt wird.

Lassen Sie uns etwas näher darauf eingehen, wie Sie bis zum Abschluss der Aufgabe warten können. Unten finden Sie einen Beispielcode mit Kommentaren, wenn das Warten bedingt gut und bedingt schlecht durchgeführt wird.

 public static async void AnotherMethod() { int result = await AsyncMethod(); // good result = AsyncMethod().Result; // bad AsyncMethod().Wait(); // bad IEnumerable<Task> tasks = new Task[] { AsyncMethod(), OtherAsyncMethod() }; await Task.WhenAll(tasks); // good await Task.WhenAny(tasks); // good Task.WaitAll(tasks.ToArray()); // bad } 

Im ersten Beispiel warten wir, bis die Aufgabe abgeschlossen ist, und ohne den aufrufenden Thread zu blockieren, kehren wir erst dann zur Verarbeitung des Ergebnisses zurück, wenn es bereits vorhanden ist, bis der aufrufende Thread sich selbst überlassen bleibt.

In der zweiten Option blockieren wir den aufrufenden Thread, bis das Ergebnis der Methode berechnet ist. Dies ist nicht nur deshalb schlecht, weil wir den Thread, eine so wertvolle Ressource des Programms, mit einfachem Leerlauf genommen haben, sondern auch, weil wir einen Deadlock bekommen, wenn der von uns aufgerufene Methodencode wartet und der Synchronisationskontext die Rückkehr zum aufrufenden Thread nach dem Warten beinhaltet : Der aufrufende Thread wartet, bis das Ergebnis der asynchronen Methode berechnet ist. Die asynchrone Methode versucht vergeblich, ihre Ausführung im aufrufenden Thread fortzusetzen.

Ein weiterer Nachteil dieses Ansatzes ist die komplizierte Fehlerbehandlung. Tatsache ist, dass Fehler im asynchronen Code bei Verwendung von async / await sehr einfach zu behandeln sind - sie verhalten sich so, als ob der Code synchron wäre. Wenn wir Exorzismus und synchrone Erwartung auf Task anwenden, wird die ursprüngliche Ausnahme zu einer AggregateException, d. H. Um eine Ausnahme zu behandeln, müssen Sie den InnerException-Typ untersuchen und die if-Kette in einen catch-Block schreiben oder den catch when-Konstrukt anstelle der bekannteren catch-Blockkette in C # verwenden.

Das dritte und das letzte Beispiel sind aus demselben Grund ebenfalls als schlecht markiert und enthalten dieselben Probleme.

Wenn die Methoden WhenAny und WhenAll äußerst praktisch sind, um auf eine Gruppe von Task'ov zu warten, wickeln sie eine Gruppe von Task'ov in eine Gruppe ein, die entweder bei der ersten Operation von Task'a aus der Gruppe funktioniert oder wenn alle ihre Ausführung beendet haben.

Durchflussstopp


Aus verschiedenen Gründen kann es erforderlich sein, den Stream nach dem Start anzuhalten. Es gibt verschiedene Möglichkeiten, dies zu tun. Die Thread-Klasse verfügt über zwei Methoden mit entsprechenden Namen - Abort und Interrupt . Der erste wird nicht zur Verwendung empfohlen, da Nachdem es zu einem beliebigen Zeitpunkt aufgerufen wurde, wird während der Verarbeitung einer Anweisung eine ThreadAbortedException ausgelöst. Sie erwarten nicht, dass eine solche Ausnahme beim Inkrementieren einer Ganzzahlvariablen abstürzt, oder? Bei dieser Methode ist dies eine sehr reale Situation. Wenn Sie verhindern möchten, dass die CLR eine solche Ausnahme in einem bestimmten Abschnitt des Codes auslöst , können Sie sie in Aufrufe von Thread.BeginCriticalRegion , Thread.EndCriticalRegion einschließen . Jeder Code, der in einen finally-Block geschrieben wird, wird mit solchen Aufrufen umbrochen. Aus diesem Grund finden Sie im Darm des Framework-Codes Blöcke mit einem leeren Versuch, aber schließlich nicht mit einem leeren. Microsoft empfiehlt daher nicht, diese Methode zu verwenden, da sie nicht in den .net-Kern aufgenommen wurde.

Die Interrupt-Methode funktioniert vorhersehbarer. Es kann einen Thread mit Ausnahme von ThreadInterruptedException nur unterbrechen , wenn sich der Thread im Ruhezustand befindet. In diesem Zustand wird es angehalten, während auf WaitHandle gewartet, gesperrt oder Thread.Sleep aufgerufen wird.

Beide oben beschriebenen Optionen sind schlecht für ihre Unvorhersehbarkeit. Die Lösung besteht darin, die CancellationToken- Struktur und die CancellationTokenSource- Klasse zu verwenden. Das Fazit lautet: Eine Instanz der Klasse CancellationTokenSource wird erstellt, und nur die Person, deren Eigentümer sie ist, kann den Vorgang durch Aufrufen der Cancel- Methode stoppen. Nur das CancellationToken wird an die Operation selbst übergeben. Besitzer des CancellationToken können den Vorgang nicht selbst abbrechen, sondern nur prüfen, ob der Vorgang abgebrochen wurde. Zu diesem Zweck gibt es eine boolesche Eigenschaft IsCancellationRequested und die ThrowIfCancelRequested- Methode. Letzteres löst eine TaskCancelledException aus, wenn die Cancel-Methode für die abgebrochene CancellationToken-Instanz der CancellationTokenSource aufgerufen wird. Und diese Methode empfehle ich. Dies ist besser als die vorherigen Optionen, da Sie die vollständige Kontrolle darüber erlangen, an welchen Punkten der Ausnahmevorgang unterbrochen werden kann.

Die grausamste Option, um den Thread zu stoppen, ist das Aufrufen der Win32-API-Funktion TerminateThread. Das Verhalten der CLR nach dem Aufruf dieser Funktion kann unvorhersehbar sein. In MSDN wird über diese Funktion Folgendes geschrieben: „TerminateThread ist eine gefährliche Funktion, die nur in den extremsten Fällen verwendet werden sollte.

Konvertieren Sie die Legacy-API mithilfe der FromAsync-Methode in eine aufgabenbasierte API


Wenn Sie das Glück haben, an einem Projekt zu arbeiten, das nach der Einführung der Aufgaben gestartet wurde und für die meisten Entwickler keinen stillen Horror mehr verursacht, müssen Sie sich nicht mit vielen alten APIs befassen, sowohl mit Drittanbietern als auch mit solchen, die Ihr Team in der Vergangenheit gefoltert hat. Glücklicherweise hat sich das .NET Framework-Entwicklungsteam um uns gekümmert, obwohl das Ziel vielleicht darin bestand, auf uns selbst aufzupassen. Wie auch immer, .NET verfügt über eine Reihe von Tools, mit denen Sie Code, der in alten asynchronen Programmieransätzen geschrieben wurde, problemlos in einen neuen konvertieren können. Eine davon ist die FromAsync-Methode von TaskFactory. Anhand des folgenden Codebeispiels verpacke ich die alten asynchronen Methoden der WebRequest-Klasse mit dieser Methode in Task.

 object state = null; WebRequest wr = WebRequest.CreateHttp("http://github.com"); await Task.Factory.FromAsync( wr.BeginGetResponse, we.EndGetResponse ); 

Dies ist nur ein Beispiel, und es ist unwahrscheinlich, dass Sie dies mit integrierten Typen tun, aber in jedem alten Projekt wimmelt es nur so von BeginDoSomething-Methoden, die IAsyncResult- und EndDoSomething-Methoden zurückgeben, die dies akzeptieren.

Konvertieren Sie die Legacy-API mithilfe der TaskCompletionSource-Klasse in Task Based


Ein weiteres wichtiges Werkzeug ist die TaskCompletionSource- Klasse. In Bezug auf Funktionen, Zweck und Funktionsprinzip kann es die RegisterWaitForSingleObject-Methode irgendwie an die ThreadPool-Klasse erinnern, über die ich oben geschrieben habe. Mit dieser Klasse können Sie alte asynchrone APIs einfach und bequem in Task einbinden.

Sie werden sagen, dass ich bereits über die FromAsync-Methode der TaskFactory-Klasse gesprochen habe, die für diese Zwecke vorgesehen ist. Hier müssen wir uns an die gesamte Geschichte der Entwicklung von asynchronen Modellen in .net erinnern, die Microsoft seit 15 Jahren anbietet: Vor dem Task-Based Asynchronous Pattern (TAP) gab es Asynchronous Programming Pattern (APP), bei dem es um Begin DoSomething-Methoden ging, die IAsyncResult- und End DoSomething-Methoden zurückgeben, die dies akzeptieren Die FromAsync-Methode ist für das Erbe dieser Jahre in Ordnung, wurde jedoch im Laufe der Zeit durch ein ereignisbasiertes asynchrones Muster (Event Based Asynchronous Pattern, EAP ) ersetzt, bei dem davon ausgegangen wurde, dass ein Ereignis ausgelöst wird, wenn der asynchrone Vorgang abgeschlossen ist.

TaskCompletionSource eignet sich hervorragend zum Einschließen von Task- und Legacy-APIs, die auf dem Ereignismodell basieren. Das Wesentliche seiner Arbeit ist folgender: Ein Objekt dieser Klasse hat eine öffentliche Eigenschaft vom Typ Task, deren Status über die Methoden SetResult, SetException usw. der TaskCompletionSource-Klasse gesteuert werden kann. An Stellen, an denen der Operator "Warten" auf diese Aufgabe angewendet wurde, wird er abhängig von der auf die TaskCompletionSource angewendeten Methode mit einer Ausnahme ausgeführt oder stürzt ab. Wenn immer noch nicht alles klar ist, schauen wir uns dieses Codebeispiel an, in dem eine alte EAP-API mithilfe von TaskCompletionSource in Task eingeschlossen ist: Wenn das Ereignis ausgelöst wird, wird die Task in den Status "Abgeschlossen" versetzt, und die Methode, mit der der Operator "Warten" auf diese Task angewendet wurde, setzt die Ausführung fort das Ergebnisobjekt erhalten .

 public static Task<Result> DoAsync(this SomeApiInstance someApiObj) { var completionSource = new TaskCompletionSource<Result>(); someApiObj.Done += result => completionSource.SetResult(result); someApiObj.Do(); result completionSource.Task; } 

Tipps und Tricks zu TaskCompletionSource


Das Umschließen älterer APIs ist nicht alles, was Sie mit TaskCompletionSource tun können. Die Verwendung dieser Klasse eröffnet eine interessante Möglichkeit, verschiedene APIs für Aufgaben zu entwerfen, die keine Threads belegen. Und der Fluss ist, wie wir uns erinnern, eine teure Ressource und ihre Anzahl ist begrenzt (hauptsächlich durch RAM). Diese Einschränkung lässt sich leicht erreichen, indem beispielsweise eine geladene Webanwendung mit komplexer Geschäftslogik entwickelt wird. Betrachten Sie die Möglichkeiten, über die ich die Implementierung eines Tricks wie Long-Polling spreche.

Kurz gesagt, der Kern des Tricks ist folgender: Sie müssen Informationen von der API über einige Ereignisse abrufen, die auf ihrer Seite auftreten, während die API aus irgendeinem Grund das Ereignis nicht melden kann, sondern nur den Status zurückgeben kann. Ein Beispiel hierfür sind alle APIs, die vor WebSocket oder wenn es aus irgendeinem Grund nicht möglich ist, diese Technologie zu verwenden, auf HTTP basieren. Der Client kann den HTTP-Server fragen. Ein HTTP-Server kann selbst keine Kommunikation mit einem Client provozieren. Eine einfache Lösung besteht darin, den Server nach Timer abzufragen. Dies führt jedoch zu einer zusätzlichen Belastung des Servers und einer zusätzlichen Verzögerung eines durchschnittlichen TimerInterval / 2. Um dies zu umgehen, wurde ein Trick namens Long Polling erfunden, bei dem die Antwort vom Server bis zum Ablauf des Timeouts oder verzögert wird ein Ereignis wird passieren. Wenn ein Ereignis aufgetreten ist, wird es verarbeitet. Wenn nicht, wird die Anforderung erneut gesendet.

 while(!eventOccures && !timeoutExceeded) { CheckTimout(); CheckEvent(); Thread.Sleep(1); } 

Aber eine solche Lösung wird sich schrecklich zeigen, sobald die Anzahl der Kunden, die auf die Veranstaltung warten, steigt, weil Jeder dieser Kunden nimmt im Vorgriff auf das Ereignis einen ganzen Strom auf. Ja, und wir erhalten eine zusätzliche Verzögerung von 1 ms beim Auslösen des Ereignisses. Meistens ist dies nicht signifikant, aber warum sollte die Software schlechter als möglich sein? Wenn Sie Thread.Sleep (1) entfernen, laden wir vergeblich einen Prozessorkern zu 100% im Leerlauf und drehen uns in einem nutzlosen Zyklus. Mit TaskCompletionSource können Sie diesen Code einfach wiederholen und alle oben genannten Probleme lösen:

 class LongPollingApi { private Dictionary<int, TaskCompletionSource<Msg>> tasks; public async Task<Msg> AcceptMessageAsync(int userId, int duration) { var cs = new TaskCompletionSource<Msg>(); 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 Demo. Um es in realen Fällen zu verwenden, müssen Sie zumindest die Situation behandeln, in der eine Nachricht zu einem Zeitpunkt eintrifft, zu dem niemand sie erwartet: In diesem Fall sollte die AsseptMessageAsync-Methode eine bereits abgeschlossene Aufgabe zurückgeben. Wenn dieser Fall am häufigsten auftritt, können Sie über die Verwendung von ValueTask nachdenken.

Nach Erhalt einer Anforderung für eine Nachricht erstellen wir TaskCompletionSource und platzieren es im Wörterbuch. Anschließend warten wir darauf, was zuerst passiert: Das angegebene Zeitintervall läuft ab oder eine Nachricht wird empfangen.

ValueTask: warum und wie


Async / await-Operatoren generieren wie der Yield Return-Operator eine Zustandsmaschine aus der Methode, die ein neues Objekt erstellt, was fast immer nicht wichtig ist, aber in seltenen Fällen ein Problem verursachen kann. Dieser Fall kann eine Methode sein, die sehr oft aufgerufen wird und über Zehntausende von Anrufen pro Sekunde spricht. Wenn eine solche Methode so geschrieben ist, dass sie in den meisten Fällen ein Ergebnis zurückgibt, das alle Wartemethoden umgeht, bietet .NET ein Tool zur Optimierung dieser Methode - die ValueTask-Struktur. Betrachten Sie zur Verdeutlichung ein Beispiel für seine Verwendung: Es gibt einen Cache, in den wir sehr oft gehen. Es sind einige Werte darin und dann geben wir sie einfach zurück. Wenn nicht, gehen wir zu einem langsamen E / A hinter ihnen. Letzteres möchte ich asynchron machen, was bedeutet, dass die gesamte Methode asynchron ist. Daher ist der offensichtliche Weg, eine Methode zu schreiben, wie folgt:

 public async Task<string> GetById(int id) { if (cache.TryGetValue(id, out string val)) return val; return await RequestById(id); } 

Aufgrund des Wunsches, ein wenig zu optimieren, und der leichten Angst, was Roslyn durch das Kompilieren dieses Codes erzeugen wird, können wir dieses Beispiel wie folgt umschreiben:

 public Task<string> GetById(int id) { if (cache.TryGetValue(id, out string val)) return Task.FromResult(val); return RequestById(id); } 

In der Tat besteht die optimale Lösung in diesem Fall darin, den Hot-Path zu optimieren, nämlich den Wert aus dem Wörterbuch ohne zusätzliche Zuordnungen und Belastung des GC abzurufen, während in den seltenen Fällen, in denen wir noch zum E / A gehen müssen, alles plus bleibt / minus alt:

 public ValueTask<string> GetById(int id) { if (cache.TryGetValue(id, out string val)) return new ValueTask<string>(val); return new ValueTask<string>(RequestById(id)); } 

Schauen wir uns dieses Codefragment genauer an: Wenn sich ein Wert im Cache befindet, erstellen wir eine Struktur, andernfalls wird die eigentliche Aufgabe in eine signifikante Aufgabe eingeschlossen. Dem aufrufenden Code ist es egal, wie dieser Code ausgeführt wurde: ValueTask verhält sich aus Sicht der C # -Syntax genau wie die in diesem Fall übliche Aufgabe.

TaskSchedulers: Verwalten von Task-Startstrategien


Die nächste API, die ich in Betracht ziehen möchte, ist die TaskScheduler- Klasse und ihre Ableitungen. Ich habe oben bereits erwähnt, dass es in TPL die Möglichkeit gibt, die Strategien für die Verteilung von Task'ov nach Thread zu steuern. Solche Strategien sind in den Nachkommen der TaskScheduler-Klasse definiert. Fast jede Strategie, die möglicherweise benötigt wird, befindet sich in der ParallelExtensionsExtras- Bibliothek , die von Microsoft entwickelt wurde, jedoch nicht Teil von .NET ist, sondern als Nuget-Paket geliefert wird. Betrachten wir einige davon kurz:

  • CurrentThreadTaskScheduler - Führt eine Aufgabe für den aktuellen Thread aus
  • LimitedConcurrencyLevelTaskScheduler - begrenzt die Anzahl gleichzeitig ausgeführter Aufgaben auf den Parameter N, der im Konstruktor akzeptiert wird
  • OrderedTaskScheduler — LimitedConcurrencyLevelTaskScheduler(1), .
  • WorkStealingTaskSchedulerwork-stealing . ThreadPool. , .NET ThreadPool , , . . T.O. WorkStealingTaskScheduler' , ThreadPool .
  • QueuedTaskScheduler - Ermöglicht das Ausführen von Aufgaben gemäß den Warteschlangenregeln mit Prioritäten
  • ThreadPerTaskScheduler - Erstellt einen separaten Thread für jede Aufgabe, die darauf ausgeführt wird. Dies kann für Aufgaben nützlich sein, die unvorhersehbar lange dauern.

Es gibt einen guten detaillierten Artikel über TaskSchedulers im Microsoft-Blog.

Zum bequemen Debuggen aller Aufgaben in Visual Studio gibt es ein Aufgabenfenster. In diesem Fenster können Sie den aktuellen Status der Aufgabe anzeigen und zur aktuell ausgeführten Codezeile wechseln.



PLinq und die Parallel-Klasse


Neben Task und allem, was in .NET mit ihnen gesagt wurde, gibt es zwei weitere interessante Tools: PLinq (Linq2Parallel) und die Parallel-Klasse. Die erste verspricht die parallele Ausführung aller Linq-Operationen auf mehreren Threads. Die Anzahl der Threads kann mit der WithDegreeOfParallelism-Erweiterungsmethode konfiguriert werden. Leider verfügt PLinq im Ausführungsmodus meistens nicht über genügend Informationen zu den Innenseiten Ihrer Datenquelle, um einen signifikanten Geschwindigkeitsgewinn zu erzielen. Andererseits ist der Versuchspreis sehr niedrig: Sie müssen nur die AsParallel-Methode vor der Linq-Methodenkette aufrufen und Leistungstests durchführen. Darüber hinaus ist es möglich, mithilfe des Partitionsmechanismus zusätzliche Informationen über die Art Ihrer Datenquelle an PLinq zu übertragen. Hier und hier können Sie mehr lesen ..

Die statische Parallel-Klasse bietet Methoden zum parallelen Durchlaufen einer Foreach-Auflistung, zum Ausführen einer For-Schleife und zum parallelen Ausführen mehrerer Delegaten zu Invoke. Die Ausführung des aktuellen Threads wird bis zum Ende der Berechnungen gestoppt. Die Anzahl der Threads kann konfiguriert werden, indem ParallelOptions als letztes Argument übergeben wird. Mithilfe von Optionen können Sie auch TaskScheduler und CancellationToken angeben.

Schlussfolgerungen


Als ich anfing, diesen Artikel basierend auf den Materialien meines Berichts und den Informationen zu schreiben, die ich während der Arbeit danach gesammelt hatte, hatte ich nicht erwartet, dass es so viel werden würde. Wenn mir der Texteditor, in den ich diesen Artikel schreibe, vorwurfsvoll mitteilt, dass die 15. Seite verschwunden ist, fasse ich die Zwischenergebnisse zusammen. Weitere Tricks, APIs, visuelle Tools und Fallstricke werden in einem zukünftigen Artikel behandelt.

Schlussfolgerungen:

  • , , .
  • .NET
  • , legacy, API .
  • .NET Thread ThreadPool
  • Thread.Abort, Thread.Interrupt, Win32 API TerminateThread . CancellationToken'
  • — , . , . TaskCompletionSource
  • .NET Task'.
  • c# async/await
  • Task' TaskScheduler'
  • ValueTask hot-paths memory-traffic
  • Tasks Threads Visual Studio
  • PLinq , , partitioning
  • ...

Source: https://habr.com/ru/post/de452094/


All Articles