.NET: Tools für die Arbeit mit Multithreading und Asynchronität. Teil 1

Ich veröffentliche den Originalartikel auf Habr, dessen Übersetzung im Unternehmen veröffentlicht ist блоге.

Die Notwendigkeit, etwas asynchron zu erledigen, ohne hier und jetzt auf das Ergebnis zu warten, oder große Arbeit auf mehrere ausführende Einheiten aufzuteilen, bestand bereits vor dem Aufkommen von Computern. Mit ihrem Aufkommen wurde dieses Bedürfnis sehr greifbar. Jetzt, im Jahr 2019, schreibe ich diesen Artikel auf einem Laptop mit einem 8-Kern-Intel-Core-Prozessor, auf dem mehr als hundert Prozesse und noch mehr Threads parallel laufen. In der Nähe steht ein etwas heruntergekommenes Telefon, vor ein paar Jahren gekauft, es hat einen 8-Kern-Prozessor an Bord. Die thematischen Ressourcen sind voll von Artikeln und Videos, in denen ihre Autoren die diesjährigen Flaggschiff-Smartphones mit 16-Core-Prozessoren bewundern. MS Azure stellt eine virtuelle Maschine mit einem 20-Kern-Prozessor und 128 TB RAM für weniger als 2 US-Dollar pro Stunde bereit. Leider ist es unmöglich, das Maximum herauszuholen und diese Leistung zu nutzen, ohne das Zusammenspiel der Threads verwalten zu können.

Vocabulary

Verfahren - Betriebssystemobjekt, isolierter Adressraum, enthält Threads.
Faden - ein Betriebssystemobjekt, die kleinste Ausführungseinheit, Teil eines Prozesses; Threads teilen sich innerhalb eines Prozesses Speicher und andere Ressourcen.
Multitasking - Betriebssystemeigenschaft, die Fähigkeit, mehrere Prozesse gleichzeitig auszuführen
Mehrkernig - eine Eigenschaft des Prozessors, die Fähigkeit, mehrere Kerne zur Datenverarbeitung zu nutzen
Mehrfachverarbeitung - eine Eigenschaft eines Computers, die Fähigkeit, physisch gleichzeitig mit mehreren Prozessoren zu arbeiten
Multithreading — eine Eigenschaft eines Prozesses, die Fähigkeit, die Datenverarbeitung auf mehrere Threads zu verteilen.
Parallelität - Pro Zeiteinheit mehrere Aktionen physisch gleichzeitig ausführen
Asynchronität — Ausführung einer Operation, ohne auf den Abschluss 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 füge ich der formal eingeführten Terminologie eine Metapher über das Kochen des Frühstücks hinzu. In dieser Metapher ist das Frühstück kochen ein Prozess.

Während ich morgens das Frühstück vorbereite, habe ich (CPU) Ich komme in die Küche (Computer). Ich habe 2 Hände (Farben). In der Küche gibt es eine Reihe von Geräten (IO): Backofen, Wasserkocher, Toaster, Kühlschrank. Ich schalte das Gas ein, stelle eine Bratpfanne darauf und gieße Öl hinein, ohne darauf zu warten, dass es heiß wird (asynchron, Non-Blocking-IO-Wait), ich nehme die Eier aus dem Kühlschrank und schlage sie auf einen Teller, dann schlage ich sie mit einer Hand (Thread Nr. 1), und zweitens (Thread Nr. 2) hält die Platte (gemeinsame Ressource). Jetzt würde ich gerne den Wasserkocher anstellen, aber ich habe nicht genug Hände (Thread-Hunger) Während dieser Zeit heizt sich die Bratpfanne auf (Verarbeitung des Ergebnisses), in die ich das Geschlagene gieße. Ich greife nach dem Wasserkocher, schalte ihn ein und sehe dumm zu, wie das Wasser darin kocht (Blockieren-IO-Warten), obwohl er in dieser Zeit den Teller, auf dem er das Omelett aufgeschlagen hat, hätte abwaschen können.

Ich habe ein Omelett nur mit zwei Händen zubereitet, und mehr habe ich nicht, aber gleichzeitig fanden im Moment des Aufschlagens des Omeletts drei Vorgänge gleichzeitig statt: das Aufschlagen des Omeletts, das Halten des Tellers, das Erhitzen der Bratpfanne . Die CPU ist der schnellste Teil des Computers, IO ist es, der am häufigsten alles verlangsamt, daher besteht eine effektive Lösung oft darin, die CPU mit etwas zu belegen, während Daten von IO empfangen werden.

