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

Ich habe diesen Artikel ursprünglich im CodingSight- Blog veröffentlicht
Der zweite Teil des Artikels ist hier verfügbar

Die Notwendigkeit, Dinge asynchron zu erledigen, dh große Aufgaben auf mehrere Arbeitseinheiten aufzuteilen, bestand lange vor dem Erscheinen von Computern. Als sie jedoch auftauchten, wurde dieses Bedürfnis noch offensichtlicher. Es ist jetzt 2019, und ich schreibe diesen Artikel auf einem Laptop, der mit einer 8-Kern-Intel-Core-CPU betrieben wird, die zusätzlich an Hunderten von Prozessen arbeitet, wobei die Anzahl der Threads noch größer ist. Neben mir liegt ein etwas veraltetes Smartphone, das ich vor ein paar Jahren gekauft habe - und in dem sich auch ein 8-Kern-Prozessor befindet. Spezialisierte Webressourcen enthalten eine Vielzahl von Artikeln, in denen die diesjährigen Flaggschiff-Smartphones mit 16-Kern-CPUs gelobt werden. Für weniger als 20 US-Dollar pro Stunde können Sie mit MS Azure auf eine virtuelle 128-Core-Maschine mit 2 TB RAM zugreifen. Leider können Sie diese Leistung nur dann optimal nutzen, wenn Sie wissen, wie Sie die Interaktion zwischen Threads steuern.

Inhalt




Terminologie


Prozess - Ein Betriebssystemobjekt, das einen isolierten Adressraum darstellt, der Threads enthält.

Thread - Ein Betriebssystemobjekt, das die kleinste Ausführungseinheit darstellt. Threads sind Bestandteile von Prozessen, sie teilen Speicher und andere Ressourcen im Rahmen eines Prozesses untereinander auf.

Multitasking - eine Betriebssystemfunktion, die die Fähigkeit darstellt, mehrere Prozesse gleichzeitig auszuführen.

Multi-Core - eine CPU-Funktion, die die Möglichkeit darstellt, mehrere Kerne für die Datenverarbeitung zu verwenden

Multiprocessing - die Funktion eines Computers, die die Fähigkeit darstellt, physisch mit mehreren CPUs zu arbeiten.

Multithreading - das Merkmal eines Prozesses, das die Fähigkeit darstellt, die Datenverarbeitung auf mehrere Threads aufzuteilen und zu verteilen.

Parallelität - gleichzeitige physische Ausführung mehrerer Aktionen in einer Zeiteinheit

Asynchronität - Ausführen einer Operation, ohne darauf zu warten, dass sie vollständig verarbeitet wird, sodass die Berechnung des Ergebnisses für eine spätere Zeit verbleibt.


Eine Metapher


Nicht alle Definitionen sind wirksam und einige müssen ausgearbeitet werden. Lassen Sie mich daher eine Kochmetapher für die soeben eingeführte Terminologie bereitstellen.

Das Frühstück zuzubereiten ist ein Prozess in dieser Metapher.

Wenn ich morgens frühstücke, gehe ich ( CPU ) in die Küche ( Computer ). Ich habe zwei Hände ( Kerne ). In der Küche gibt es eine Auswahl an Geräten ( IO ): Herd, Wasserkocher, Toaster, Kühlschrank. Ich schalte den Herd ein, stelle eine Pfanne darauf und gieße etwas Pflanzenöl hinein. Ohne darauf zu warten, dass sich das Öl erwärmt ( asynchron, Non-Blocking-IO-Wait ), hole ich einige Eier aus dem Kühlschrank, knacke sie über eine Schüssel und peitsche sie dann mit einer Hand ( Faden Nr. 1 ). Währenddessen hält der Sekundenzeiger (Faden Nr. 2) die Schüssel an Ort und Stelle ( Shared Resource ). Ich möchte den Wasserkocher einschalten, habe aber momentan nicht genügend freie Hände ( Thread Starvation ). Während ich die Eier peitschte, wurde die Pfanne heiß genug (Ergebnisverarbeitung), also gieße ich die geschlagenen Eier hinein. Ich greife zum Wasserkocher, schalte ihn ein und schaue auf das gekochte Wasser ( Blocking-IO-Wait ) - aber ich hätte diese Zeit nutzen können, um die Schüssel zu waschen.

Ich habe nur 2 Hände benutzt, um das Omelett zuzubereiten (weil ich nicht mehr habe), aber es wurden 3 Operationen gleichzeitig ausgeführt: die Eier schlagen, die Schüssel halten, die Pfanne erhitzen. Die CPU ist der schnellste Teil des Computers, und E / A ist der Teil, der am häufigsten gewartet werden muss. Daher ist es sehr effektiv, die CPU mit etwas Arbeit zu laden, während sie auf die Daten von E / A wartet.

