Async / warte in C #: Konzept, internes Design, nützliche Tricks

Guten Tag. Lassen Sie uns diesmal über ein Thema sprechen, das jeder selbstbewusste Anhänger der C # -Sprache zu verstehen begann - asynchrone Programmierung mit Task oder, bei gewöhnlichen Menschen, asynchron / warten. Microsoft hat gute Arbeit geleistet - um Asynchronität zu verwenden, müssen Sie in den meisten Fällen nur die Syntax und keine weiteren Details kennen. Aber wenn Sie tief gehen, ist das Thema ziemlich umfangreich und komplex. Es wurde von vielen gesagt, jeder in seinem eigenen Stil. Es gibt viele coole Artikel zu diesem Thema, aber es gibt immer noch viele Missverständnisse. Wir werden versuchen, die Situation zu korrigieren und das Material so weit wie möglich zu kauen, ohne dabei die Tiefe oder das Verständnis zu beeinträchtigen.



Behandelte Themen / Kapitel:

  1. Das Konzept der Asynchronität - die Vorteile von Asynchronität und Mythen über einen "blockierten" Thread
  2. TAP. Syntax- und Kompilierungsbedingungen - Voraussetzungen für das Schreiben einer Kompilierungsmethode
  3. Arbeiten Sie mit TAP - der Mechanik und dem Verhalten des Programms in asynchronem Code (Freigeben von Threads, Starten von Aufgaben und Warten auf deren Abschluss)
  4. Hinter den Kulissen: die Zustandsmaschine - eine Übersicht über die Compiler-Transformationen und die von ihr generierten Klassen
  5. Die Ursprünge der Asynchronität. Das Gerät der asynchronen Standardmethoden - asynchrone Methoden für die Arbeit mit Dateien und dem Netzwerk von innen
  6. TAP-Klassen und -Tricks sind nützliche Tricks, mit denen Sie ein Programm mithilfe von TAP verwalten und beschleunigen können

Asynchrones Konzept


Asynchronität an sich ist alles andere als neu. Asynchronität impliziert normalerweise das Ausführen einer Operation in einem Stil, der nicht das Blockieren des aufrufenden Threads bedeutet, dh das Starten der Operation, ohne auf deren Abschluss zu warten. Blockieren ist nicht so böse wie beschrieben. Man kann auf Behauptungen stoßen, dass blockierte Threads CPU-Zeit verschwenden, langsamer arbeiten und Regen verursachen. Scheint letzteres unwahrscheinlich? Tatsächlich sind die vorherigen 2 Punkte gleich.

Wenn sich ein Thread auf der Ebene des Betriebssystem-Schedulers in einem "blockierten" Zustand befindet, wird ihm keine wertvolle Prozessorzeit zugewiesen. Scheduler-Aufrufe fallen in der Regel auf Vorgänge, die Blockierungen, Timer-Interrupts und andere Interrupts verursachen. Das heißt, wenn beispielsweise der Plattencontroller den Lesevorgang abschließt und einen geeigneten Interrupt initiiert, startet der Scheduler. Er entscheidet, ob ein Thread gestartet werden soll, der durch diesen Vorgang blockiert wurde, oder ein anderer mit einer höheren Priorität.

Langsame Arbeit scheint noch absurder. In der Tat ist die Arbeit ein und dieselbe. Nur die asynchrone Operation erhöht den Overhead etwas.

Die Herausforderung des Regens ist in der Regel nicht etwas aus diesem Bereich.

Das Hauptblockierungsproblem ist der unangemessene Verbrauch von Computerressourcen. Selbst wenn wir die Zeit vergessen, einen Thread zu erstellen und mit einem Pool von Threads zu arbeiten, benötigt jeder blockierte Thread zusätzlichen Speicherplatz. Nun, es gibt Szenarien, in denen nur ein Thread bestimmte Arbeiten ausführen kann (z. B. ein UI-Thread). Dementsprechend möchte ich nicht, dass er mit einer Aufgabe beschäftigt ist, die ein anderer Thread ausführen kann, und die Leistung von Operationen opfert, die ausschließlich für ihn gelten.

Asynchronität ist ein sehr weit gefasstes Konzept und kann auf viele Arten erreicht werden.
In der Geschichte von .NET kann Folgendes unterschieden werden :

  1. EAP (Event-based Asynchronous Pattern) - Wie der Name schon sagt, basiert die Wanderung auf Ereignissen, die nach Abschluss der Operation ausgelöst werden, und der üblichen Methode, die diese Operation aufruft
  2. APM (Asynchronous Programming Model) - basierend auf 2 Methoden. Die BeginSmth-Methode gibt die IAsyncResult-Schnittstelle zurück. Die EndSmth-Methode akzeptiert IAsyncResult (wenn der Vorgang zum Zeitpunkt des Aufrufs von EndSmth nicht abgeschlossen ist, wird der Thread blockiert).
  3. TAP (Task-based Asynchronous Pattern) ist das gleiche asynchrone / warten (genau genommen erschienen diese Wörter nach dem Ansatz und die Arten von Task und Task <TResult> erschienen, aber async / await hat dieses Konzept erheblich verbessert).

Der letztere Ansatz war so erfolgreich, dass jeder die vorherigen erfolgreich vergaß. Es wird also um ihn gehen.

Aufgabenbasiertes asynchrones Muster. Syntax- und Kompilierungsbedingungen


Die asynchrone Standardmethode im TAP-Stil ist sehr einfach zu schreiben.

Dazu benötigen Sie :

  1. Damit der Rückgabewert Task, Task <T> oder void ist (nicht empfohlen, wird später erläutert). In C # 7 kamen aufgabenähnliche Typen (im letzten Kapitel besprochen). In C # 8 werden IAsyncEnumerable <T> und IAsyncEnumerator <T> zu dieser Liste hinzugefügt.
  2. Damit ist die Methode mit dem Schlüsselwort async markiert und enthält wait in. Diese Schlüsselwörter sind gepaart. Wenn die Methode "Warten" enthält, müssen Sie sie außerdem als asynchron markieren. Das Gegenteil ist nicht der Fall, aber sinnlos
  3. Halten Sie sich aus Anstand an die Async-Suffix-Konvention. Natürlich wird der Compiler dies nicht als Fehler betrachten. Wenn Sie ein sehr anständiger Entwickler sind, können Sie mit einem CancellationToken (im letzten Kapitel beschrieben) Überladungen hinzufügen.