Fortsetzung der Metapher:

  • Wenn ich beim Zubereiten eines Omeletts auch versuchen würde, mich umzuziehen, wäre das ein Beispiel für Multitasking. Eine wichtige Nuance: Computer können das viel besser als Menschen.
  • Eine Küche mit mehreren Köchen, zum Beispiel in einem Restaurant – ein Multicore-Computer.
  • Viele Restaurants in einem Food-Court in einem Einkaufszentrum – Rechenzentrum

.NET-Tools

.NET ist wie bei vielen anderen Dingen gut darin, mit Threads zu arbeiten. Mit jeder neuen Version werden immer mehr neue Tools für die Arbeit mit ihnen eingeführt, neue Abstraktionsebenen über Betriebssystem-Threads. Bei der Erstellung von Abstraktionen verwenden Framework-Entwickler einen Ansatz, der bei Verwendung einer Abstraktion auf hoher Ebene die Möglichkeit lässt, eine oder mehrere Ebenen darunter abzusteigen. In den meisten Fällen ist dies nicht notwendig, es öffnet sogar die Tür dazu, sich selbst mit einer Schrotflinte 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 dem aktuellen Abstraktionsniveau nicht gelöst ist .

Mit Tools meine ich sowohl Anwendungsprogrammierschnittstellen (APIs), die vom Framework und Paketen von Drittanbietern bereitgestellt werden, als auch komplette Softwarelösungen, die die Suche nach Problemen im Zusammenhang mit Multithread-Code vereinfachen.

Einen Thread starten

Die Thread-Klasse ist 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 im neu erstellten Thread nach Aufruf der Start-Methode 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 beliebige lokale Informationen an den Stream zu übertragen. Es ist erwähnenswert, dass das Erstellen eines Threads ein teurer Vorgang ist und der Thread selbst ein schweres Objekt ist, zumindest weil er 1 MB Speicher auf dem Stapel zuweist und eine Interaktion mit der Betriebssystem-API erfordert.

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

Die ThreadPool-Klasse repräsentiert das Konzept eines Pools. In .NET ist der Thread-Pool ein Teil der Technik, und die Entwickler bei Microsoft haben große Anstrengungen unternommen, um sicherzustellen, dass er in einer Vielzahl von Szenarien optimal funktioniert.

Allgemeines Konzept:

Sobald die Anwendung gestartet wird, werden im Hintergrund mehrere Threads in Reserve erstellt und die Möglichkeit bereitgestellt, diese zur Verwendung zu nutzen. Wenn Threads häufig und in großer Zahl verwendet werden, erweitert sich der Pool entsprechend den Anforderungen des Aufrufers. Wenn zum richtigen Zeitpunkt keine freien Threads im Pool vorhanden sind, wird entweder auf die Rückkehr eines Threads gewartet oder ein neuer Thread erstellt. Daraus folgt, dass der Thread-Pool für einige kurzfristige Aktionen gut geeignet ist und für Vorgänge, die während des gesamten Betriebs der Anwendung als Dienste ausgeführt werden, schlecht geeignet ist.

Um einen Thread aus dem Pool zu verwenden, gibt es eine QueueUserWorkItem-Methode, die einen Delegaten vom Typ WaitCallback 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 zum Organisieren nicht blockierender IO-Operationen verwendet. Der an diese Methode übergebene Delegat wird aufgerufen, wenn das an die Methode übergebene WaitHandle „Released“ ist.

ThreadPool.RegisterWaitForSingleObject(...)

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

System.Threading.Timer

Es gibt auch eine eher exotische Möglichkeit, einen Delegaten zur Ausführung aus dem Pool an einen Thread zu senden – die BeginInvoke-Methode.

DelegateInstance.BeginInvoke

Ich möchte kurz auf die Funktion eingehen, die viele der oben genannten Methoden aufrufen kann – CreateThread von der Win32-API Kernel32.dll. Dank des Mechanismus externer Methoden gibt es eine Möglichkeit, diese Funktion aufzurufen. Ich habe einen solchen Aufruf nur einmal in einem schrecklichen Beispiel von Legacy-Code gesehen, und die Motivation des Autors, der genau das getan hat, bleibt mir immer noch ein Rätsel.

Kernel32.dll CreateThread

Threads anzeigen und debuggen