So erweitern Sie die Metapher:

  • Wenn ich auch versucht hätte, mich beim Frühstück umzuziehen, hätte ich Multitasking betrieben . Computer können das viel besser als Menschen.
  • Eine Küche mit mehreren Köchen - zum Beispiel in einem Restaurant - ist ein Multi-Core- Computer.
  • Ein Einkaufszentrum Food Court mit vielen Restaurants würde ein Rechenzentrum darstellen .



.NET Tools


.NET ist wirklich gut, wenn es um die Arbeit mit Threads geht - und bei vielen anderen Dingen. Mit jeder neuen Version bietet es mehr Tools für die Arbeit mit Threads und neuen OS-Thread-Abstraktionsschichten. Bei der Arbeit mit Abstraktionen verwenden die Entwickler, die mit dem Framework arbeiten, einen Ansatz, der es ihnen ermöglicht, eine oder mehrere Ebenen nach unten zu verschieben, während sie Abstraktionen auf hoher Ebene verwenden. In den meisten Fällen besteht keine wirkliche Notwendigkeit, dies zu tun (und dies kann die Möglichkeit mit sich bringen, sich in den Fuß zu schießen), aber manchmal ist dies möglicherweise die einzige Möglichkeit, ein Problem zu lösen, das auf der aktuellen Abstraktionsebene nicht gelöst werden kann.

Als ich zuvor Tools erwähnte, meinte ich sowohl Programmschnittstellen (API), die vom Framework oder von Paketen von Drittanbietern bereitgestellt werden, als auch vollwertige Softwarelösungen, die die Suche nach Problemen im Zusammenhang mit Multithread-Code vereinfachen.


Einen Thread starten


Die Thread-Klasse ist die grundlegendste .NET-Klasse für die Arbeit mit Threads. Sein Konstruktor akzeptiert einen dieser beiden Delegierten:

  • ThreadStart - keine Parameter
  • ParametrizedThreadStart - Ein Parameter vom Typ Objekt.


Der Delegat wird nach dem Aufrufen der Start-Methode in einem neu erstellten Thread ausgeführt. Wenn der ParametrizedThreadStart-Delegat an den Konstruktor übergeben wurde, sollte ein Objekt an die Start-Methode übergeben werden. Dieser Prozess ist erforderlich, um lokale Informationen an den Thread zu übergeben. Ich sollte darauf hinweisen, dass das Erstellen eines Threads viele Ressourcen erfordert und der Thread selbst ein schweres Objekt ist - zumindest, weil er eine Interaktion mit der Betriebssystem-API erfordert und dem Stapel 1 MB Speicher zugewiesen ist.

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

Die ThreadPool-Klasse repräsentiert das Konzept eines Pools. In .NET ist der Thread-Pool ein Kunstwerk, und die Microsoft-Entwickler haben große Anstrengungen unternommen, damit er in allen möglichen Szenarien optimal funktioniert.

Das allgemeine Konzept:
Beim Start erstellt die App einige Threads im Hintergrund, sodass Sie bei Bedarf darauf zugreifen können. 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 der Pool zum richtigen Zeitpunkt nicht genügend freie Threads hat, wartet er entweder darauf, dass einer der aktiven Threads nicht mehr belegt ist, oder erstellt einen neuen. Darauf basierend folgt, dass der Thread-Pool perfekt für kurze Aktionen ist und nicht so gut für Prozesse funktioniert, die während der gesamten Dauer des Anwendungsbetriebs als Services fungieren.

Mit der QueueUserWorkItem-Methode können Threads aus dem Pool verwendet werden. Diese Methode verwendet den Delegaten vom Typ WaitCallback . Die Signatur stimmt mit der Signatur von ParametrizedThreadStart überein, und der an sie übergebene Parameter hat dieselbe Rolle.

 ThreadPool.QueueUserWorkItem(...); 

Die weniger bekannte RegisterWaitForSingleObject-Threadpoolmethode wird zum Organisieren nicht blockierender E / A-Vorgänge verwendet. Der Delegat, der an diese Methode übergeben wird, wird aufgerufen, wenn das WaitHandle freigegeben wird, nachdem es an die Methode übergeben wurde.

 ThreadPool.RegisterWaitForSingleObject(...) 


In .NET gibt es einen Thread-Timer, der sich von den WinForms / WPF-Timern dadurch unterscheidet, dass sein Handler in dem aus dem Pool entnommenen Thread aufgerufen wird.

 System.Threading.Timer 


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

 DelegateInstance.BeginInvoke 


Ich möchte auch einen Blick auf die Funktion werfen, auf die sich viele der zuvor erwähnten Methoden beziehen - CreateThread aus der Kernel32.dll Win32-API. Es gibt eine Möglichkeit, diese Funktion mithilfe des externen Mechanismus der Methoden aufzurufen. Ich habe nur einmal gesehen, dass dies in einem besonders schlimmen Fall von Legacy-Code verwendet wurde - und ich verstehe immer noch nicht, was die Gründe des Autors waren.
 Kernel32.dll CreateThread 



