Asynchrone Programmierung - asynchrone Leistung: Verstehen Sie die Kosten der asynchronen Programmierung und warten Sie

Dieser Artikel ist ziemlich alt, hat aber seine Relevanz nicht verloren. Wenn es um Async / Warten geht, wird normalerweise ein Link dazu angezeigt. Ich konnte keine Übersetzung ins Russische finden und beschloss, jemandem zu helfen, der nicht fließend ist.




Asynchrone Programmierung ist seit langem das Königreich der erfahrensten Entwickler mit einem Verlangen nach Masochismus - diejenigen, die genug Freizeit, Neigung und psychische Fähigkeit hatten, um Rückrufe von Rückrufen in einem nichtlinearen Ablauf der Ausführung zu betrachten. Mit dem Aufkommen von Microsoft .NET Framework 4.5 haben uns C # und Visual Basic alle Asynchronität gebracht, sodass bloße Sterbliche jetzt fast so einfach asynchrone Methoden schreiben können wie synchrone. Rückrufe werden nicht mehr benötigt. Kein explizites Marshalling von Code von einem Synchronisationskontext zum anderen. Machen Sie sich keine Sorgen mehr darüber, wie sich Ausführungsergebnisse oder Ausnahmen verschieben. Es sind keine Tricks erforderlich, die die Mittel der Programmiersprachen verzerren, um asynchronen Code zu entwickeln. Kurz gesagt, es gibt keine Probleme und Kopfschmerzen mehr.


Obwohl es jetzt einfach ist, asynchrone Methoden zu schreiben (siehe die Artikel von Eric Lippert und Mads Torgersen in diesem MSDN-Magazin [OKTOBER 2011] ), ist Verständnis erforderlich, um dies korrekt zu tun. Was passiert unter der Haube? Jedes Mal, wenn eine Sprache oder Bibliothek den Abstraktionsgrad erhöht, den ein Entwickler verwenden kann, geht dies unweigerlich mit versteckten Kosten einher, die die Produktivität verringern. In vielen Fällen sind diese Kosten vernachlässigbar, so dass sie in den meisten Fällen von den meisten Programmierern vernachlässigt werden können. Fortgeschrittene Entwickler sollten jedoch vollständig verstehen, welche Kosten anfallen, um die erforderlichen Maßnahmen zu ergreifen und mögliche Probleme zu lösen, wenn sie sich manifestieren. Dies ist erforderlich, wenn asynchrone Programmiertools in C # und Visual Basic verwendet werden.


In diesem Artikel werde ich die Ein- und Ausgaben von asynchronen Methoden beschreiben, beschreiben, wie asynchrone Methoden implementiert werden, und einige der geringeren Kosten diskutieren. Beachten Sie, dass dies keine Empfehlung ist, lesbaren Code im Namen der Mikrooptimierung und Leistung in etwas zu verzerren, das schwer zu warten ist. Dies ist nur das Wissen, das bei der Diagnose von Problemen hilft, auf das Sie möglicherweise stoßen, und eine Reihe von Tools, um diese Probleme zu überwinden. Darüber hinaus basiert dieser Artikel auf der Vorschau von .NET Framework Version 4.5, und wahrscheinlich können sich die spezifischen Implementierungsdetails in der endgültigen Version ändern.


Holen Sie sich ein komfortables Denkmodell


Seit Jahrzehnten verwenden Programmierer die Programmiersprachen C #, Visual Basic, F # und C ++ auf hoher Ebene, um produktive Anwendungen zu entwickeln. Diese Erfahrung ermöglichte es Programmierern, die Kosten verschiedener Vorgänge zu bewerten und Kenntnisse über die besten Entwicklungstechniken zu erlangen. In den meisten Fällen ist das Aufrufen einer synchronen Methode beispielsweise relativ wirtschaftlich, insbesondere wenn der Compiler den Inhalt der aufgerufenen Methode direkt in den Aufrufpunkt einbetten kann. Daher sind Entwickler daran gewöhnt, den Code in kleine, einfach zu wartende Methoden zu unterteilen, ohne sich über die negativen Folgen einer Erhöhung der Anzahl der Aufrufe Gedanken machen zu müssen. Das Denkmodell dieser Programmierer ist für die Verarbeitung von Methodenaufrufen ausgelegt.