Von Ihnen erstellte Threads, alle Komponenten von Drittanbietern und der .NET-Pool können im Threads-Fenster von Visual Studio angezeigt werden. In diesem Fenster werden Thread-Informationen nur angezeigt, wenn sich die Anwendung im Debug-Modus und im Unterbrechungsmodus befindet. Hier können Sie bequem die Stapelnamen und Prioritäten jedes Threads anzeigen und das Debuggen auf einen bestimmten Thread umschalten. Mithilfe der Priority-Eigenschaft der Thread-Klasse können Sie die Priorität eines Threads festlegen, die von OC und CLR als Empfehlung bei der Aufteilung der Prozessorzeit zwischen Threads interpretiert wird.

.NET: Tools für die Arbeit mit Multithreading und Asynchronität. Teil 1

Task Parallele Bibliothek

Die Task Parallel Library (TPL) wurde in .NET 4.0 eingeführt. Heute ist es der Standard und das Hauptwerkzeug für die Arbeit mit Asynchronität. Jeder Code, der einen älteren Ansatz verwendet, gilt als veraltet. Die Grundeinheit von TPL ist die Task-Klasse aus dem System.Threading.Tasks-Namespace. Eine Aufgabe ist eine Abstraktion über einen Thread. Mit der neuen Version der C#-Sprache haben wir eine elegante Möglichkeit, mit Aufgaben zu arbeiten – Async/Wait-Operatoren. Diese Konzepte machten es möglich, asynchronen Code so zu schreiben, als wäre er einfach und synchron. Dies ermöglichte es auch Leuten mit wenig Verständnis für die interne Funktionsweise von Threads, Anwendungen zu schreiben, die sie verwenden, Anwendungen, die bei der Ausführung langer Vorgänge nicht einfrieren. Die Verwendung von async/await ist ein Thema für einen oder sogar mehrere Artikel, aber ich versuche, das Wesentliche in ein paar Sätzen zusammenzufassen:

  • async ist ein Modifikator einer Methode, die Task oder void zurückgibt
  • und „await“ ist ein nicht blockierender Task-Warteoperator.

Noch einmal: Der Wait-Operator gibt im allgemeinen Fall (es gibt Ausnahmen) den aktuellen Ausführungsthread weiter frei, und wenn die Aufgabe ihre Ausführung beendet, und den Thread (eigentlich wäre es richtiger, den Kontext zu sagen). , aber dazu später mehr) wird die Methode weiterhin ausführen. In .NET wird dieser Mechanismus auf die gleiche Weise wie Yield Return implementiert, wenn die 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 async/await schreiben, die Assembly mit JetBrains dotPeek kompilieren und anzeigen, wobei der vom Compiler generierte Code aktiviert ist.

Schauen wir uns die Optionen zum Starten und Verwenden von Task an. Im folgenden Codebeispiel erstellen wir eine neue Aufgabe, die nichts Nützliches tut (Thread.Schlaf(10000)), aber im wirklichen Leben sollte dies eine komplexe CPU-intensive Arbeit sein.

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
}

Eine Aufgabe wird mit einer Reihe von Optionen erstellt:

  • LongRunning ist ein Hinweis darauf, dass die Aufgabe nicht schnell erledigt sein wird. Daher kann es sinnvoll sein, darüber nachzudenken, keinen Thread aus dem Pool zu nehmen, sondern einen separaten Thread für diese Aufgabe zu erstellen, um anderen keinen Schaden zuzufügen.
  • 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 selbst abgeschlossen wurde und auf die Ausführung ihrer untergeordneten Aufgaben wartet.
  • PreferFairness – bedeutet, dass es besser wäre, früher zur Ausführung gesendete Aufgaben auszuführen, als später gesendete Aufgaben. Dies ist jedoch nur eine Empfehlung und die Ergebnisse sind nicht garantiert.

Der zweite an die Methode übergebene Parameter ist CancellationToken. Um den Abbruch eines Vorgangs nach dem Start korrekt zu handhaben, muss der ausgeführte Code mit Prüfungen für den CancellationToken-Status gefüllt sein. Wenn keine Prüfungen vorhanden sind, kann die für das CancellationTokenSource-Objekt aufgerufene Methode „Cancel“ die Ausführung der Aufgabe nur stoppen, bevor sie beginnt.

Der letzte Parameter ist ein Scheduler-Objekt vom Typ TaskScheduler. Diese Klasse und ihre Nachkommen sollen Strategien zur Verteilung von Aufgaben auf Threads steuern; standardmäßig wird die Aufgabe in einem zufälligen Thread aus dem Pool ausgeführt.