Anzeigen und Debuggen von Threads


Alle Threads - ob von Ihnen, Komponenten von Drittanbietern oder dem .NET-Pool erstellt - können im Threads- Fenster von Visual Studio angezeigt werden. In diesem Fenster werden nur die Informationen zu Threads angezeigt, wenn die Anwendung im Unterbrechungsmodus debuggt wird. Hier können Sie die Namen und Prioritäten jedes Threads anzeigen und den Debug-Modus auf bestimmte Threads konzentrieren. Mit der Priority-Eigenschaft der Thread-Klasse können Sie die Priorität des Threads festlegen. Diese Priorität wird dann berücksichtigt, wenn das Betriebssystem und die CLR die Prozessorzeit zwischen den Threads aufteilen.




Task parallele Bibliothek


Die Task Parallel Library (TPL) wurde erstmals in .NET 4.0 angezeigt. Derzeit ist es das Hauptwerkzeug für die Arbeit mit Asynchronität. Jeder Code, der ältere Ansätze verwendet, wird als Legacy-Code betrachtet. Die Haupteinheit von TPL ist die Task- Klasse aus dem Namespace System.Threading.Tasks. Aufgaben repräsentieren die Thread-Abstraktion. Mit der neuesten Version von C # haben wir eine neue elegante Art der Arbeit mit Aufgaben erworben - die asynchronen / wartenden Operatoren. Diese ermöglichen es, asynchronen Code so zu schreiben, als ob er einfach und synchron wäre, sodass diejenigen, die sich mit der Theorie der Threads nicht auskennen, jetzt Apps schreiben können, die nicht mit langen Vorgängen zu kämpfen haben. Die Verwendung von async / await ist wirklich ein Thema für einen separaten Artikel (oder sogar einige Artikel), aber ich werde versuchen, die Grundlagen in ein paar Sätzen zu skizzieren:

  • async ist ein Modifikator einer Methode, die eine Task oder void zurückgibt
  • await ist ein Operator einer nicht blockierenden Warteaufgabe.


Noch einmal: Der Operator "Warten" lässt normalerweise (es gibt Ausnahmen) den aktuellen Thread los und wenn die Aufgabe ausgeführt wird und der Thread (eigentlich der Kontext, aber wir werden später darauf zurückkommen) als frei ist Infolgedessen wird die Methode weiterhin ausgeführt. In .NET wird dieser Mechanismus auf die gleiche Weise wie die Ertragsrückgabe implementiert. Eine Methode wird in eine endliche Zustandsmaschinenklasse umgewandelt, die je nach Status in separaten Teilen ausgeführt werden kann. Wenn dies interessant klingt, würde ich empfehlen, einen einfachen Code basierend auf async / await zu schreiben, ihn zu kompilieren und seine Kompilierung mit Hilfe von JetBrains dotPeek mit aktiviertem Compiler Generated Code zu betrachten.

Schauen wir uns die Optionen an, die wir zum Starten und Verwenden einer Aufgabe haben. Im folgenden Beispiel erstellen wir eine neue Aufgabe, die eigentlich nichts Produktives bewirkt (Thread.Sleep (10000)). In realen Fällen sollten wir es jedoch durch eine komplexe Arbeit ersetzen, die CPU-Ressourcen nutzt.

 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 folgenden Optionen erstellt:

  • LongRunning - Diese Option weist darauf hin, dass die Aufgabe nicht schnell ausgeführt werden kann. Daher ist es möglicherweise besser, einen separaten Thread für diese Aufgabe zu erstellen, als einen vorhandenen aus dem Pool zu nehmen, um den Schaden für andere Aufgaben zu minimieren.
  • AttachedToParent - Aufgaben können hierarchisch angeordnet werden. Wenn diese Option verwendet wird, wartet die Aufgabe darauf, dass ihre untergeordneten Aufgaben ausgeführt werden, nachdem sie selbst ausgeführt wurden.
  • PreferFairness - Diese Option gibt an, dass die Aufgabe besser vor den später erstellten Aufgaben ausgeführt werden soll. Es ist jedoch eher ein Vorschlag, sodass das Ergebnis nicht immer garantiert ist.


Der zweite Parameter, der an die Methode übergeben wurde, ist CancellationToken. Damit der Vorgang ordnungsgemäß abgebrochen werden kann, nachdem er bereits gestartet wurde, sollte der ausführbare Code CancellationToken-Statusprüfungen enthalten. Wenn solche Überprüfungen nicht vorhanden sind, kann die für das CancellationTokenSource-Objekt aufgerufene Cancel-Methode die Taskausführung nur stoppen, bevor die Task tatsächlich gestartet wird.