Mit dem Aufkommen asynchroner Methoden ist ein neues Denkmodell erforderlich. C # und Visual Basic mit ihren Compilern können die Illusion erzeugen, dass die asynchrone Methode als synchrones Gegenstück fungiert, obwohl innen alles völlig falsch ist. Der Compiler generiert eine große Menge an Code für den Programmierer, ähnlich der Standardvorlage, die die Entwickler geschrieben haben, um die Asynchronität zu unterstützen, als dies von Hand erforderlich war. Darüber hinaus enthält der vom Compiler generierte Code Aufrufe der Bibliotheksfunktionen von .NET Framework, wodurch der Arbeitsaufwand für einen Programmierer weiter reduziert wird. Um das richtige Denkmodell zu haben und damit fundierte Entscheidungen zu treffen, ist es wichtig zu verstehen, was der Compiler für Sie generiert.


Mehr Methoden, weniger Aufrufe


Wenn Sie mit synchronem Code arbeiten, ist das Ausführen von Methoden mit leerem Inhalt praktisch wertlos. Bei asynchronen Methoden ist dies nicht der Fall. Betrachten Sie diese asynchrone Methode, die aus einer Anweisung besteht (und die aufgrund fehlender Anweisungen zum Warten synchron ausgeführt wird):


public static async Task SimpleBodyAsync() { Console.WriteLine("Hello, Async World!"); } 

Ein Intermediate Language Decompiler (IL) zeigt nach der Kompilierung den wahren Inhalt dieser Funktion an und gibt etwas Ähnliches wie in Abbildung 1 aus. Aus einem einfachen Einzeiler wurden zwei Methoden, von denen eine zur Hilfsklasse der Zustandsmaschine gehört. Die erste ist eine Stub-Methode, die eine ähnliche Signatur wie die vom Programmierer geschriebene hat (diese Methode hat denselben Namen, denselben Bereich, dieselben Parameter und denselben Typ), enthält jedoch keinen vom Programmierer geschriebenen Code. Es enthält nur eine Standard-Heizplatte für die Ersteinrichtung. Der anfängliche Setup-Code initialisiert die Zustandsmaschine, die zur Darstellung der asynchronen Methode benötigt wird, und startet sie mit einem Aufruf der Dienstprogrammmethode MoveNext. Der Objekttyp der Zustandsmaschine enthält eine Variable mit dem Ausführungsstatus der asynchronen Methode, sodass Sie diese beim Umschalten zwischen asynchronen Wartepunkten speichern können. Es enthält auch Code, der von einem Programmierer geschrieben wurde und geändert wurde, um die Übertragung von Ausführungsergebnissen und Ausnahmen an das zurückgegebene Task-Objekt sicherzustellen. Halten der aktuellen Position in der Methode, damit die Ausführung von dieser Position nach Wiederaufnahme usw. fortgesetzt werden kann.


Abbildung 1 Asynchrone Methodenvorlage


 [DebuggerStepThrough] public static Task SimpleBodyAsync() { <SimpleBodyAsync>d__0 d__ = new <SimpleBodyAsync>d__0(); d__.<>t__builder = AsyncTaskMethodBuilder.Create(); d__.MoveNext(); return d__.<>t__builder.Task; } [CompilerGenerated] [StructLayout(LayoutKind.Sequential)] private struct <SimpleBodyAsync>d__0 : <>t__IStateMachine { private int <>1__state; public AsyncTaskMethodBuilder <>t__builder; public Action <>t__MoveNextDelegate; public void MoveNext() { try { if (this.<>1__state == -1) return; Console.WriteLine("Hello, Async World!"); } catch (Exception e) { this.<>1__state = -1; this.<>t__builder.SetException(e); return; } this.<>1__state = -1; this.<>t__builder.SetResult(); } ... } 