Der Wait-Operator wird auf die erstellte Task angewendet, was bedeutet, dass der danach geschriebene Code, falls vorhanden, im selben Kontext (häufig bedeutet dies im selben Thread) wie der Code vor dem Wait ausgeführt wird.

Die Methode ist als async void markiert, was bedeutet, dass sie den Wait-Operator verwenden kann, der aufrufende Code jedoch nicht auf die Ausführung warten kann. Wenn eine solche Funktion erforderlich ist, muss die Methode Task zurückgeben. Mit async void gekennzeichnete Methoden sind weit verbreitet: In der Regel handelt es sich dabei um Event-Handler oder andere Methoden, die nach dem Fire-and-Forget-Prinzip arbeiten. Wenn Sie nicht nur die Möglichkeit geben müssen, bis zum Ende der Ausführung zu warten, sondern auch das Ergebnis zurückzugeben, müssen Sie Task verwenden.

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

Lassen Sie uns etwas näher darauf eingehen, wie Sie auf den Abschluss einer Aufgabe warten können. Nachfolgend finden Sie ein Codebeispiel mit Kommentaren dazu, wann die Erwartung bedingt gut und wann bedingt schlecht erfüllt 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 darauf, dass die Aufgabe abgeschlossen ist, ohne den aufrufenden Thread zu blockieren; wir kehren zur Verarbeitung des Ergebnisses erst zurück, wenn es bereits da ist; bis dahin bleibt der aufrufende Thread sich selbst überlassen.

Bei der zweiten Option blockieren wir den aufrufenden Thread, bis das Ergebnis der Methode berechnet ist. Das ist nicht nur deshalb schlecht, weil wir einen Thread, eine so wertvolle Ressource des Programms, mit einfachem Leerlauf belegt haben, sondern auch, weil der Code der von uns aufgerufenen Methode „await“ enthält und der Synchronisationskontext danach eine Rückkehr zum aufrufenden Thread erfordert warten, dann bekommen wir einen Deadlock: Der aufrufende Thread wartet, während das Ergebnis der asynchronen Methode berechnet wird, 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 der Verwendung von async/await sehr einfach zu handhaben sind – sie verhalten sich genauso, als ob der Code synchron wäre. Wenn wir hingegen den synchronen Warteauszug auf eine Aufgabe anwenden, verwandelt sich die ursprüngliche Ausnahme in eine AggregateException, d. h. Um die Ausnahme zu behandeln, müssen Sie den InnerException-Typ untersuchen und selbst eine if-Kette innerhalb eines Catch-Blocks schreiben oder das Konstrukt „catch when“ anstelle der Kette von Catch-Blöcken verwenden, die in der C#-Welt bekannter ist.

Das dritte und letzte Beispiel wird aus demselben Grund ebenfalls als schlecht bewertet und enthält dieselben Probleme.

Die Methoden „WhenAny“ und „WhenAll“ sind äußerst praktisch, um auf eine Gruppe von Aufgaben zu warten. Sie verpacken eine Gruppe von Aufgaben in eine, die entweder ausgelöst wird, wenn eine Aufgabe aus der Gruppe zum ersten Mal ausgelöst wird oder wenn alle Aufgaben ihre Ausführung abgeschlossen haben.

Threads stoppen

Aus verschiedenen Gründen kann es notwendig sein, den Fluss nach seinem Beginn zu stoppen. Es gibt verschiedene Möglichkeiten, dies zu tun. Die Thread-Klasse verfügt über zwei entsprechend benannte Methoden: Abbrechen и Unterbrechen. Die Verwendung des ersten wird dringend empfohlen, weil Nach dem Aufruf zu einem beliebigen Zeitpunkt während der Verarbeitung einer Anweisung wird eine Ausnahme ausgelöst ThreadAbortedException. Sie erwarten nicht, dass eine solche Ausnahme beim Erhöhen einer ganzzahligen Variablen ausgelöst wird, oder? Und wenn man diese Methode anwendet, ist das eine sehr reale Situation. Wenn Sie verhindern müssen, dass die CLR in einem bestimmten Codeabschnitt eine solche Ausnahme generiert, können Sie sie in Aufrufe einschließen Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Jeder in einem „finally“-Block geschriebene Code wird in solche Aufrufe eingeschlossen. Aus diesem Grund findet man in den Tiefen des Framework-Codes Blöcke mit einem leeren try, aber nicht mit einem leeren final. Microsoft rät so sehr von dieser Methode ab, dass sie sie nicht in den .net-Kern aufgenommen hat.