Für den letzten Parameter haben wir ein Objekt vom Typ TaskScheduler namens Scheduler gesendet. Diese Klasse wird zusammen mit ihren untergeordneten Klassen verwendet, um zu steuern, wie Aufgaben zwischen Threads verteilt werden. Standardmäßig wird eine Aufgabe für einen zufällig ausgewählten Thread aus dem Pool ausgeführt

Der Operator "Warten" wird auf die erstellte Aufgabe angewendet. Dies bedeutet, dass der danach geschriebene Code (wenn es einen solchen Code gibt) im selben Kontext ausgeführt wird (häufig bedeutet dies "im selben Thread") wie der zuvor geschriebene Code.

Diese Methode wird als async void bezeichnet. Dies bedeutet, dass der Operator await darin verwendet werden kann, der aufrufende Code jedoch nicht auf die Ausführung warten kann. Wenn eine solche Möglichkeit benötigt wird, sollte die Methode eine Aufgabe zurückgeben. Als asynchrone Leere gekennzeichnete Methoden sind häufig zu sehen: In der Regel handelt es sich um Ereignishandler oder andere Methoden, die unter dem Prinzip des Feuers und Vergessens arbeiten. Wenn Sie warten müssen, bis die Ausführung abgeschlossen ist, und das Ergebnis zurückgeben, sollten Sie Task verwenden.

Für Aufgaben, die die StartNew-Methode zurückgeben, können wir ConfigureAwait mit dem Parameter false aufrufen. Anschließend wird die Ausführung nach dem Warten in einem zufälligen Kontext anstelle eines erfassten Kontexts fortgesetzt. Dies sollte immer dann erfolgen, wenn der nach dem Warten geschriebene Code keinen bestimmten Ausführungskontext erfordert. Dies ist auch eine Empfehlung von MS, wenn es darum geht, Code zu schreiben, der als Bibliothek bereitgestellt wird.

Schauen wir uns an, wie wir warten können, bis eine Aufgabe erledigt ist. Unten sehen Sie ein Beispiel für einen Code mit Kommentaren, die angeben, wann das Warten relativ gut oder schlecht implementiert ist.

 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 ausgeführt wird, ohne den aufrufenden Thread zu blockieren. Daher werden wir das Ergebnis wieder verarbeiten, wenn es fertig ist. Bevor dies geschieht, bleibt der aufrufende Thread für sich allein.

Im zweiten Versuch blockieren wir den aufrufenden Thread, bis das Ergebnis der Methode berechnet ist. Dies ist aus zwei Gründen ein schlechter Ansatz. Zunächst verschwenden wir einen Thread - eine sehr wertvolle Ressource - für einfaches Warten. Wenn die von uns aufgerufene Methode ein Warten enthält, während der Synchronisierungskontext eine Rückkehr zum aufrufenden Thread nach dem Warten beabsichtigt, wird ein Deadlock angezeigt. Dies geschieht, weil der aufrufende Thread auf das Ergebnis einer asynchronen Methode wartet und die asynchrone Methode selbst erfolglos versucht, ihre Ausführung im aufrufenden Thread fortzusetzen.

Ein weiterer Nachteil dieses Ansatzes ist die erhöhte Komplexität der Fehlerbehandlung. Die Fehler können im asynchronen Code relativ einfach behandelt werden, wenn async / await verwendet wird - der Prozess ist in diesem Fall identisch mit dem im synchronen Code. Wenn jedoch eine Aufgabe synchron synchronisiert wird, wird die anfängliche Ausnahme in AggregateException eingeschlossen. Mit anderen Worten, um die Ausnahme zu behandeln, müssten wir den InnerException-Typ untersuchen und manuell eine if-Kette in einen catch-Block schreiben oder alternativ die catch when-Struktur anstelle der üblicheren Kette von catch-Blöcken verwenden.

Die beiden letzten Beispiele werden aus den gleichen Gründen als relativ schlecht bezeichnet und enthalten beide die gleichen Probleme.

Die WhenAny- und WhenAll-Methoden sind sehr nützlich, wenn Sie auf eine Gruppe von Aufgaben warten möchten. Sie wickeln diese Aufgaben in eine ein und werden entweder ausgeführt, wenn eine Aufgabe aus der Gruppe gestartet wird oder wenn alle diese Aufgaben erfolgreich ausgeführt werden.


Fäden stoppen