Für solche Methoden leistet der Compiler ernsthafte Arbeit. Und sie werden hinter den Kulissen völlig unkenntlich, aber dazu später mehr.

Es wurde erwähnt, dass die Methode das Schlüsselwort await enthalten sollte. Es (das Wort) zeigt an, dass asynchron auf die Ausführung der Aufgabe gewartet werden muss. Dies ist das Aufgabenobjekt, auf das es angewendet wird.

Das Task-Objekt hat auch bestimmte Bedingungen, so dass das Warten auf es angewendet werden kann:

  1. Der erwartete Typ muss eine öffentliche (oder interne) GetAwaiter () -Methode haben. Er kann auch eine Erweiterungsmethode sein. Diese Methode gibt ein Warteobjekt zurück.
  2. Das Warteobjekt muss die INotifyCompletion-Schnittstelle implementieren, für die die void OnCompleted-Methode (Action Continuation) implementiert werden muss. Es sollte auch die Instanzeigenschaft bool IsCompleted haben, die void GetResult () -Methode. Es kann entweder eine Struktur oder eine Klasse sein.

Das folgende Beispiel zeigt, wie ein int erwartet und sogar nie ausgeführt wird.

Erweiterung int
public class Program { public static async Task Main() { await 1; } } public static class WeirdExtensions { public static AnyTypeAwaiter GetAwaiter(this int number) => new AnyTypeAwaiter(); public class AnyTypeAwaiter : INotifyCompletion { public bool IsCompleted => false; public void OnCompleted(Action continuation) { } public void GetResult() { } } } 



Arbeiten Sie mit TAP


Es ist schwierig, in den Dschungel zu gehen, ohne zu verstehen, wie etwas funktionieren soll. Berücksichtigen Sie TAP im Hinblick auf das Programmverhalten.

In der Terminologie: Die fragliche asynchrone Methode, deren Code berücksichtigt wird, ich werde die asynchrone Methode aufrufen, und die aufgerufenen asynchronen Methoden darin werde ich die asynchrone Operation aufrufen.

Nehmen wir das einfachste Beispiel: Als asynchrone Operation verwenden wir Task.Delay, das um die angegebene Zeit verzögert, ohne den Stream zu blockieren.

 public static async Task DelayOperationAsync() //   { BeforeCall(); Task task = Task.Delay(1000); //  AfterCall(); await task; AfterAwait(); } 

Die Ausführung der Methode in Bezug auf das Verhalten ist wie folgt.

  1. Der gesamte Code, der dem Aufruf der asynchronen Operation vorausgeht, wird ausgeführt. In diesem Fall ist dies die BeforeCall- Methode
  2. Ein asynchroner Operationsaufruf wird ausgeführt. Zu diesem Zeitpunkt wird der Thread nicht freigegeben oder blockiert. Diese Operation gibt das Ergebnis zurück - das erwähnte Task-Objekt (normalerweise Task), das in einer lokalen Variablen gespeichert ist
  3. Der Code wird nach dem Aufrufen der asynchronen Operation, jedoch vor dem Warten (Warten) ausgeführt. Im Beispiel - AfterCall
  4. Warten auf den Abschluss des Aufgabenobjekts (das in einer lokalen Variablen gespeichert ist) - Warten Sie auf die Aufgabe.

    Wenn der asynchrone Vorgang zu diesem Zeitpunkt abgeschlossen ist, wird die Ausführung synchron im selben Thread fortgesetzt.

    Wenn die asynchrone Operation nicht abgeschlossen ist, wird der Code gespeichert, der nach Abschluss der asynchronen Operation (der sogenannten Fortsetzung) aufgerufen werden muss, und der Stream kehrt zum Thread-Pool zurück und steht zur Verwendung zur Verfügung.
  5. Die Ausführung von Operationen nach dem Warten - AfterAwait - wird entweder sofort im selben Thread ausgeführt, wenn die Operation zum Zeitpunkt des Wartens abgeschlossen wurde, oder nach Abschluss der Operation wird ein neuer Thread erstellt, der fortgesetzt wird (im vorherigen Schritt gespeichert).


Hinter den Kulissen. Zustandsmaschine


Tatsächlich wird unsere Methode vom Compiler in eine Stub-Methode umgewandelt, in der die generierte Klasse - die Zustandsmaschine - initialisiert wird. Dann startet es (die Maschine) und das in Schritt 2 verwendete Task-Objekt wird von der Methode zurückgegeben.

Von besonderem Interesse ist die MoveNext- Methode der Zustandsmaschine . Diese Methode macht das, was es vor der Konvertierung in der asynchronen Methode war. Es bricht den Code zwischen jedem wartenden Anruf. Jedes Teil wird in einem bestimmten Zustand der Maschine ausgeführt. Die MoveNext- Methode selbst wird als Fortsetzung an das Warteobjekt angehängt. Die Erhaltung des Staates garantiert die Ausführung genau des Teils, der logischerweise der Erwartung entsprach.

Wie sie sagen, ist es besser, 1 Mal zu sehen, als 100 Mal zu hören. Ich empfehle Ihnen daher dringend, sich mit dem folgenden Beispiel vertraut zu machen. Ich habe den Code ein wenig umgeschrieben, die Benennung von Variablen verbessert und großzügig kommentiert.

Quellcode
 public static async Task Delays() { Console.WriteLine(1); await Task.Delay(1000); Console.WriteLine(2); await Task.Delay(1000); Console.WriteLine(3); await Task.Delay(1000); Console.WriteLine(4); await Task.Delay(1000); Console.WriteLine(5); await Task.Delay(1000); } 


Stub-Methode
 [AsyncStateMachine(typeof(DelaysStateMachine))] [DebuggerStepThrough] public Task Delays() { DelaysStateMachine stateMachine = new DelaysStateMachine(); stateMachine.taskMethodBuilder = AsyncTaskMethodBuilder.Create(); stateMachine.currentState = -1; AsyncTaskMethodBuilder builder = stateMachine.taskMethodBuilder; taskMethodBuilder.Start(ref stateMachine); return stateMachine.taskMethodBuilder.Task; } 