Die Interrupt-Methode funktioniert vorhersehbarer. Es kann den Thread mit einer Ausnahme unterbrechen ThreadInterruptedException nur in den Momenten, in denen sich der Thread im Wartezustand befindet. Es tritt in diesen Zustand ein, während es hängt, während es auf WaitHandle wartet, sperrt oder nach dem Aufruf von Thread.Sleep.

Beide oben beschriebenen Optionen sind aufgrund ihrer Unvorhersehbarkeit schlecht. Die Lösung besteht darin, eine Struktur zu verwenden StornierungToken und Klasse CancellationTokenSource. Der Punkt ist folgender: Es wird eine Instanz der Klasse „CancellationTokenSource“ erstellt und nur derjenige, der sie besitzt, kann den Vorgang durch Aufrufen der Methode stoppen Abbrechen. An den Vorgang selbst wird nur das CancellationToken übergeben. Besitzer von CancellationToken können den Vorgang nicht selbst abbrechen, sondern nur prüfen, ob der Vorgang abgebrochen wurde. Dafür gibt es eine boolesche Eigenschaft IsCancellationRequested und Methode ThrowIfCancelRequested. Letzteres wird eine Ausnahme auslösen TaskCancelledException wenn die Cancel-Methode für die abzubrechende CancellationToken-Instanz aufgerufen wurde. Und das ist die Methode, die ich empfehle. Dies stellt eine Verbesserung gegenüber den vorherigen Optionen dar, da Sie die volle Kontrolle darüber erhalten, an welchem ​​Punkt ein Ausnahmevorgang abgebrochen werden kann.

Die brutalste Option zum Stoppen eines Threads ist der Aufruf der TerminateThread-Funktion der Win32-API. Das Verhalten der CLR nach dem Aufruf dieser Funktion kann unvorhersehbar sein. Auf MSDN steht zu dieser Funktion Folgendes: „TerminateThread ist eine gefährliche Funktion, die nur in den extremsten Fällen verwendet werden sollte. „

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

Wenn Sie das Glück haben, an einem Projekt zu arbeiten, das nach der Einführung von Tasks gestartet wurde und bei den meisten Entwicklern kein stilles Entsetzen mehr auslöste, müssen Sie sich nicht mit vielen alten APIs auseinandersetzen, sowohl von Drittanbietern als auch von Ihrem Team hat in der Vergangenheit gefoltert. Glücklicherweise hat sich das .NET Framework-Team um uns gekümmert, obwohl das Ziel vielleicht darin bestand, auf uns selbst aufzupassen. Wie dem auch sei, .NET verfügt über eine Reihe von Tools zum problemlosen Konvertieren von Code, der in alten asynchronen Programmieransätzen geschrieben wurde, in den neuen. Eine davon ist die FromAsync-Methode von TaskFactory. Im folgenden Codebeispiel verbinde ich die alten asynchronen Methoden der WebRequest-Klasse mit dieser Methode in eine 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 müssen, aber in jedem alten Projekt wimmelt es nur so von BeginDoSomething-Methoden, die IAsyncResult zurückgeben, und EndDoSomething-Methoden, die es empfangen.

Konvertieren Sie die Legacy-API mithilfe der TaskCompletionSource-Klasse in eine aufgabenbasierte API

Ein weiteres wichtiges Instrument, das es zu berücksichtigen gilt, ist die Klasse TaskCompletionSource. In Bezug auf Funktionen, Zweck und Funktionsprinzip erinnert es möglicherweise ein wenig an die RegisterWaitForSingleObject-Methode der ThreadPool-Klasse, über die ich oben geschrieben habe. Mit dieser Klasse können Sie alte asynchrone APIs einfach und bequem in Aufgaben einbinden.

Sie werden sagen, dass ich bereits über die für diese Zwecke vorgesehene FromAsync-Methode der TaskFactory-Klasse gesprochen habe. Hier müssen wir uns an die gesamte Geschichte der Entwicklung asynchroner Modelle in .net erinnern, die Microsoft in den letzten 15 Jahren angeboten hat: Vor dem Task-Based Asynchronous Pattern (TAP) gab es das Asynchronous Programming Pattern (APP), das ging es um Methoden BeginnenDoSomething kehrt zurück IAsyncResult und Methoden EndeDoSomething, das dies akzeptiert, und für das Erbe dieser Jahre ist die FromAsync-Methode einfach perfekt, aber im Laufe der Zeit wurde sie durch das Event Based Asynchronous Pattern (EAP), bei dem davon ausgegangen wurde, dass ein Ereignis ausgelöst wird, wenn der asynchrone Vorgang abgeschlossen ist.