Wenn Sie sich fragen, wie viel Aufrufe von asynchronen Methoden kosten, denken Sie an dieses Muster. Der try / catch-Block in der MoveNext-Methode wird benötigt, um einen möglichen Versuch zu verhindern, diese JIT-Methode durch den Compiler einzubetten, sodass zumindest die Kosten für den Aufruf der Methode anfallen, während dies bei Verwendung der synchronen Methode höchstwahrscheinlich nicht der Fall ist (vorausgesetzt, dass dies nicht der Fall ist) minimalistischer Inhalt). Wir werden mehrere Aufrufe von Framework-Prozeduren erhalten (z. B. SetResult). Sowie mehrere Schreibvorgänge in den Feldern des Zustandsmaschinenobjekts. Natürlich müssen wir all diese Kosten mit den Kosten von Console.WriteLine vergleichen, die wahrscheinlich vorherrschen (einschließlich der Kosten für Sperren, E / A usw.). Achten Sie auf die Optimierungen, die die Umgebung für Sie vornimmt. Beispielsweise wird ein Objekt einer Zustandsmaschine als Struktur (Struktur) implementiert. Diese Struktur wird nur dann in einem verwalteten Heap gespeichert, wenn die Methode die Ausführung anhalten und auf den Abschluss des Vorgangs warten muss. Dies wird bei dieser einfachen Methode niemals der Fall sein. Das Muster dieser asynchronen Methode erfordert also keine Speicherzuweisung vom Heap. Der Compiler und die Laufzeit versuchen, die Anzahl der Speicherzuweisungsvorgänge zu minimieren.


Wann sollte Async nicht verwendet werden?


Das .NET Framework versucht, mithilfe verschiedener Optimierungsmethoden effiziente Implementierungen für asynchrone Methoden zu generieren. Aufgrund ihrer Erfahrung wenden Entwickler jedoch häufig ihre Optimierungsmethoden an, die für die Automatisierung durch den Compiler und die Laufzeit riskant und unpraktisch sein können, da sie versuchen, universelle Ansätze zu verwenden. Wenn Sie es nicht vergessen, ist die Ablehnung der Verwendung von asynchronen Methoden in einer Reihe von speziellen Fällen von Vorteil, insbesondere für Methoden in Bibliotheken, die mit feineren Einstellungen verwendet werden können. Normalerweise geschieht dies, wenn bekannt ist, dass die Methode synchron ausgeführt werden kann, da die Daten, von denen sie abhängt, bereits bereit sind.


Beim Erstellen asynchroner Methoden haben .NET Framework-Entwickler viel Zeit damit verbracht, die Anzahl der Speicherverwaltungsvorgänge zu optimieren. Dies ist erforderlich, da die Speicherverwaltung die größten Kosten für die Leistung einer asynchronen Infrastruktur verursacht. Die Zuweisung von Speicher für ein Objekt ist normalerweise relativ kostengünstig. Das Zuweisen von Speicher für Objekte ähnelt dem Befüllen des Einkaufswagens mit Produkten im Supermarkt. Sie geben nichts aus, wenn Sie sie in den Einkaufswagen legen. Ausgaben entstehen, wenn Sie an der Kasse bezahlen, Ihre Brieftasche herausnehmen und anständiges Geld geben. Wenn die Speicherzuweisung einfach ist, kann die nachfolgende Speicherbereinigung die Anwendungsleistung erheblich beeinträchtigen. Wenn Sie mit der Speicherbereinigung beginnen, werden Objekte gescannt und markiert, die sich derzeit im Speicher befinden, aber keine Verknüpfungen haben. Je mehr Objekte platziert werden, desto länger dauert das Markieren. Je größer die Anzahl der platzierten Objekte ist, desto häufiger ist eine Speicherbereinigung erforderlich. Dieser Aspekt der Arbeit mit dem Speicher hat globale Auswirkungen auf das System: Je mehr Müll durch asynchrone Methoden erzeugt wird, desto langsamer wird die Anwendung ausgeführt, auch wenn Mikrotests keine signifikanten Kosten für ihre Leistung aufweisen.