Aus verschiedenen Gründen kann es erforderlich sein, einen Thread nach dem Start anzuhalten. Es gibt einige Möglichkeiten, dies zu tun. Die Thread-Klasse verfügt über zwei Methoden mit entsprechenden Namen - Abort und Interrupt . Ich würde dringend davon abraten , die erste zu verwenden, da nach dem Aufruf zu jedem beliebigen Zeitpunkt eine ThreadAbortedException ausgelöst wird, während eine beliebig ausgewählte Anweisung verarbeitet wird. Sie erwarten nicht, dass eine solche Ausnahme auftritt, wenn eine Ganzzahlvariable inkrementiert wird, oder? Nun, wenn Sie die Abort-Methode verwenden, wird dies eine echte Möglichkeit. Falls Sie die Fähigkeit der CLR verweigern müssen, solche Ausnahmen in einem bestimmten Teil des Codes zu erstellen, können Sie sie in die Aufrufe Thread. BeginCriticalRegion und Thread.EndCriticalRegion einschließen . Jeder im finally-Block geschriebene Code wird in diese Aufrufe eingeschlossen. Aus diesem Grund finden Sie Blöcke mit einem leeren Versuch und einem nicht leeren schließlich in den Tiefen des Framework-Codes. Microsoft mag diese Methode nicht, da sie nicht im .NET-Kern enthalten ist.

Die Interrrupt- Methode funktioniert viel vorhersehbarer. Es kann einen Thread mit einer ThreadInterruptedException nur unterbrechen , wenn sich der Thread im Wartemodus befindet. Es wird in diesen Zustand versetzt, wenn es angehalten wird, während auf WaitHandle, eine Sperre oder nach Thread.Sleep gewartet wird.

Beide Wege haben den Nachteil der Unvorhersehbarkeit. Um diesem Problem zu entgehen, sollten wir die CancellationToken- Struktur und die CancellationTokenSource- Klasse verwenden. Die allgemeine Idee lautet: Eine Instanz der CancellationTokenSource-Klasse wird erstellt, und nur diejenigen, die sie besitzen, können den Vorgang durch Aufrufen der Cancel- Methode stoppen. Nur CancellationToken wird an die Operation übergeben. Die Eigentümer von CancellationToken können den Vorgang nicht selbst abbrechen. Sie können nur überprüfen, ob der Vorgang abgebrochen wurde. Dies kann mithilfe der Booleschen Eigenschaft IsCancellationRequested und der ThrowIfCancelRequested- Methode erreicht werden. Die letzte generiert eine TaskCancelledException, wenn die Cancel-Methode für die CancellationTokenSource-Instanz aufgerufen wurde, die das CancellationToken erstellt hat. Dies ist die Methode, die ich empfehle. Der Vorteil gegenüber den zuvor beschriebenen Methoden liegt in der Tatsache, dass sie die vollständige Kontrolle über die genauen Ausnahmefälle bieten, in denen eine Operation abgebrochen werden kann.

Der brutalste Weg, einen Thread zu stoppen, besteht darin, eine Win32-API-Funktion namens TerminateThread aufzurufen. Nachdem diese Funktion aufgerufen wurde, kann das Verhalten der CLR ziemlich 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.


Verwandeln einer Legacy-API mithilfe von FromAsync in eine aufgabenbasierte


Wenn Sie das Glück hatten, an einem Projekt zu arbeiten, das nach Einführung der Aufgaben gestartet wurde (und bei den meisten Entwicklern keinen existenziellen Horror mehr auslöst), müssen Sie sich nicht mit alten APIs befassen - sowohl mit Drittanbietern diejenigen und diejenigen, an denen Ihr Team in der Vergangenheit gearbeitet hat. Glücklicherweise hat es uns das .NET Framework-Entwicklungsteam leichter gemacht - aber nach allem, was wir wissen, hätte dies eine Selbstversorgung sein können. In jedem Fall verfügt .NET über einige Tools, mit denen der mit alten Ansätzen geschriebene Code nahtlos in die Asynchronität gebracht und auf ein aktuelles Formular gebracht werden kann. Eine davon ist die TaskFactory-Methode FromAsync. Im folgenden Beispiel verpacke ich die alten asynchronen Methoden der WebRequest-Klasse mithilfe von FromAsync in eine Task.

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

Es ist nur ein Beispiel, und Sie werden wahrscheinlich nichts dergleichen mit eingebauten Typen tun. In alten Projekten gibt es jedoch viele BeginDoSomething-Methoden, die IAsyncResult- und EndDoSomething-Methoden zurückgeben, die sie empfangen.


Verwandeln einer Legacy-API mithilfe von TaskCompletionSource in eine aufgabenbasierte


Ein weiteres Werkzeug, das es wert ist, erkundet zu werden, ist die TaskCompletionSource- Klasse. In seiner Funktionalität, seinem Zweck und seinem Funktionsprinzip ähnelt es der RegisterWaitForSingleObject-Methode aus der zuvor erwähnten ThreadPool-Klasse. Mit dieser Klasse können wir alte asynchrone APIs einfach in Aufgaben einbinden.