TaskCompletionSource eignet sich perfekt zum Verpacken von Aufgaben und Legacy-APIs, die auf dem Ereignismodell basieren. Der Kern seiner Arbeit ist wie folgt: Ein Objekt dieser Klasse verfügt über eine öffentliche Eigenschaft vom Typ Task, deren Zustand über die Methoden SetResult, SetException usw. der Klasse TaskCompletionSource gesteuert werden kann. An Stellen, an denen der Wait-Operator auf diese Aufgabe angewendet wurde, wird sie abhängig von der auf TaskCompletionSource angewendeten Methode ausgeführt oder schlägt mit einer Ausnahme fehl. Wenn es immer noch nicht klar ist, schauen wir uns dieses Codebeispiel an, in dem eine alte EAP-API mithilfe einer TaskCompletionSource in eine Aufgabe eingebunden wird: Wenn das Ereignis ausgelöst wird, wird die Aufgabe in den Status „Abgeschlossen“ versetzt und die Methode, die den Wait-Operator angewendet hat, wird angezeigt Die Ausführung dieser Aufgabe wird nach Erhalt des Objekts fortgesetzt Folge.

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 alter APIs ist nicht alles, was mit TaskCompletionSource möglich ist. Die Verwendung dieser Klasse eröffnet eine interessante Möglichkeit, verschiedene APIs für Aufgaben zu entwerfen, die keine Threads belegen. Und Streams sind, wie wir uns erinnern, eine teure Ressource und ihre Anzahl ist begrenzt (hauptsächlich durch die Menge an RAM). Diese Einschränkung kann leicht erreicht werden, indem beispielsweise eine geladene Webanwendung mit komplexer Geschäftslogik entwickelt wird. Betrachten wir die Möglichkeiten, über die ich spreche, wenn ich einen Trick wie Long-Polling umsetze.

Kurz gesagt, der Kern des Tricks besteht darin, dass Sie von der API Informationen über einige Ereignisse erhalten, die auf ihrer Seite auftreten, während die API aus irgendeinem Grund das Ereignis nicht melden, sondern nur den Status zurückgeben kann. Ein Beispiel hierfür sind alle auf HTTP basierenden APIs vor der Zeit von WebSocket oder als es aus irgendeinem Grund unmöglich war, diese Technologie zu verwenden. Der Client kann den HTTP-Server fragen. Der HTTP-Server kann die Kommunikation mit dem Client nicht selbst initiieren. Eine einfache Lösung besteht darin, den Server mithilfe eines Timers abzufragen. Dies führt jedoch zu einer zusätzlichen Belastung des Servers und einer zusätzlichen Verzögerung im durchschnittlichen TimerInterval / 2. Um dies zu umgehen, wurde ein Trick namens Long Polling erfunden, bei dem die Antwort verzögert wird auf dem Server, bis das Timeout abläuft oder ein Ereignis eintritt. Wenn das Ereignis eingetreten ist, wird es verarbeitet, wenn nicht, wird die Anfrage erneut gesendet.

while(!eventOccures && !timeoutExceeded)  {

  CheckTimout();
  CheckEvent();
  Thread.Sleep(1);
}

Aber eine solche Lösung wird sich als schrecklich erweisen, sobald die Zahl der Kunden, die auf die Veranstaltung warten, steigt, denn... Jeder dieser Clients belegt einen ganzen Thread und wartet auf ein Ereignis. Ja, und wir bekommen eine zusätzliche Verzögerung von 1 ms, wenn das Ereignis ausgelöst wird. Meistens ist das nicht signifikant, aber warum sollte man die Software schlechter machen, als sie sein kann? Wenn wir Thread.Sleep(1) entfernen, belasten wir vergeblich einen Prozessorkern, der zu 100 % im Leerlauf ist und in einem nutzlosen Zyklus rotiert. Mit TaskCompletionSource können Sie diesen Code einfach neu erstellen 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 noch nicht produktionsbereit, sondern lediglich eine Demo. Um es in realen Fällen zu verwenden, müssen Sie zumindest auch die Situation bewältigen, wenn 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 dies der häufigste Fall ist, können Sie über die Verwendung von ValueTask nachdenken.

Wenn wir eine Anforderung für eine Nachricht erhalten, erstellen und platzieren wir eine TaskCompletionSource im Wörterbuch und warten dann darauf, was zuerst passiert: Das angegebene Zeitintervall läuft ab oder eine Nachricht wird empfangen.