Bei asynchronen Methoden, die ihre Ausführung unterbrechen (auf Daten warten, die noch nicht bereit sind), muss die Umgebung ein Objekt vom Typ Task erstellen, das von der Methode zurückgegeben wird, da dieses Objekt als eindeutige Referenz auf den Aufruf dient. Oft können jedoch asynchrone Methodenaufrufe ohne Unterbrechung durchgeführt werden. Anschließend kann die Laufzeit das zuvor abgeschlossene Task-Objekt aus dem Cache zurückgeben, das immer wieder verwendet wird, ohne dass neue Task-Objekte erstellt werden müssen. Dies ist zwar nur unter bestimmten Bedingungen zulässig, z. B. wenn die asynchrone Methode ein nicht universelles (nicht generisches) Objekt Task, Task zurückgibt oder wenn die universelle Task durch einen Referenztyp TResult angegeben wird und null von der Methode zurückgegeben wird. Obwohl die Liste dieser Bedingungen im Laufe der Zeit erweitert wird, ist es immer noch besser, wenn Sie wissen, wie der Vorgang implementiert wird.

Betrachten Sie eine Implementierung wie MemoryStream. MemoryStream wird von Stream geerbt und definiert neue in .NET 4.5 implementierte Methoden neu: ReadAsync, WriteAsync und FlushAsync, um eine speicherspezifische Codeoptimierung bereitzustellen. Da die Leseoperation aus einem im Speicher befindlichen Puffer ausgeführt wird, dh tatsächlich eine Kopie des Speicherbereichs ist, ist die beste Leistung zu erzielen, wenn ReadAsync im synchronen Modus ausgeführt wird. Eine Implementierung in einer asynchronen Methode könnte folgendermaßen aussehen:


 public override async Task<int> ReadAsync(byte [] buffer, int offset, int count, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); return this.Read(buffer, offset, count); } 

Einfach genug. Und da Read ein synchroner Aufruf ist und die Methode keine Warteanweisungen zur Steuerung der Erwartungen hat, werden alle Aufrufe dieses ReadAsync tatsächlich synchron ausgeführt. Betrachten wir nun einen Standardfall für die Verwendung von Threads, beispielsweise eine Kopieroperation:


 byte [] buffer = new byte[0x1000]; int numRead; while((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0) { await source.WriteAsync(buffer, 0, numRead); } 

Beachten Sie, dass im angegebenen ReadAsync-Beispiel der Quelldatenstrom immer mit demselben Pufferlängenparameter aufgerufen wird. Dies bedeutet, dass der Rückgabewert (die Anzahl der gelesenen Bytes) sehr wahrscheinlich ebenfalls wiederholt wird. Außer in einigen seltenen Fällen ist es unwahrscheinlich, dass bei der Implementierung von ReadAsync das zwischengespeicherte Task-Objekt als Rückgabewert verwendet wird. Sie können dies jedoch tun.


Betrachten Sie eine andere Implementierungsoption für diese Methode (siehe Abbildung 2). Durch die Vorteile der inhärenten Aspekte in Standardskripten für diese Methode können wir die Implementierung optimieren, indem wir Speicherzuweisungsvorgänge ausschließen, die zur Laufzeit wahrscheinlich nicht zu erwarten sind. Wir können Speicherverlust vollständig beseitigen, indem wir dasselbe Task-Objekt zurückgeben, das im vorherigen ReadAsync-Aufruf verwendet wurde, wenn dieselbe Anzahl von Bytes gelesen wurde. Und für eine solche Operation auf niedriger Ebene, die wahrscheinlich sehr schnell ist und wiederholt aufgerufen wird, hat diese Optimierung erhebliche Auswirkungen, insbesondere auf die Anzahl der Speicherbereinigungen.


Abbildung 2 Optimierung der Aufgabenerstellung


 private Task<int> m_lastTask; public override Task<int> ReadAsync(byte [] buffer, int offset, int count, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) { var tcs = new TaskCompletionSource<int>(); tcs.SetCanceled(); return tcs.Task; } try { int numRead = this.Read(buffer, offset, count); return m_lastTask != null && numRead == m_lastTask.Result ? m_lastTask : (m_lastTask = Task.FromResult(numRead)); } catch(Exception e) { var tcs = new TaskCompletionSource<int>(); tcs.SetException(e); return tcs.Task; } } 