Vielleicht möchten Sie sagen, dass ich bereits von der TaskFactory-Klasse, die diesen Zwecken diente, über die FromAsync-Methode berichtet habe. Hier müssen wir uns an die vollständige Historie der von Microsoft in den letzten 15 Jahren bereitgestellten asynchronen Modelle erinnern: Vor TAP (Task-Based Asynchronous Patterns) gab es Asynchronous Programming Patterns (APP). Bei APPs ging es ausschließlich um Begin DoSomething, das IAsyncResult zurückgibt, und um die End DoSomething-Methode, die dies akzeptiert - und die FromAsync-Methode ist perfekt für das diesjährige Erbe. Im Laufe der Zeit wurde dies jedoch durch ereignisbasierte asynchrone Muster (EAP) ersetzt, die angaben, dass ein Ereignis aufgerufen wird, wenn eine asynchrone Operation erfolgreich ausgeführt wird.

TaskCompletionSource eignet sich perfekt zum Umschließen älterer APIs, die um das Ereignismodell herum erstellt wurden, in Aufgaben. So funktioniert es: Objekte dieser Klasse haben eine öffentliche Eigenschaft namens Task, deren Status durch verschiedene Methoden der TaskCompletionSource-Klasse (SetResult, SetException usw.) gesteuert werden kann. An Stellen, an denen der Operator "Warten" auf diese Aufgabe angewendet wurde, wird er mit einer Ausnahme ausgeführt oder stürzt ab, abhängig von der auf TaskCompletionSource angewendeten Methode. Um es besser zu verstehen, schauen wir uns diesen Beispielcode an. Hier wird eine alte API aus der EAP-Ära mithilfe von TaskCompletionSource in eine Task eingeschlossen: Wenn ein Ereignis ausgelöst wird, wird die Task in den Status "Abgeschlossen" versetzt, während die Methode, mit der der Operator "Warten" auf diese Task angewendet wurde, ihre Ausführung fortsetzt nach dem Empfang eines Ergebnisobjekts .

 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


TaskCompletionSource kann mehr als nur veraltete APIs verpacken. Diese Klasse eröffnet eine interessante Möglichkeit, verschiedene APIs basierend auf Aufgaben zu entwerfen, die keine Threads belegen. Wie wir uns erinnern, ist ein Thread eine teure Ressource, die hauptsächlich durch RAM begrenzt ist. Wir können diese Grenze leicht erreichen, wenn wir eine robuste Webanwendung mit komplexer Geschäftslogik entwickeln. Schauen wir uns die Funktionen an, die ich in Aktion erwähnt habe, indem wir einen netten Trick implementieren, der als Long Polling bekannt ist.

Kurz gesagt, so funktioniert Long Polling:
Sie müssen von einer API einige Informationen zu Ereignissen abrufen, die auf ihrer Seite auftreten. Die API kann jedoch aus irgendeinem Grund nur einen Status zurückgeben, anstatt Sie über das Ereignis zu informieren. Ein Beispiel hierfür wäre eine API, die über HTTP erstellt wurde, bevor WebSocket angezeigt wurde, oder unter Umständen, unter denen diese Technologie nicht verwendet werden kann. Der Client kann den HTTP-Server fragen. Der HTTP-Server kann dagegen nicht selbst Kontakt mit dem Client aufnehmen. Die einfachste Lösung wäre, den Server regelmäßig mit einem Timer zu fragen. Dies würde jedoch eine zusätzliche Belastung für den Server und eine allgemeine Verzögerung verursachen, die ungefähr TimerInterval / 2 entspricht. Um dies zu umgehen, wurde Long Polling erfunden. Dies führt dazu, dass die Serverantwort verzögert wird, bis das Timeout abläuft oder ein Ereignis eintritt. Wenn ein Ereignis eintritt, wird es behandelt. Wenn nicht, wird die Anfrage erneut gesendet.

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

Die Effektivität dieser Lösung wird jedoch radikal sinken, wenn die Anzahl der auf das Ereignis wartenden Clients zunimmt - jeder wartende Client belegt einen vollständigen Thread. Außerdem erhalten wir eine zusätzliche Verzögerung von 1 ms für die Ereignisauslösung. Oft ist es nicht wirklich so wichtig, aber warum sollten wir unsere Software schlechter machen, als es sein könnte? Wenn wir dagegen Thread.Sleep (1) entfernen, wird einer der CPU-Kerne zu 100% geladen, während in einem nutzlosen Zyklus nichts unternommen wird. Mit Hilfe von TaskCompletionSource können wir unseren Code einfach transformieren, um alle genannten Probleme zu 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); } } 