ValueTask: Warum und wie

Die Async/Await-Operatoren erzeugen wie der Yield-Return-Operator eine Zustandsmaschine aus der Methode. Dabei handelt es sich um die Erstellung eines neuen Objekts, was fast immer unwichtig ist, in seltenen Fällen jedoch zu Problemen führen kann. In diesem Fall handelt es sich möglicherweise um eine Methode, die sehr häufig aufgerufen wird. Wir sprechen von Zehntausenden und Hunderttausenden Aufrufen pro Sekunde. Wenn eine solche Methode so geschrieben ist, dass sie in den meisten Fällen ein Ergebnis unter Umgehung aller Wait-Methoden zurückgibt, dann stellt .NET ein Tool zur Optimierung bereit – die ValueTask-Struktur. Schauen wir uns zur Verdeutlichung ein Anwendungsbeispiel an: Es gibt einen Cache, den wir sehr oft besuchen. Darin sind einige Werte enthalten, und dann geben wir sie einfach zurück. Wenn nicht, gehen wir zu einer langsamen E/A, um sie abzurufen. Letzteres möchte ich asynchron machen, was bedeutet, dass die gesamte Methode asynchron ist. Daher ist die offensichtliche Art, die 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 einer leichten Angst davor, was Roslyn beim Kompilieren dieses Codes generieren wird, können Sie 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);
}

Tatsächlich wäre die optimale Lösung in diesem Fall die Optimierung des Hot-Paths, nämlich das Erhalten eines Werts aus dem Wörterbuch ohne unnötige Zuweisungen und Belastung des GC, während wir in den seltenen Fällen, in denen wir noch zu IO gehen müssen, um Daten abzurufen , alles bleibt ein Plus/Minus wie bisher:

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 diesen Codeabschnitt genauer an: Wenn sich ein Wert im Cache befindet, erstellen wir eine Struktur, andernfalls wird die eigentliche Aufgabe in eine sinnvolle verpackt. Dem aufrufenden Code ist es egal, in welchem ​​Pfad dieser Code ausgeführt wurde: Aus Sicht der C#-Syntax verhält sich ValueTask in diesem Fall genauso wie eine reguläre Task.

TaskSchedulers: Verwalten von Aufgabenstartstrategien

Die nächste API, die ich in Betracht ziehen möchte, ist die Klasse Aufgabenplaner und seine Derivate. Ich habe oben bereits erwähnt, dass TPL in der Lage ist, Strategien zur Verteilung von Aufgaben auf Threads zu verwalten. Solche Strategien werden in den Nachkommen der TaskScheduler-Klasse definiert. Fast jede Strategie, die Sie benötigen, finden Sie in der Bibliothek. ParallelErweiterungenExtras, von Microsoft entwickelt, aber nicht Teil von .NET, sondern als Nuget-Paket geliefert. Schauen wir uns einige davon kurz an:

  • CurrentThreadTaskScheduler – führt Aufgaben im aktuellen Thread aus
  • LimitedConcurrencyLevelTaskScheduler – begrenzt die Anzahl der gleichzeitig ausgeführten Aufgaben durch den Parameter N, der im Konstruktor akzeptiert wird
  • OrderedTaskScheduler – ist als LimitedConcurrencyLevelTaskScheduler(1) definiert, sodass Aufgaben nacheinander ausgeführt werden.
  • WorkStealingTaskScheduler — Geräte Arbeitsraub Ansatz zur Aufgabenverteilung. Im Wesentlichen handelt es sich um einen separaten ThreadPool. Löst das Problem, dass ThreadPool in .NET eine statische Klasse ist, eine für alle Anwendungen, was bedeutet, dass ihre Überlastung oder falsche Verwendung in einem Teil des Programms zu Nebenwirkungen in einem anderen Teil führen kann. Darüber hinaus ist es äußerst schwierig, die Ursache solcher Mängel zu verstehen. Das. In Teilen des Programms, in denen die Verwendung von ThreadPool aggressiv und unvorhersehbar sein kann, kann es erforderlich sein, separate WorkStealingTaskScheduler zu verwenden.
  • QueuedTaskScheduler – Ermöglicht die Ausführung von Aufgaben gemäß den Regeln der Prioritätswarteschlange
  • ThreadPerTaskScheduler – Erstellt einen separaten Thread für jede darauf ausgeführte Aufgabe. Kann bei Aufgaben hilfreich sein, deren Ausführung unvorhersehbar lange dauert.