Eine ähnliche Optimierungsmethode durch Eliminieren der unnötigen Erstellung von Task-Objekten kann verwendet werden, wenn Caching erforderlich ist. Stellen Sie sich eine Methode vor, mit der der Inhalt einer Webseite abgerufen und zur späteren Bezugnahme zwischengespeichert werden kann. Als asynchrone Methode kann dies wie folgt geschrieben werden (unter Verwendung der neuen System.Net.Http.dll-Bibliothek für .NET 4.5):


 private static ConcurrentDictionary<string,string> s_urlToContents; public static async Task<string> GetContentsAsync(string url) { string contents; if (!s_urlToContents.TryGetValue(url, out contents)) { var response = await new HttpClient().GetAsync(url); contents = response.EnsureSuccessStatusCode().Content.ReadAsString(); s_urlToContents.TryAdd(url, contents); } return contents; } 

Dies ist eine Stirnimplementierung. Bei GetContentsAsync-Aufrufen, bei denen keine Daten im Cache gefunden werden, kann der Aufwand für das Erstellen eines neuen Task-Objekts im Vergleich zu den Kosten für den Empfang von Daten über das Netzwerk vernachlässigt werden. Wenn Sie jedoch Daten aus dem Cache abrufen, werden diese Kosten erheblich, wenn Sie einfach die verfügbaren lokalen Daten einpacken und angeben.

Um diese Kosten zu eliminieren (falls erforderlich, um eine hohe Leistung zu erzielen), können Sie die Methode wie in Abbildung 3 dargestellt neu schreiben. Jetzt haben wir zwei Methoden: eine synchrone öffentliche Methode und eine asynchrone private Methode, an die die öffentlichen Delegierten delegieren. Die Dictionary-Sammlung speichert jetzt die erstellten Task-Objekte und nicht deren Inhalt zwischen, sodass zukünftige Versuche, den Inhalt einer zuvor erfolgreich erhaltenen Seite abzurufen, durch einfachen Zugriff auf die Sammlung ausgeführt werden können, um das vorhandene Task-Objekt zurückzugeben. Im Inneren können Sie die ContinueWith-Methoden des Task-Objekts verwenden, mit denen wir das ausgeführte Objekt in der Sammlung speichern können - falls das Laden der Seite erfolgreich war. Natürlich ist dieser Code komplexer und erfordert wie üblich viel Entwicklung und Unterstützung, wenn Sie die Leistung optimieren: Sie möchten keine Zeit damit verbringen, ihn zu schreiben, bis Leistungstests zeigen, dass diese Komplikationen zu seiner Verbesserung führen, was beeindruckend und offensichtlich ist. Welche Verbesserungen tatsächlich von der Art der Anwendung abhängen. Sie können eine Testsuite erstellen, die häufige Anwendungsfälle simuliert, und die Ergebnisse auswerten, um festzustellen, ob das Spiel die Kerze wert ist.


Abbildung 3 Manuelles Zwischenspeichern von Aufgaben


 private static ConcurrentDictionary<string,Task<string>> s_urlToContents; public static Task<string> GetContentsAsync(string url) { Task<string> contents; if (!s_urlToContents.TryGetValue(url, out contents)) { contents = GetContentsInternalAsync(url); contents.ContinueWith(delegate { s_urlToContents.TryAdd(url, contents); }, CancellationToken.None, TaskContinuationOptions.OnlyOnRanToCompletion | TaskContinuatOptions.ExecuteSynchronously, TaskScheduler.Default); } return contents; } private static async Task<string> GetContentsInternalAsync(string url) { var response = await new HttpClient().GetAsync(url); return response.EnsureSuccessStatusCode().Content.ReadAsString(); } 