Bitte beachten Sie, dass dieser Code nur ein Beispiel ist und in keiner Weise produktionsbereit. Um es in realen Fällen zu verwenden, müssten wir zumindest eine Möglichkeit hinzufügen, um Situationen zu behandeln, in denen eine Nachricht empfangen wird, wenn nichts darauf wartet: In diesem Fall sollte die AcceptMessageAsync-Methode eine bereits abgeschlossene Aufgabe zurückgeben. Wenn dieser Fall der häufigste ist, können wir die Verwendung von ValueTask in Betracht ziehen.

Beim Empfang einer Nachrichtenanforderung erstellen wir eine TaskCompletionSource, platzieren sie in einem Wörterbuch und warten dann auf eines der folgenden Ereignisse: Entweder wird das angegebene Zeitintervall ausgegeben oder eine Nachricht wird empfangen.


ValueTask: Warum und wie


async / await-Operatoren generieren genau wie der Yield-Return-Operator eine Finite-State-Maschine aus einer Methode, was bedeutet, dass ein neues Objekt erstellt wird. Dies ist meistens nicht wirklich wichtig, kann jedoch in einigen seltenen Fällen zu Problemen führen. Einer dieser Fälle kann bei häufig aufgerufenen Methoden auftreten - wir sprechen von Zehntausenden von Anrufen pro Sekunde. Wenn eine solche Methode so geschrieben ist, dass sie das Ergebnis zurückgibt und in den meisten Fällen alle Wartemethoden umgeht, bietet .NET hierfür ein Optimierungstool - die ValueTask-Struktur. Schauen wir uns ein Beispiel an, um zu verstehen, wie es funktioniert. Angenommen, es gibt einen Cache, auf den wir regelmäßig zugreifen. Wenn darin Werte enthalten sind, geben wir diese einfach zurück. Wenn es keine Werte gibt, versuchen wir, sie von einem langsamen E / A zu erhalten. Letzteres sollte idealerweise asynchron erfolgen, damit die gesamte Methode asynchron ist. Der naheliegendste Weg, diese Methode zu implementieren, ist folgender:

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

Mit dem Wunsch, es ein wenig zu optimieren und der Sorge, was Roslyn beim Kompilieren dieses Codes erzeugen wird, könnten wir die Methode wie folgt neu schreiben:

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

Die beste Lösung in diesem Fall wäre jedoch die Optimierung des Hot-Path - insbesondere das Abrufen von Wörterbuchwerten ohne unnötige Zuordnungen und ohne Belastung des GC. In den seltenen Fällen, in denen wir Daten von IO abrufen müssen, bleiben die Dinge fast gleich:

 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 ein Wert im Cache vorhanden ist, erstellen wir eine Struktur. Andernfalls wird die eigentliche Aufgabe in eine ValueTask eingeschlossen. Der Pfad, über den dieser Code ausgeführt wird, ist für den aufrufenden Code nicht wichtig: Aus Sicht der C # -Syntax verhält sich eine ValueTask wie eine normale Aufgabe.


TaskScheduler: Steuern von Aufgabenausführungsstrategien


Die nächste API, über die ich sprechen möchte, ist die TaskScheduler- Klasse und die daraus abgeleiteten. Ich habe bereits erwähnt, dass TPL die Möglichkeit bietet, zu steuern, wie genau Aufgaben zwischen Threads verteilt werden. Diese Strategien werden in Klassen definiert, die von TaskScheduler erben. Fast jede Strategie, die wir benötigen, finden Sie in der ParallelExtensionsExtras- Bibliothek. Diese Bibliothek wurde von Microsoft entwickelt, ist jedoch nicht Teil von .NET, sondern wird als Nuget-Paket verteilt. Schauen wir uns einige der Strategien an:

  • CurrentThreadTaskScheduler - führt Aufgaben für den aktuellen Thread aus
  • LimitedConcurrencyLevelTaskScheduler - begrenzt die Anzahl der gleichzeitig ausgeführten Aufgaben mithilfe des N-Parameters, den es im Konstruktor akzeptiert
  • OrderedTaskScheduler - ist als LimitedConcurrencyLevelTaskScheduler (1) definiert, sodass Aufgaben nacheinander ausgeführt werden.
  • WorkStealingTaskScheduler - Implementiert den Work-Stealing- Ansatz für die Aufgabenausführung. Im Wesentlichen kann es als separater ThreadPool angezeigt werden. Dies hilft bei dem Problem, dass ThreadPool eine statische Klasse in .NET ist. Wenn es in einem Teil der Anwendung überladen oder nicht ordnungsgemäß verwendet wird, können an einer anderen Stelle unangenehme Nebenwirkungen auftreten. Die tatsächlichen Ursachen solcher Fehler können schwer zu lokalisieren sein. Daher müssen Sie möglicherweise separate WorkStealingTaskSchedulers in den Teilen der Anwendung verwenden, in denen die Verwendung von ThreadPool aggressiv und unvorhersehbar sein kann.
  • QueuedTaskScheduler - Ermöglicht die Ausführung von Aufgaben auf der Grundlage einer priorisierten Warteschlange
  • ThreadPerTaskScheduler - Erstellt einen separaten Thread für jede Aufgabe, die darauf ausgeführt wird. Dies kann für Aufgaben hilfreich sein, deren Ausführungszeit nicht geschätzt werden kann.