Es gibt eine gute detaillierte Beitrag über TaskSchedulers im Microsoft-Blog.

Zum bequemen Debuggen von allem, was mit Aufgaben zu tun hat, verfügt Visual Studio über ein Aufgabenfenster. In diesem Fenster können Sie den aktuellen Status der Aufgabe sehen und zur aktuell ausgeführten Codezeile springen.

.NET: Tools für die Arbeit mit Multithreading und Asynchronität. Teil 1

Plinq und die Parallel-Klasse

Zusätzlich zu Tasks und allem, was darüber gesagt wird, gibt es in .NET zwei weitere interessante Tools: PLinq (Linq2Parallel) und die Parallel-Klasse. Der erste verspricht die parallele Ausführung aller Linq-Operationen in mehreren Threads. Die Anzahl der Threads kann mit der Erweiterungsmethode WithDegreeOfParallelism konfiguriert werden. Leider verfügt Plinq im Standardmodus meistens nicht über genügend Informationen über die Interna Ihrer Datenquelle, um einen erheblichen Geschwindigkeitsgewinn zu erzielen. Andererseits sind die Kosten für den Versuch sehr gering: Sie müssen lediglich vorher die AsParallel-Methode aufrufen die Kette der Linq-Methoden und führen Leistungstests durch. Darüber hinaus ist es möglich, mithilfe des Partitionsmechanismus zusätzliche Informationen über die Art Ihrer Datenquelle an PLinq zu übergeben. Sie können mehr lesen hier и hier.

Die statische Klasse Parallel stellt Methoden zum parallelen Durchlaufen einer Foreach-Sammlung, zum Ausführen einer For-Schleife und zum parallelen Ausführen mehrerer Delegaten bereit. Die Ausführung des aktuellen Threads wird angehalten, bis die Berechnungen abgeschlossen sind. Die Anzahl der Threads kann durch die Übergabe von ParallelOptions als letztes Argument konfiguriert werden. Sie können TaskScheduler und CancellationToken auch mithilfe von Optionen angeben.

Befund

Als ich begann, diesen Artikel auf der Grundlage der Materialien meines Berichts und der Informationen, die ich während meiner Arbeit danach gesammelt hatte, zu schreiben, hatte ich nicht damit gerechnet, dass es so viel davon geben würde. Wenn mir nun der Texteditor, in dem ich diesen Artikel schreibe, vorwurfsvoll mitteilt, dass Seite 15 verschwunden sei, fasse ich die Zwischenergebnisse zusammen. Weitere Tricks, APIs, visuelle Tools und Fallstricke werden im nächsten Artikel behandelt.

Schlussfolgerungen:

  • Um die Ressourcen moderner PCs nutzen zu können, müssen Sie die Tools zum Arbeiten mit Threads, Asynchronität und Parallelität kennen.
  • .NET verfügt für diese Zwecke über viele verschiedene Tools
  • Da nicht alle auf einmal erschienen sind, findet man häufig ältere APIs. 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 repräsentiert
  • Die Methoden Thread.Abort, Thread.Interrupt und Win32 API TerminateThread sind gefährlich und werden nicht zur Verwendung empfohlen. Stattdessen ist es besser, den CancellationToken-Mechanismus zu verwenden
  • Fluss ist eine wertvolle Ressource und ihr Vorrat ist begrenzt. Situationen, in denen Threads damit beschäftigt sind, auf Ereignisse zu warten, sollten vermieden werden. Hierfür ist es praktisch, die Klasse TaskCompletionSource zu verwenden
  • Die leistungsstärksten und fortschrittlichsten .NET-Tools für die Arbeit mit Parallelität und Asynchronität sind Aufgaben.
  • Die C#-Async/Await-Operatoren implementieren das Konzept des nicht blockierenden Wartens
  • Sie können die Verteilung von Aufgaben über Threads mithilfe von TaskScheduler abgeleiteten Klassen steuern
  • Die ValueTask-Struktur kann bei der Optimierung von Hot-Paths und Speicherverkehr nützlich sein
  • Die Fenster „Aufgaben“ und „Threads“ von Visual Studio bieten viele nützliche Informationen zum Debuggen von Multithread- oder asynchronem Code
  • Plinq ist ein cooles Tool, verfügt jedoch möglicherweise nicht über genügend Informationen über Ihre Datenquelle. Dies kann jedoch mithilfe des Partitionierungsmechanismus behoben werden
  • To be continued ...

Source: habr.com

Kommentar hinzufügen