Eine andere Optimierungsmethode, die Task-Objekten zugeordnet ist, besteht darin, zu bestimmen, ob ein solches Objekt überhaupt von der asynchronen Methode zurückgegeben werden soll. Sowohl C # als auch Visual Basic unterstützen asynchrone Methoden, die einen Nullwert (void) zurückgeben, und sie erstellen überhaupt keine Task-Objekte. Asynchrone Methoden in Bibliotheken sollten immer Task und Task zurückgeben, da Sie beim Entwerfen einer Bibliothek nicht wissen können, dass sie nicht zum Warten auf den Abschluss verwendet werden. Bei der Entwicklung von Anwendungen können jedoch Methoden, die void zurückgeben, ihren Platz finden. Der Hauptgrund für das Vorhandensein solcher Methoden besteht darin, vorhandene ereignisgesteuerte Umgebungen wie ASP.NET und Windows Presentation Foundation (WPF) bereitzustellen. Mit async und await erleichtern diese Methoden die Implementierung von Schaltflächenhandlern, Seitenladeereignissen usw. Wenn Sie eine asynchrone Methode mit void verwenden möchten, gehen Sie vorsichtig mit Ausnahmen um: Ausnahmen werden in jedem SynchronizationContext angezeigt, der zum Zeitpunkt des Aufrufs der Methode aktiv war.

Vergiss den Kontext nicht


Es gibt viele verschiedene Kontexte in .NET Framework: LogicalCallContext, SynchronizationContext, HostExecutionContext, SecurityContext, ExecutionContext und andere (ihre gigantische Menge könnte darauf hindeuten, dass die Ersteller des Frameworks finanziell motiviert waren, neue Kontexte zu erstellen, aber ich weiß sicher, dass dies nicht der Fall ist). Einige dieser Kontexte wirken sich stark auf asynchrone Methoden aus, nicht nur in Bezug auf die Funktionalität, sondern auch in Bezug auf die Leistung.


SynchronizationContext SynchronizationContext spielt für asynchrone Methoden eine wichtige Rolle. Ein „Synchronisationskontext“ ist nur eine Abstraktion, um sicherzustellen, dass ein Delegatenaufruf mit den Besonderheiten einer bestimmten Bibliothek oder Umgebung gemarshallt wird. Beispielsweise verfügt WPF über einen DispatcherSynchronizationContext, der einen Benutzeroberflächenthread für Dispatcher darstellt: Durch das Senden eines Delegaten an diesen Synchronisationskontext wird dieser Delegat zur Ausführung durch den Dispatcher in seinem Thread in die Warteschlange gestellt. ASP.NET bietet einen AspNetSynchronizationContext, mit dem sichergestellt wird, dass asynchrone Vorgänge, die an der Verarbeitung einer ASP.NET-Anforderung beteiligt sind, garantiert nacheinander ausgeführt werden und an den korrekten HttpContext-Status gebunden sind. Nun, etc. Im Allgemeinen gibt es in .NET Framework etwa 10 Spezialisierungen des SynchronizationContext, einige offen, andere intern.


Wenn Sie auf Aufgaben oder Objekte anderer Typen warten, für die .NET Framework dies implementieren kann, erfassen Objekte, die auf sie warten (z. B. TaskAwaiter), den aktuellen SynchronizationContext zu dem Zeitpunkt, zu dem das Warten (Warten) beginnt. Wenn nach Abschluss des Wartens der SynchronizationContext erfasst wurde, wird die Fortsetzung der asynchronen Methode an diesen Synchronisationskontext gesendet. Aus diesem Grund müssen Programmierer, die asynchrone Methoden schreiben, die vom UI-Stream aufgerufen werden, Aufrufe zum UI-Stream nicht manuell zurückführen, um die UI-Steuerelemente zu aktualisieren: Das Framework führt dieses Marshalling automatisch durch.