Es gibt einen sehr guten Artikel über TaskScheduler im Microsoft-Blog. Schauen Sie sich das an.

In Visual Studio gibt es ein Aufgabenfenster, das beim Debuggen aller Aufgaben helfen kann. In diesem Fenster können Sie den Status der Aufgabe anzeigen und zur aktuell ausgeführten Codezeile springen.



PLinq und die Parallelklasse


Abgesehen von Aufgaben und allen damit verbundenen Dingen gibt es in .NET zwei zusätzliche Tools, die wir interessant finden können - PLinq (Linq2Parallel) und die Parallel- Klasse. Der erste verspricht die parallele Ausführung aller Linq-Operationen auf allen Threads. Die Anzahl der Threads kann durch eine Erweiterungsmethode WithDegreeOfParallelism konfiguriert werden. Leider verfügt PLinq im Standardmodus in den meisten Fällen nicht über genügend Informationen zur Datenquelle, um die Geschwindigkeit erheblich zu erhöhen. Andererseits sind die Kosten für den Versuch sehr gering: Sie müssen AsParallel nur vor der Kette der Linq-Methoden aufrufen und Leistungstests durchführen. Darüber hinaus können Sie mithilfe des Partitionsmechanismus zusätzliche Informationen über die Art Ihrer Datenquelle an PLinq übergeben. Weitere Informationen finden Sie hier und hier .

Die statische Parallel-Klasse bietet Methoden zum parallelen Auflisten von Sammlungen über Foreach, zum Ausführen des For-Zyklus und zum parallelen Ausführen mehrerer Delegaten zu Invoke. Die Ausführung des aktuellen Threads wird gestoppt, bis die Ergebnisse berechnet sind. Sie können die Anzahl der Threads konfigurieren, indem Sie ParallelOptions als letztes Argument übergeben. TaskScheduler und CancellationToken können auch mithilfe von Optionen festgelegt werden.


Zusammenfassung


Als ich anfing, diesen Artikel zu schreiben, basierend auf meiner These und dem Wissen, das ich während der Arbeit danach gewonnen hatte, dachte ich nicht, dass es so viele Informationen geben würde. Jetzt, da mir der Texteditor vorwurfsvoll mitteilt, dass ich fast 15 Seiten geschrieben habe, möchte ich eine Zwischenschlussfolgerung ziehen. Wir werden uns im nächsten Artikel mit anderen Techniken, APIs, visuellen Tools und versteckten Gefahren befassen.

Schlussfolgerungen:

  • Um die Ressourcen moderner PCs effektiv nutzen zu können, benötigen Sie Tools für die Arbeit mit Threads, Asynchronität und Parallelität.
  • In .NET gibt es viele solche Tools
  • Nicht alle von ihnen wurden gleichzeitig erstellt, daher kann es häufig vorkommen, dass Sie auf alten Code stoßen. Es gibt jedoch Möglichkeiten, alte APIs mit geringem Aufwand zu transformieren.
  • In .NET werden die Klassen Thread und ThreadPool zum Arbeiten mit Threads verwendet
  • Die Thread.Abort- und Thread.Interrupt-Methoden sowie die Win32-API-Funktion TerminateThread sind gefährlich und werden nicht zur Verwendung empfohlen. Stattdessen ist es besser, CancellationTokens zu verwenden
  • Threads sind eine wertvolle Ressource und ihre Anzahl ist begrenzt. Sie sollten Fälle vermeiden, in denen Threads durch Warten auf Ereignisse belegt sind. Die TaskCompletionSource-Klasse kann dabei helfen.
  • Aufgaben sind das leistungsstärkste und robusteste Tool, mit dem .NET mit Parallelität und Asynchronität arbeiten kann.
  • Die asynchronen / wartenden C # -Operatoren implementieren das Konzept eines nicht blockierenden Wartens
  • Sie können mithilfe von Klassen, die von TaskScheduler abgeleitet wurden, steuern, wie Aufgaben zwischen Threads verteilt werden
  • Die ValueTask-Struktur kann verwendet werden, um Hot-Paths und Speicherverkehr zu optimieren
  • Die Fenster Aufgaben und Threads in Visual Studio bieten viele hilfreiche Informationen zum Debuggen von Multithread- oder asynchronem Code
  • PLinq ist ein großartiges Tool, das jedoch möglicherweise nicht alle erforderlichen Informationen zu Ihrer Datenquelle enthält - die mit dem Partitionierungsmechanismus weiterhin behoben werden können

Fortsetzung folgt ...

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


All Articles