Zustandsmaschine
 [CompilerGenerated] private sealed class DelaysStateMachine : IAsyncStateMachine { //  ,     await   //       await'a public int currentState; public AsyncTaskMethodBuilder taskMethodBuilder; //   private TaskAwaiter taskAwaiter; //  ,             ""  public int paramInt; private int localInt; private void MoveNext() { int num = currentState; try { TaskAwaiter awaiter5; TaskAwaiter awaiter4; TaskAwaiter awaiter3; TaskAwaiter awaiter2; TaskAwaiter awaiter; switch (num) { default: localInt = paramInt; //  await Console.WriteLine(1); //  await awaiter5 = Task.Delay(1000).GetAwaiter(); //  await if (!awaiter5.IsCompleted) //  await. ,    { num = (currentState = 0); // ,      taskAwaiter = awaiter5; //    ,        DelaysStateMachine stateMachine = this; //    taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter5, ref stateMachine); //                 return; } goto Il_AfterFirstAwait; //  ,   ,    case 0: //            ,        .   ,          awaiter5 = taskAwaiter; //   taskAwaiter = default(TaskAwaiter); //   num = (currentState = -1); //  goto Il_AfterFirstAwait; //       case 1: //  ,      ,    ,     . awaiter4 = taskAwaiter; taskAwaiter = default(TaskAwaiter); num = (currentState = -1); goto Il_AfterSecondAwait; case 2: // ,     . awaiter3 = taskAwaiter; taskAwaiter = default(TaskAwaiter); num = (currentState = -1); goto Il_AfterThirdAwait; case 3: //    awaiter2 = taskAwaiter; taskAwaiter = default(TaskAwaiter); num = (currentState = -1); goto Il_AfterFourthAwait; case 4: //    { awaiter = taskAwaiter; taskAwaiter = default(TaskAwaiter); num = (currentState = -1); break; } Il_AfterFourthAwait: awaiter2.GetResult(); Console.WriteLine(5); //     awaiter = Task.Delay(1000).GetAwaiter(); //   if (!awaiter.IsCompleted) { num = (currentState = 4); taskAwaiter = awaiter; DelaysStateMachine stateMachine = this; taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine); return; } break; Il_AfterFirstAwait: //  ,        awaiter5.GetResult(); //       Console.WriteLine(2); //  ,     await awaiter4 = Task.Delay(1000).GetAwaiter(); //    if (!awaiter4.IsCompleted) { num = (currentState = 1); taskAwaiter = awaiter4; DelaysStateMachine stateMachine = this; taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter4, ref stateMachine); return; } goto Il_AfterSecondAwait; Il_AfterThirdAwait: awaiter3.GetResult(); Console.WriteLine(4); //     awaiter2 = Task.Delay(1000).GetAwaiter(); //   if (!awaiter2.IsCompleted) { num = (currentState = 3); taskAwaiter = awaiter2; DelaysStateMachine stateMachine = this; taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter2, ref stateMachine); return; } goto Il_AfterFourthAwait; Il_AfterSecondAwait: awaiter4.GetResult(); Console.WriteLine(3); //     awaiter3 = Task.Delay(1000).GetAwaiter(); //   if (!awaiter3.IsCompleted) { num = (currentState = 2); taskAwaiter = awaiter3; DelaysStateMachine stateMachine = this; taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter3, ref stateMachine); return; } goto Il_AfterThirdAwait; } awaiter.GetResult(); } catch (Exception exception) { currentState = -2; taskMethodBuilder.SetException(exception); return; } currentState = -2; taskMethodBuilder.SetResult(); //    ,   ,       } void IAsyncStateMachine.MoveNext() {...} [DebuggerHidden] private void SetStateMachine(IAsyncStateMachine stateMachine) {...} void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) {...} } 


Ich konzentriere mich auf den Satz "zu diesem Zeitpunkt wurde nicht synchron ausgeführt." Eine asynchrone Operation kann auch einem synchronen Ausführungspfad folgen. Die Hauptbedingung für die synchrone Ausführung der aktuellen asynchronen Methode, dh ohne Änderung des Threads, ist die Vollständigkeit der asynchronen Operation zum Zeitpunkt der IsCompleted- Überprüfung.

Dieses Beispiel zeigt dieses Verhalten deutlich.
 static async Task Main() { Console.WriteLine(Thread.CurrentThread.ManagedThreadId); //1 Task task = Task.Delay(1000); Thread.Sleep(1700); await task; Console.WriteLine(Thread.CurrentThread.ManagedThreadId); //1 } 


Informationen zum Synchronisierungskontext. Die auf dem Computer verwendete Methode AwaitUnsafeOnCompleted führt letztendlich zu einem Aufruf der Task.SetContinuationForAwait- Methode. Bei dieser Methode wird der aktuelle Synchronisationskontext SynchronizationContext.Current abgerufen. Der Synchronisationskontext kann als eine Art Stream interpretiert werden. Falls es vorhanden und spezifisch ist (z. B. der Kontext des UI-Threads), wird die Fortsetzung mithilfe der SynchronizationContextAwaitTaskContinuation- Klasse erstellt. Diese Klasse zum Starten der Fortsetzung ruft die Post-Methode für den gespeicherten Kontext auf, wodurch sichergestellt wird, dass die Fortsetzung genau in dem Kontext ausgeführt wird, in dem die Methode ausgeführt wurde. Die spezifische Logik zum Ausführen der Fortsetzung hängt von der Post- Methode in einem Kontext ab, der, gelinde gesagt, nicht für die Geschwindigkeit bekannt ist. Wenn kein Synchronisationskontext vorhanden war (oder angegeben wurde, dass es für uns nicht wichtig ist, in welchem ​​Kontext die Ausführung mit ConfigureAwait (false) fortgesetzt wird, was im letzten Kapitel erläutert wird), wird die Fortsetzung vom Thread aus dem Pool ausgeführt.

Die Ursprünge der Asynchronität. Die asynchronen Standardmethoden des Geräts


Wir haben uns angesehen, wie eine Methode mit Async und Warten aussieht und was hinter den Kulissen passiert. Diese Informationen sind nicht ungewöhnlich. Es ist jedoch wichtig, die Art der asynchronen Operationen zu verstehen. Denn wie wir in der Zustandsmaschine gesehen haben, werden asynchrone Operationen im Code aufgerufen, es sei denn, ihr Ergebnis wird schlauer verarbeitet. Was passiert jedoch innerhalb der asynchronen Operationen selbst? Wahrscheinlich das gleiche, aber das kann nicht unendlich passieren.

Eine wichtige Aufgabe ist es, die Natur der Asynchronität zu verstehen. Beim Versuch, Asynchronität zu verstehen, gibt es einen Wechsel der Zustände "jetzt klar" und "jetzt wieder unverständlich". Und diese Abwechslung wird sein, bis die Quelle der Asynchronität verstanden ist.

Wenn wir mit Asynchronität arbeiten, bearbeiten wir Aufgaben. Dies ist überhaupt nicht dasselbe wie ein Stream. Eine Aufgabe kann von vielen Threads ausgeführt werden, und ein Thread kann viele Aufgaben ausführen.

Asynchronität beginnt normalerweise mit einer Methode, die beispielsweise Task zurückgibt, jedoch nicht mit Async gekennzeichnet ist und dementsprechend kein Warten im Inneren verwendet. Diese Methode toleriert keine Compileränderungen und wird unverändert ausgeführt.

Schauen wir uns also einige der Wurzeln der Asynchronität an.

  1. Task.Run, neue Task (..). Start (), Factory.StartNew und dergleichen. Der einfachste Weg, um die asynchrone Ausführung zu starten. Diese Methoden erstellen einfach ein neues Aufgabenobjekt und übergeben einen Delegaten als einen der Parameter. Die Aufgabe wird an den Scheduler übertragen, der sie von einem der Threads im Pool ausführen lässt. Die zu erwartende fertige Aufgabe wird zurückgegeben. In der Regel wird dieser Ansatz verwendet, um die Berechnung (CPU-gebunden) in einem separaten Thread zu starten.
  2. TaskCompletionSource. Eine Hilfsklasse, mit der das Aufgabenobjekt gesteuert werden kann. Entwickelt für diejenigen, die keinen Delegaten für die Implementierung zuweisen können und komplexere Mechanismen zur Steuerung der Fertigstellung verwenden. Es hat eine sehr einfache API - SetResult, SetError usw., die die Aufgabe entsprechend aktualisiert. Diese Aufgabe ist über die Eigenschaft Aufgabe verfügbar. Vielleicht erstellen Sie in Ihrem Inneren Threads, haben eine komplexe Logik für deren Interaktion oder Vervollständigung nach Ereignis. Ein wenig mehr Details zu dieser Klasse finden Sie im letzten Abschnitt.

In einem zusätzlichen Absatz können Sie die Methoden von Standardbibliotheken festlegen. Dazu gehören das Lesen / Schreiben von Dateien, das Arbeiten mit einem Netzwerk und dergleichen. In der Regel verwenden solche gängigen und gebräuchlichen Methoden Systemaufrufe, die auf verschiedenen Plattformen variieren, und ihr Gerät ist äußerst unterhaltsam. Arbeiten Sie mit Dateien und dem Netzwerk.

Dateien


Ein wichtiger Hinweis: Wenn Sie mit Dateien arbeiten möchten, müssen Sie beim Erstellen von FileStream useAsync = true angeben.

Alles ist nicht trivial und verwirrend in Akten angeordnet. Die FileStream-Klasse wird als partiell deklariert. Außerdem gibt es 6 weitere plattformspezifische Add-Ons. Unter Unix verwendet der asynchrone Zugriff auf eine beliebige Datei in der Regel eine synchrone Operation in einem separaten Thread. In Windows gibt es Systemaufrufe für den asynchronen Betrieb, die natürlich verwendet werden. Dies führt zu Unterschieden in der Arbeit auf verschiedenen Plattformen. Quellen .

Unix

Das Standardverhalten beim Schreiben oder Lesen besteht darin, die Operation synchron auszuführen, wenn der Puffer dies zulässt und der Stream nicht mit einer anderen Operation beschäftigt ist:

1. Stream ist nicht mit einem anderen Vorgang beschäftigt

Die Filestream-Klasse verfügt über ein von SemaphoreSlim geerbtes Objekt mit den Parametern (1, 1), dh einem kritischen Abschnitt. Ein durch dieses Semaphor geschütztes Codefragment kann jeweils nur von einem Thread ausgeführt werden. Dieses Semaphor wird sowohl zum Lesen als auch zum Schreiben verwendet. Das heißt, es ist unmöglich, gleichzeitig Lesen und Schreiben zu erzeugen. In diesem Fall tritt keine Blockierung des Semaphors auf. Die Methode this._asyncState.WaitAsync () wird darauf aufgerufen, die das Task-Objekt zurückgibt (es gibt keine Sperre oder Wartezeit, wenn das Schlüsselwort await auf das Ergebnis der Methode angewendet würde). Wenn das angegebene Aufgabenobjekt nicht abgeschlossen ist, dh das Semaphor erfasst wird, wird die Fortsetzung (Task.ContinueWith), in der die Operation ausgeführt wird, an das zurückgegebene Warteobjekt angehängt. Wenn das Objekt frei ist, müssen Sie Folgendes überprüfen

2. Der Puffer erlaubt

Hier hängt das Verhalten bereits von der Art der Operation ab.

Für die Aufzeichnung wird überprüft, ob die Größe der Daten zum Schreiben + Position in der Datei geringer ist als die Größe des Puffers, der standardmäßig 4096 Byte beträgt. Das heißt, wir müssen von Anfang an 4096 Bytes schreiben, 2048 Bytes mit einem Offset von 2048 und so weiter. Ist dies der Fall, wird die Operation synchron ausgeführt, andernfalls wird die Fortsetzung angehängt (Task.ContinueWith). Die Fortsetzung verwendet einen regulären synchronen Systemaufruf. Wenn der Puffer voll ist, wird er synchron auf die Festplatte geschrieben.
Zum Lesen wird geprüft, ob sich genügend Daten im Puffer befinden, um alle erforderlichen Daten zurückzugeben. Wenn nicht, dann wieder eine Fortsetzung (Task.ContinueWith) mit einem synchronen Systemaufruf.

Übrigens gibt es ein interessantes Detail. Wenn ein Datenelement den gesamten Puffer belegt, werden sie ohne Beteiligung des Puffers direkt in die Datei geschrieben. Gleichzeitig gibt es eine Situation, in der mehr Daten als die Größe des Puffers vorhanden sind, die jedoch alle durchlaufen werden. Dies geschieht, wenn sich bereits etwas im Puffer befindet. Dann werden unsere Daten in zwei Teile geteilt, einer füllt den Puffer bis zum Ende und die Daten werden in die Datei geschrieben, der zweite wird in den Puffer geschrieben, wenn er in ihn gelangt, oder direkt in die Datei, wenn dies nicht der Fall ist. Wenn wir also einen Stream erstellen und 4097 Bytes in ihn schreiben, werden sie sofort in der Datei angezeigt, ohne Dispose aufzurufen. Wenn wir 4095 schreiben, ist nichts in der Datei.

Windows

Unter Windows ist der Algorithmus zum Verwenden des Puffers und zum direkten Schreiben sehr ähnlich. Ein signifikanter Unterschied wird jedoch direkt bei den Schreib- und Leseaufrufen des asynchronen Systems beobachtet. Wenn Sie sprechen, ohne tief in Systemaufrufe einzusteigen, gibt es eine solche überlappende Struktur. Es hat ein wichtiges Feld für uns - HANDLE HEvent. Dies ist ein manuelles Rücksetzereignis, das nach Abschluss eines Vorgangs in den Alarmzustand wechselt. Zurück zur Implementierung. Beim direkten Schreiben sowie beim Schreiben in den Puffer werden asynchrone Systemaufrufe verwendet, die die obige Struktur als Parameter verwenden. Bei der Aufzeichnung wird ein FileStreamCompletionSource-Objekt erstellt - ein Erbe von TaskCompletionSource, in dem IOCallback angegeben ist. Es wird von einem freien Thread aus dem Pool aufgerufen, wenn der Vorgang abgeschlossen ist. Im Rückruf wird die überlappende Struktur analysiert und das Task-Objekt entsprechend aktualisiert. Das ist alles Magie.

Netzwerk


Es ist schwierig, alles zu beschreiben, was ich gesehen habe, um die Quelle zu verstehen. Mein Pfad lag von HttpClient zu Socket und zu SocketAsyncContext für Unix. Das allgemeine Schema ist das gleiche wie bei Dateien. Für Windows wird die erwähnte überlappende Struktur verwendet und der Vorgang wird asynchron ausgeführt. Unter Unix verwenden Netzwerkvorgänge auch Rückruffunktionen.

Und eine kleine Erklärung. Ein aufmerksamer Leser wird feststellen, dass bei der Verwendung von asynchronen Anrufen zwischen einem Anruf und einem Rückruf eine gewisse Leere vorliegt, die irgendwie mit Daten funktioniert. Hier lohnt es sich, der Vollständigkeit halber zu klären. Am Beispiel von Dateien führt der Plattencontroller direkte Operationen mit der Platte durch den Plattencontroller aus. Er gibt die Signale zum Bewegen der Köpfe in den gewünschten Sektor usw. Der Prozessor ist zu diesem Zeitpunkt frei. Die Kommunikation mit der Festplatte erfolgt über die Eingabe- / Ausgabeports. Sie geben die Art des Vorgangs, den Speicherort der Daten auf der Festplatte usw. an. Als nächstes werden die Steuerung und die Festplatte in diesen Vorgang einbezogen und erzeugen nach Abschluss der Arbeit einen Interrupt. Dementsprechend trägt ein asynchroner Systemaufruf nur Informationen zu den Eingabe- / Ausgabeports bei, während der synchrone ebenfalls auf die Ergebnisse wartet und den Stream in einen blockierenden Zustand versetzt. Dieses Schema gibt nicht vor, absolut genau zu sein (nicht zu diesem Artikel), sondern vermittelt ein konzeptionelles Verständnis der Arbeit.

Jetzt ist die Art des Prozesses klar. Aber jemand könnte fragen, was mit Asynchronität zu tun ist. Es ist unmöglich, für immer asynchron über eine Methode zu schreiben.

Erstens. Ein Antrag kann als Dienst gestellt werden. In diesem Fall wird der Einstiegspunkt - Main - von Ihnen von Grund auf neu geschrieben. Bis vor kurzem konnte Main nicht asynchron sein. In Version 7 der Sprache wurde diese Funktion hinzugefügt. Aber es ändert nichts grundlegend, der Compiler generiert einfach das übliche Main und aus dem asynchronen wird nur eine statische Methode erstellt, die in Main aufgerufen wird und deren Abschluss synchron erwartet wird. Wahrscheinlich haben Sie also einige langlebige Aktionen. Aus irgendeinem Grund beginnen in diesem Moment viele Leute darüber nachzudenken, wie Threads für dieses Unternehmen erstellt werden können: über Task, ThreadPool oder Thread im Allgemeinen manuell, da es in etwas einen Unterschied geben sollte. Die Antwort ist einfach - natürlich Aufgabe. Wenn Sie den TAP-Ansatz verwenden, stören Sie die manuelle Thread-Erstellung nicht. Dies entspricht der Verwendung von HttpClient für fast alle Anforderungen, und POST erfolgt unabhängig über Socket.

Zweitens. Webanwendungen. Bei jeder eingehenden Anforderung wird ein neuer Thread zur Verarbeitung aus ThreadPool abgerufen. Der Pool ist natürlich groß, aber nicht unendlich. In dem Fall, dass viele Anforderungen vorhanden sind, sind möglicherweise überhaupt nicht genügend Threads vorhanden, und alle neuen Anforderungen werden zur Verarbeitung in die Warteschlange gestellt. Diese Situation nennt man Hunger. Bei Verwendung von asynchronen Controllern, wie bereits erläutert, kehrt der Stream jedoch in den Pool zurück und kann zur Verarbeitung neuer Anforderungen verwendet werden. Dadurch wird der Durchsatz des Servers deutlich erhöht.

Wir haben den asynchronen Prozess von Anfang bis Ende betrachtet. Mit dem Verständnis all dieser Asynchronität, die der menschlichen Natur widerspricht, werden wir einige nützliche Tricks in Betracht ziehen, wenn wir mit asynchronem Code arbeiten.

Nützliche Klassen und Tricks bei der Arbeit mit TAP


Die statische Vielfalt der Task-Klasse.


Die Task-Klasse verfügt über mehrere nützliche statische Methoden. Unten sind die wichtigsten.

  1. Task.WhenAny (..) ist ein Kombinator, der IEnumerable / params von Aufgabenobjekten verwendet und ein Aufgabenobjekt zurückgibt, das abgeschlossen wird, wenn die erste abgeschlossene Aufgabe abgeschlossen ist. Das heißt, Sie können auf eine von mehreren laufenden Aufgaben warten
  2. Task.WhenAll (..) - Kombinator, akzeptiert IEnumerable / params von Aufgabenobjekten und gibt ein Aufgabenobjekt zurück, das abgeschlossen wird, wenn alle übertragenen Aufgaben abgeschlossen sind
  3. Task.FromResult<T>(T value) — , .
  4. Task.Delay(..) —
  5. Task.Yield() — . , . , ,

ConfigureAwait


Natürlich die beliebteste "erweiterte" Funktion. Diese Methode gehört zur Task-Klasse und ermöglicht es Ihnen anzugeben, ob wir in demselben Kontext fortfahren müssen, in dem die asynchrone Operation aufgerufen wurde. Ohne diese Methode wird der Kontext standardmäßig gespeichert und mit der genannten Post-Methode fortgesetzt. Wie gesagt, Post ist ein sehr teures Vergnügen. Wenn die Leistung an erster Stelle steht und wir sehen, dass die Fortsetzung beispielsweise die Benutzeroberfläche nicht aktualisiert, können Sie sie im wartenden Objekt .ConfigureAwait (false) angeben . Dies bedeutet, dass es uns egal ist, wo die Fortsetzung durchgeführt wird.

Nun zum Problem. Wie sie sagen, ist beängstigend keine Unwissenheit, sondern falsches Wissen.

Irgendwie habe ich zufällig den Code einer Webanwendung beobachtet, bei der jeder asynchrone Aufruf mit diesem Beschleuniger dekoriert wurde. Dies hat keine andere Wirkung als visuellen Ekel. Die Standard-ASP.NET Core-Webanwendung hat keine eindeutigen Kontexte (es sei denn, Sie schreiben sie natürlich selbst). Daher wird die Post-Methode dort sowieso nicht aufgerufen.

TaskCompletionSource <T>


Eine Klasse, die das Verwalten eines Task-Objekts vereinfacht. Eine Klasse bietet zahlreiche Möglichkeiten, ist jedoch am nützlichsten, wenn wir eine Aufgabe mit einer Aktion abschließen möchten, deren Ende bei einem Ereignis auftritt. Im Allgemeinen wurde die Klasse erstellt, um alte asynchrone Methoden an TAP anzupassen, aber wie wir gesehen haben, wird sie nicht nur dafür verwendet. Ein kleines Beispiel für die Arbeit mit dieser Klasse:

Beispiel
 public static Task<string> GetSomeDataAsync() { TaskCompletionSource<string> tcs = new TaskCompletionSource<string>(); FileSystemWatcher watcher = new FileSystemWatcher { Path = Directory.GetCurrentDirectory(), NotifyFilter = NotifyFilters.LastAccess, EnableRaisingEvents = true }; watcher.Changed += (o, e) => tcs.SetResult(e.FullPath); return tcs.Task; } 


Diese Klasse erstellt einen asynchronen Wrapper, um den Namen der Datei abzurufen, auf die im aktuellen Ordner zugegriffen wurde.

CancellationTokenSource


Ermöglicht das Abbrechen eines asynchronen Vorgangs. Die allgemeine Gliederung ähnelt der Verwendung einer TaskCompletionSource. Zuerst wird var cts = new CancellationTokenSource () erstellt , das übrigens IDisposable ist, und dann wird cts.Token an asynchrone Operationen übergeben . Nach einer bestimmten Logik von Ihnen wird unter bestimmten Bedingungen die Methode cts.Cancel () aufgerufen . Es kann auch ein Ereignis oder etwas anderes abonnieren.

Die Verwendung eines CancellationToken ist eine gute Vorgehensweise. Wenn Sie Ihre asynchrone Methode schreiben, die beispielsweise im Hintergrund arbeitet, können Sie einfach eine Zeile in den Hauptteil der Schleife einfügen: cancellationToken.ThrowIfCancellationRequested () , wodurch eine Ausnahme ausgelöst wirdOperationCanceledException . Diese Ausnahme wird als Abbruch des Vorgangs behandelt und nicht als Ausnahme im Aufgabenobjekt gespeichert. Außerdem wird die IsCanceled- Eigenschaft für das Task-Objekt wahr.

Longrunning


Oft gibt es Situationen, insbesondere beim Schreiben von Diensten, in denen Sie mehrere Aufgaben erstellen, die während der gesamten Lebensdauer des Dienstes oder nur für eine sehr lange Zeit funktionieren. Wie wir uns erinnern, ist die Verwendung eines Thread-Pools zu Recht der Aufwand für die Erstellung eines Threads. Wenn jedoch selten ein Stream erstellt wird (auch nicht einmal pro Stunde), werden diese Kosten ausgeglichen und Sie können sicher separate Streams erstellen. Zu diesem

Zweck können Sie beim Erstellen einer Aufgabe eine spezielle Option angeben: Task.Factory.StartNew (Aktion, TaskCreationOptions.LongRunning )

. Ich empfehle Ihnen jedoch, alle Aufgaben zu betrachten. Factory.StartNew- Überladungen gibt es viele Möglichkeiten, die Aufgabe flexibel zu konfigurieren, um bestimmte Anforderungen zu erfüllen.

Ausnahmen


Aufgrund des nicht deterministischen Charakters der asynchronen Codeausführung ist die Frage der Ausnahmen sehr relevant. Es wäre eine Schande, wenn Sie die Ausnahme nicht abfangen könnten und sie in den linken Thread geworfen wurde, wodurch der Prozess beendet wurde. Eine ExceptionDispatchInfo- Klasse wurde erstellt, um eine Ausnahme in einem Thread abzufangen und darin zu werfen . Um die Ausnahme abzufangen, wird die statische Methode ExceptionDispatchInfo.Capture (ex) verwendet, die ExceptionDispatchInfo zurückgibt .Ein Link zu diesem Objekt kann an einen beliebigen Thread übergeben werden, der dann die Throw () -Methode aufruft, um ihn wegzuwerfen. Der Wurf selbst erfolgt NICHT am Ort des asynchronen Operationsaufrufs, sondern am Ort der Verwendung des Operators await. Und wie Sie wissen, kann Warten nicht auf Leere angewendet werden. Wenn der Kontext vorhanden war, wird er von der Post-Methode an ihn übergeben. Andernfalls wird es im Strom aus dem Pool angeregt. Und dies ist fast 100% Hallo zum Zusammenbruch der Anwendung. Und hier kommen wir zur Praxis der Tatsache, dass wir Task oder Task <T> verwenden sollten, aber nicht ungültig.

Und noch etwas. Der Scheduler verfügt über ein TaskScheduler.UnobservedTaskException- Ereignis , das ausgelöst wird , wenn eine UnobservedTaskException ausgelöst wird. Diese Ausnahme wird während der Speicherbereinigung ausgelöst, wenn der GC versucht, ein Taskobjekt mit einer nicht behandelten Ausnahme zu erfassen.

IAsyncEnumerable


Vor C # 8 und .NET Core 3.0 war es nicht möglich, einen Ertragsiterator in einer asynchronen Methode zu verwenden, was die Lebensdauer komplizierte und dazu führte, dass Task <IEnumerable <T>> von dieser Methode zurückgegeben wurde, d. H. Es gab keine Möglichkeit, die Sammlung zu durchlaufen, bis sie vollständig empfangen wurde. Jetzt gibt es eine solche Gelegenheit. Erfahren Sie hier mehr darüber . Dazu muss der Rückgabetyp IAsyncEnumerable <T> (oder IAsyncEnumerator <T> ) sein. Um eine solche Sammlung zu durchlaufen, sollten Sie die foreach-Schleife mit dem Schlüsselwort await verwenden. Außerdem können die Methoden WithCancellation und ConfigureAwait für das Ergebnis der Operation aufgerufen werden, wobei das verwendete CancelationToken und die Notwendigkeit angegeben werden, im selben Kontext fortzufahren.

Wie erwartet wird alles so faul wie möglich gemacht.
Unten ist ein Beispiel und die Schlussfolgerung, die er gibt.

Beispiel
 public class Program { public static async Task Main() { Stopwatch sw = new Stopwatch(); sw.Start(); IAsyncEnumerable<int> enumerable = AsyncYielding(); Console.WriteLine($"Time after calling: {sw.ElapsedMilliseconds}"); await foreach (var element in enumerable.WithCancellation(..).ConfigureAwait(false)) { Console.WriteLine($"element: {element}"); Console.WriteLine($"Time: {sw.ElapsedMilliseconds}"); } } static async IAsyncEnumerable<int> AsyncYielding() { foreach (var uselessElement in Enumerable.Range(1, 3)) { Task task = Task.Delay(TimeSpan.FromSeconds(uselessElement)); Console.WriteLine($"Task run: {uselessElement}"); await task; yield return uselessElement; } } } 


Fazit:

Zeit nach dem Aufruf: 0
Aufgabenlauf: 1
Element: 1
Zeit: 1033
Aufgabenlauf: 2
Element: 2
Zeit: 3034
Aufgabenlauf: 3
Element: 3
Zeit: 6035


Threadpool


Diese Klasse wird beim Programmieren mit TAP aktiv verwendet. Daher werde ich die minimalen Details seiner Implementierung angeben. Im Inneren verfügt ThreadPool über ein Array von Warteschlangen: eine für jeden Thread + eine globale. Beim Hinzufügen eines neuen Jobs zum Pool wird der Thread berücksichtigt, der das Hinzufügen initiiert hat. Wenn es sich um einen Thread aus dem Pool handelt, wird die Arbeit in eine eigene Warteschlange für diesen Thread gestellt, wenn es sich um einen anderen Thread handelt - in den globalen. Wenn ein Thread für die Arbeit ausgewählt wird, wird zuerst seine lokale Warteschlange angezeigt. Wenn es leer ist, nimmt der Thread Jobs von der globalen. Wenn es leer ist, beginnt es, von den anderen zu stehlen. Außerdem sollten Sie sich niemals auf die Reihenfolge der Arbeit verlassen, da es tatsächlich keine Reihenfolge gibt. Die Standardanzahl der Threads in einem Pool hängt von vielen Faktoren ab, einschließlich der Größe des Adressraums. Wenn weitere Ausführungsanforderungen vorliegen,Als die Anzahl der verfügbaren Threads werden Anforderungen in die Warteschlange gestellt.

Threads in einem Thread-Pool sind Hintergrund-Threads (Eigenschaft isBackground = true). Dieser Thread-Typ unterstützt nicht die Lebensdauer des Prozesses, wenn alle Vordergrund-Threads abgeschlossen sind.

Der Systemthread überwacht den Status des Wartehandles. Wenn der Wartevorgang endet, wird der übertragene Rückruf vom Thread aus dem Pool ausgeführt (beachten Sie die Dateien in Windows).

Aufgabenartiger Typ


Dieser zuvor erwähnte Typ (Struktur oder Klasse) kann als Rückgabewert der asynchronen Methode verwendet werden. Ein Builder-Typ muss diesem Typ mithilfe des Attributs [AsyncMethodBuilder (..)] zugeordnet werden . Dieser Typ muss die oben genannten Merkmale aufweisen, um das Schlüsselwort await auf ihn anwenden zu können. Es kann für Methoden parametrisiert werden, die keinen Wert zurückgeben, und für Methoden, die einen Wert zurückgeben.

Der Builder selbst ist eine Klasse oder Struktur, deren Framework im folgenden Beispiel dargestellt ist. Die SetResult- Methode verfügt über einen Parameter vom Typ T für einen von T parametrisierten aufgabenähnlichen Typ. Für nicht parametrisierte Typen verfügt die Methode über keine Parameter.

Erforderliche Builder-Schnittstelle
 class MyTaskMethodBuilder<T> { public static MyTaskMethodBuilder<T> Create(); public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine; public void SetStateMachine(IAsyncStateMachine stateMachine); public void SetException(Exception exception); public void SetResult(T result); public void AwaitOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine; public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine; public MyTask<T> Task { get; } } 


Das Prinzip der Arbeit unter dem Gesichtspunkt des Schreibens Ihres aufgabenähnlichen Typs wird nachstehend beschrieben. Das meiste davon wurde bereits beim Parsen des vom Compiler generierten Codes beschrieben.

Der Compiler verwendet alle diese Typen, um eine Zustandsmaschine zu generieren. Der Compiler weiß, welche Builder für die ihm bekannten Typen verwendet werden sollen. Hier geben wir an, was während der Codegenerierung verwendet wird. Wenn die Zustandsmaschine eine Struktur ist, wird sie beim Aufrufen von SetStateMachine gepackt . Der Builder kann die gepackte Kopie bei Bedarf zwischenspeichern. Der Builder muss stateMachine.MoveNext in der Start- Methode oder nach dem Aufruf aufrufen , um die Ausführung zu starten und die Zustandsmaschine voranzutreiben. Nach dem Aufruf von Startwird die Task-Eigenschaft von der Methode zurückgegeben. Ich empfehle, dass Sie zur Stub-Methode zurückkehren und diese Schritte anzeigen.

Wenn die Zustandsmaschine erfolgreich abgeschlossen wurde, wird die SetResult- Methode aufgerufen , andernfalls SetException . Wenn die Zustandsmaschine " wait" erreicht, wird die GetAwaiter () -Methode vom aufgabenähnlichen Typ ausgeführt . Wenn das Warteobjekt die Schnittstelle ICriticalNotifyCompletion und IsCompleted = false implementiert , verwendet die Zustandsmaschine builder.AwaitUnsafeOnCompleted (ref awaiter, ref stateMachine) . Die AwaitUnsafeOnCompleted- Methode sollte awaiter.OnCompleted (Aktion) aufrufen. In Aktion sollte stateMachine.MoveNext aufgerufen werdenwenn das Warteobjekt abgeschlossen ist. Ähnliches gilt für die INotifyCompletion- Schnittstelle und die builder.AwaitOnCompleted- Methode .

Wie Sie dies nutzen, liegt bei Ihnen. Aber ich rate Ihnen, über 514 Mal nachzudenken, bevor Sie dies in der Produktion anwenden, und nicht zum Verwöhnen. Unten finden Sie ein Anwendungsbeispiel. Ich habe nur einen Proxy für einen Standard-Builder entworfen, der der Konsole anzeigt, welche Methode zu welcher Zeit aufgerufen wurde. Übrigens möchte das asynchrone Main () keine benutzerdefinierten Erwartungen unterstützen (ich glaube, dass mehr als ein Produktionsprojekt aufgrund dieses Fehlschlags von Microsoft hoffnungslos beschädigt wurde). Wenn Sie möchten, können Sie den Proxy-Logger mit einem normalen Logger ändern und weitere Informationen protokollieren.

Proxy-Aufgabe protokollieren
 public class Program { public static void Main() { Console.WriteLine("Start"); JustMethod().Task.Wait(); //   Console.WriteLine("Stop"); } public static async LogTask JustMethod() { await DelayWrapper(1000); } public static LogTask DelayWrapper(int milliseconds) => new LogTask { Task = Task.Delay(milliseconds)}; } [AsyncMethodBuilder(typeof(LogMethodBuilder))] public class LogTask { public Task Task { get; set; } public TaskAwaiter GetAwaiter() => Task.GetAwaiter(); } public class LogMethodBuilder { private AsyncTaskMethodBuilder _methodBuilder = AsyncTaskMethodBuilder.Create(); private LogTask _task; public static LogMethodBuilder Create() { Console.WriteLine($"Method: Create; {DateTime.Now :O}"); return new LogMethodBuilder(); } public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine { Console.WriteLine($"Method: Start; {DateTime.Now :O}"); _methodBuilder.Start(ref stateMachine); } public void SetStateMachine(IAsyncStateMachine stateMachine) { Console.WriteLine($"Method: SetStateMachine; {DateTime.Now :O}"); _methodBuilder.SetStateMachine(stateMachine); } public void SetException(Exception exception) { Console.WriteLine($"Method: SetException; {DateTime.Now :O}"); _methodBuilder.SetException(exception); } public void SetResult() { Console.WriteLine($"Method: SetResult; {DateTime.Now :O}"); _methodBuilder.SetResult(); } public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine { Console.WriteLine($"Method: AwaitOnCompleted; {DateTime.Now :O}"); _methodBuilder.AwaitOnCompleted(ref awaiter, ref stateMachine); } public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine { Console.WriteLine($"Method: AwaitUnsafeOnCompleted; {DateTime.Now :O}"); _methodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine); } public LogTask Task { get { Console.WriteLine($"Property: Task; {DateTime.Now :O}"); return _task ??= new LogTask {Task = _methodBuilder.Task}; } set => _task = value; } } 


Fazit:

Startmethode
: Erstellen; 2019-10-09T17: 55: 13.7152733 + 03: 00
Methode: Start; 2019-10-09T17: 55: 13.7262226 + 03: 00
Methode: AwaitUnsafeOnCompleted; 2019-10-09T17: 55: 13.7275206 + 03: 00
Eigenschaft: Aufgabe; 2019-10-09T17: 55: 13.7292005 + 03: 00
Methode: SetResult; 2019-10-09T17: 55: 14.7297967 + 03: 00
Stop


Das ist alles, danke euch allen.

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


All Articles