Leider hat dieses Marshalling seinen Preis. Für Anwendungsentwickler, die wait verwenden, um ihren Kontrollfluss zu implementieren, ist das automatische Marshalling die richtige Lösung. Bibliotheken haben oft eine ganz andere Geschichte. Für Anwendungsentwickler ist dieses Marshalling hauptsächlich erforderlich, damit der Code den Kontext steuert, in dem er ausgeführt wird, z. B. um auf UI-Steuerelemente zuzugreifen oder um auf den HttpContext zuzugreifen, der der erforderlichen ASP.NET-Anforderung entspricht. Bibliotheken sind jedoch im Allgemeinen nicht erforderlich, um eine solche Anforderung zu erfüllen. Infolgedessen verursacht das automatische Marshalling häufig völlig unnötige zusätzliche Kosten. Schauen wir uns noch einmal den Code an, der Daten von einem Stream in einen anderen kopiert:


 byte [] buffer = new byte[0x1000]; int numRead; while((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0) { await source.WriteAsync(buffer, 0, numRead); } 

Wenn diese Kopie vom UI-Stream aufgerufen wird, erzwingt jede Lese- und Schreiboperation, dass die Ausführung zum UI-Stream zurückkehrt. Im Fall eines Megabytes an Daten in der Quelle und in Streams, die asynchron lesen und schreiben (dh die meisten ihrer Implementierungen), bedeutet dies, dass etwa 500 vom Hintergrund-Stream zum UI-Stream gewechselt werden. Um dieses Verhalten in den Task- und Task-Typen zu behandeln, wird die ConfigureAwait-Methode erstellt. Diese Methode akzeptiert den Parameter continueOnCapturedContext eines booleschen Typs, der das Marshalling steuert. Wenn true (Standardeinstellung), gibt wait automatisch die Kontrolle über den erfassten SynchronizationContext zurück. Wenn false verwendet wird, wird der Synchronisationskontext ignoriert und die Umgebung führt die asynchrone Operation in dem Thread weiter aus, in dem sie unterbrochen wurde. Durch die Implementierung dieser Logik erhalten Sie eine effizientere Version des Kopiercodes zwischen den Threads:

 byte [] buffer = new byte[0x1000]; int numRead; while((numRead = await source.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0) { await source.WriteAsync(buffer, 0, numRead).ConfigureAwait(false); } 

Für Bibliotheksentwickler reicht eine solche Beschleunigung aus, um immer über die Verwendung von ConfigureAwait nachzudenken, mit Ausnahme seltener Bedingungen, unter denen die Bibliothek genug über die Laufzeit weiß und die Methode mit Zugriff auf den richtigen Kontext ausführen muss.


Neben der Leistung gibt es noch einen weiteren Grund, warum Sie ConfigureAwait bei der Entwicklung von Bibliotheken verwenden müssen. Stellen Sie sich vor, dass die CopyStreamToStreamAsync-Methode, die mit der Version des Codes ohne ConfigureAwait implementiert wurde, beispielsweise aus dem UI-Stream in WPF aufgerufen wird:


 private void button1_Click(object sender, EventArgs args) { Stream src = …, dst = …; Task t = CopyStreamToStreamAsync(src, dst); t.Wait(); // deadlock! } 

In diesem Fall musste der Programmierer button1_Click als asynchrone Methode schreiben, bei der der Warteoperator die Task ausführen soll, und nicht die synchrone Wait-Methode dieses Objekts verwenden. Die Wait-Methode muss in vielen anderen Fällen verwendet werden, aber es ist fast immer ein Fehler, sie zum Warten in einem UI-Stream zu verwenden, wie hier gezeigt. Die Wait-Methode wird erst zurückgegeben, wenn die Aufgabe abgeschlossen ist. Im Fall von CopyStreamToStreamAsync versucht sein asynchroner Stream, die Ausführung mit dem Senden von Daten an den erfassten SynchronizationContext zurückzugeben, und kann erst abgeschlossen werden, wenn solche Übertragungen abgeschlossen sind (da sie erforderlich sind, um den Betrieb fortzusetzen). Diese Versendungen können jedoch nicht ausgeführt werden, da der UI-Thread, der sie verarbeiten muss, durch den Wait-Aufruf blockiert wird. Dies ist eine zyklische Abhängigkeit, die zu einem Deadlock führt. Wenn CopyStreamToStreamAsync mit ConfigureAwait (false) implementiert ist, gibt es keine Abhängigkeit oder Sperre.


ExecutionContext ExecutionContext ist ein wichtiger Bestandteil von .NET Framework, aber die meisten Programmierer sind sich seiner Existenz glücklicherweise nicht bewusst. ExecutionContext – , SecurityContext LogicalCallContext, , . , ThreadPool.QueueUserWorkItem, Task.Run, Delegate.BeginInvoke, Stream.BeginRead, WebClient.DownloadStringAsync Framework, ExecutionContext ExecutionContext.Run ( ). , , ThreadPool.QueueUserWorkItem, Windows (identity), WaitCallback. , Task.Run LogicalCallContext, LogicalCallContext Action. ExecutionContext .


Framework , ExecutionContext, , . Windows LogicalCallContext . (WindowsIdentity.Impersonate CallContext.LogicalSetData) .



. C# Visual Basic , . await. , , - . C# Visual Basic («») , await (boxed) , .


. , . , , , .


C# Visual Basic , . ,


 public static async Task FooAsync() { var dto = DateTimeOffset.Now; var dt = dto.DateTime; await Task.Yield(); Console.WriteLine(dt); } 

dto await, . , , - dto:


Figure 4


 [StructLayout(LayoutKind.Sequential), CompilerGenerated] private struct <FooAsync>d__0 : <>t__IStateMachine { private int <>1__state; public AsyncTaskMethodBuilder <>t__builder; public Action <>t__MoveNextDelegate; public DateTimeOffset <dto>5__1; public DateTime <dt>5__2; private object <>t__stack; private object <>t__awaiter; public void MoveNext(); [DebuggerHidden] public void <>t__SetMoveNextDelegate(Action param0); } 

, . , , , , . , :


 public static async Task FooAsync() { var dt = DateTimeOffset.Now.DateTime; await Task.Yield(); Console.WriteLine(dt); } 

, .NET (GC) , , , : 0, , , (.NET GC 0, 1 2). , GC . , , , , , , . 0, , , . , , , .


( , ). JIT , , , , . , , . , , , , . , , . , C# Visual Basic , , .



C# Visual Basic , awaits: . await , Task , , . , , :

 public static async Task<int> SumAsync(Task<int> a, Task<int> b, Task<int> c) { return Sum(await a, await b, await c); } private static int Sum(int a, int b, int c) { return a + b + c; } 

C# “await b” Sum. await, Sum, - async , «» await. , await . , , CLR, , , . , <>t__stack. , , Tuple<int, int> <>__stack. , , , . , SumAsync :


 public static async Task<int> SumAsync(Task<int> a, Task<int> b, Task<int> c) { int ra = await a; int rb = await b; int rc = await c; return Sum(ra, rb, rc); } 

, ra, rb rc, . , : . , , , . , , , , .


, , . Sum , await , . , await , . await , Task.WhenAll:


 public static async Task<int> SumAsync(Task<int> a, Task<int> b, Task<int> c) { int [] results = await Task.WhenAll(a, b, c); return Sum(results[0], results[1], results[2]); } 

Task.WhenAll Task<TResult[]>, , , , . . , WhenAll, Task Task. , , , , , WhenAll , . WhenAll, , , params, . , , . Figure 5

Figure 5


 public static Task<int> SumAsync(Task<int> a, Task<int> b, Task<int> c) { return (a.Status == TaskStatus.RanToCompletion && b.Status == TaskStatus.RanToCompletion && c.Status == TaskStatus.RanToCompletion) ? Task.FromResult(Sum(a.Result, b.Result, c.Result)) : SumAsyncInternal(a, b, c); } private static async Task<int> SumAsyncInternal(Task<int> a, Task<int> b, Task<int> c) { await Task.WhenAll((Task)a, b, c).ConfigureAwait(false); return Sum(a.Result, b.Result, c.Result); } 


, . , . , . , , : , , / , . .NET Framework , . , .NET Framework, . , , Framework, , , .

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


All Articles