Hallo Habr! Wir sprechen weiterhin über asynchrone Programmierung in C #. Heute sprechen wir über einen einzelnen Anwendungsfall oder ein benutzerspezifisches Szenario, das für alle Aufgaben im Rahmen der asynchronen Programmierung geeignet ist. Wir werden auf die Themen Synchronisation, Deadlocks, Bedienereinstellungen, Ausnahmebehandlung und vieles mehr eingehen. Jetzt mitmachen!

Vorherige verwandte Artikel
Nahezu jedes nicht standardmäßige Verhalten asynchroner Methoden in C # kann anhand eines Benutzerszenarios erklärt werden: Die Konvertierung eines vorhandenen synchronen Codes in asynchronen Code sollte so einfach wie möglich sein. Sie müssen in der Lage sein, das asynchrone Schlüsselwort vor dem Rückgabetyp der Methode hinzuzufügen, das Async-Suffix zum Namen dieser Methode hinzuzufügen und das Schlüsselwort await hier und im Textbereich der Methode hinzuzufügen, um eine voll funktionsfähige asynchrone Methode zu erhalten.

Ein „einfaches“ Szenario verändert viele Aspekte des Verhaltens asynchroner Methoden dramatisch: von der Planung der Dauer einer Aufgabe bis zur Behandlung von Ausnahmen. Das Skript sieht überzeugend und aussagekräftig aus, aber in seinem Kontext wird die Einfachheit asynchroner Methoden sehr irreführend.
Kontext synchronisieren
Die Entwicklung der Benutzeroberfläche ist ein Bereich, in dem das obige Szenario besonders wichtig ist. Aufgrund langwieriger Operationen im Benutzeroberflächenthread erhöht sich die Antwortzeit von Anwendungen. In diesem Fall wurde die asynchrone Programmierung immer als sehr effektives Werkzeug angesehen.
private async void buttonOk_ClickAsync(object sender, EventArgs args) { textBox.Text = "Running..";
Der Code sieht sehr einfach aus, aber es gibt ein Problem. Für die meisten Benutzeroberflächen gelten Einschränkungen: Benutzeroberflächenelemente können nur durch spezielle Threads geändert werden. Das heißt, in Zeile 3 tritt ein Fehler auf, wenn die Dauer der Aufgabe im Thread aus dem Thread-Pool geplant wird. Glücklicherweise ist dieses Problem seit langem bekannt, und das Konzept
eines Synchronisationskontexts ist in der Version von .NET Framework 2.0 enthalten.
Jede Benutzeroberfläche bietet spezielle Dienstprogramme zum Marshalling von Aufgaben in einem oder mehreren speziellen Benutzeroberflächenthreads. Windows Forms verwendet die
Control.Invoke
Methode, WPF
Control.Invoke
Dispatcher.Invoke-Methode, andere Systeme können auf andere Methoden zugreifen. Die in all diesen Fällen verwendeten Schemata sind weitgehend ähnlich, unterscheiden sich jedoch im Detail. Der Synchronisationskontext ermöglicht es Ihnen, Unterschiede zu abstrahieren, indem Sie eine API bereitstellen, um den Code in einem „speziellen“ Kontext auszuführen, der die Verarbeitung kleinerer Details durch abgeleitete Typen wie
WindowsFormsSynchronizationContext
,
DispatcherSynchronizationContext
usw. ermöglicht.
Um das Problem der Thread-Affinität zu lösen, haben C # -Programmierer beschlossen, in der Anfangsphase der Implementierung asynchroner Methoden in den aktuellen Synchronisationskontext einzutreten und alle nachfolgenden Operationen in diesem Kontext zu planen. Jetzt wird jeder der Blöcke zwischen den Anweisungen await im Thread der Benutzeroberfläche ausgeführt, wodurch das Hauptskript implementiert werden kann. Diese Lösung führte jedoch zu einer Reihe neuer Probleme.
Deadlocks
Schauen wir uns einen kleinen, relativ einfachen Code an. Gibt es hier irgendwelche Probleme?
Dieser Code verursacht einen
Deadlock . Der Benutzeroberflächenthread startet eine asynchrone Operation und wartet synchron auf das Ergebnis. Die asynchrone Methode kann jedoch nicht abgeschlossen werden, da die zweite Zeile von
GetStockPricesForAsync
im Benutzeroberflächenthread ausgeführt werden muss, der den Deadlock verursacht.
Sie werden einwenden, dass dieses Problem recht einfach zu lösen ist. Ja in der Tat. Sie müssen alle Aufrufe der
Task.Result
oder
Task.Wait
aus dem Benutzeroberflächencode verbieten. Das Problem kann jedoch weiterhin auftreten, wenn die von diesem Code verwendete Komponente synchron auf das Ergebnis der Benutzeroperation wartet:
Dieser Code verursacht erneut einen Deadlock. Wie man es löst:
- Sie sollten asynchronen Code nicht mit
Task.Wait()
oder Task.Result
und Task.Result
- Verwenden Sie
ConfigureAwait(false)
im Bibliothekscode.
Die Bedeutung der ersten Empfehlung ist klar, und die zweite werden wir unten erläutern.
Konfigurieren von Warteanweisungen
Es gibt zwei Gründe, warum im letzten Beispiel ein Deadlock auftritt:
Task.Wait()
in
GetStockPricesForAsync
und indirekte Verwendung des Synchronisationskontexts in nachfolgenden Schritten in InitializeIfNeededAsync. Obwohl C # -Programmierer nicht empfehlen, Aufrufe an asynchrone Methoden zu blockieren, ist es offensichtlich, dass diese Blockierung in den meisten Fällen weiterhin verwendet wird. C # -Programmierer bieten die folgende Lösung für ein Deadlock-Problem an:
Task.ConfigureAwait(continueOnCapturedContext:false)
.
Trotz des seltsamen Erscheinungsbilds (wenn ein Methodenaufruf ohne ein benanntes Argument ausgeführt wird, bedeutet dies überhaupt nichts), erfüllt diese Lösung ihre Funktion: Sie bietet eine erzwungene Fortsetzung der Ausführung ohne Synchronisationskontext.
public Task<decimal> GetStockPricesForAsync(string symbol) { InitializeIfNeededAsync().Wait(); return Task.FromResult((decimal)42); } private async Task InitializeIfNeededAsync() => await Task.Delay(1).ConfigureAwait(false);
In diesem Fall ist die Fortsetzung der
Task.Delay(1
)
Task.Delay(1
hier ist die leere Anweisung) im Thread aus dem Thread-Pool und nicht im Thread der Benutzeroberfläche geplant, wodurch der Deadlock beseitigt wird.
Deaktivieren des Synchronisationskontexts
Ich weiß, dass
ConfigureAwait
dieses Problem tatsächlich löst, aber es erzeugt viel mehr. Hier ist ein kleines Beispiel:
public Task<decimal> GetStockPricesForAsync(string symbol) { InitializeIfNeededAsync().Wait(); return Task.FromResult((decimal)42); } private async Task InitializeIfNeededAsync() {
Sehen Sie das Problem? Wir haben
ConfigureAwait(false)
, daher sollte alles in Ordnung sein. Aber keine Tatsache.
ConfigureAwait(false)
gibt ein benutzerdefiniertes
ConfiguredTaskAwaitable
Wait-Objekt zurück, und wir wissen, dass es nur verwendet wird, wenn die Aufgabe nicht synchron abgeschlossen wird. Das heißt, wenn
_cache.InitializeAsync()
synchron beendet wird, ist ein Deadlock weiterhin möglich.
Um Deadlocks zu beseitigen, müssen alle Aufgaben, die auf ihren Abschluss warten, mit einem Aufruf der
ConfigureAwait(false)
Methode
ConfigureAwait(false)
„dekoriert“ werden. All dies nervt und erzeugt Fehler.
Alternativ können Sie das benutzerdefinierte Objekt awaiter in allen öffentlichen Methoden verwenden, um den Synchronisationskontext in der asynchronen Methode zu deaktivieren:
private void buttonOk_Click(object sender, EventArgs args) { textBox.Text = "Running.."; var result = _stockPrices.GetStockPricesForAsync("MSFT").Result; textBox.Text = "Result is: " + result; }
Awaiters.DetachCurrentSyncContext
gibt das folgende benutzerdefinierte Warteobjekt zurück:
public struct DetachSynchronizationContextAwaiter : ICriticalNotifyCompletion {
DetachSynchronizationContextAwaiter
führt Folgendes aus: Die asynchrone Methode arbeitet mit einem Synchronisationskontext ungleich Null. Wenn die asynchrone Methode jedoch ohne Synchronisationskontext funktioniert, gibt die
IsCompleted
Eigenschaft true zurück und die Fortsetzung der Methode wird synchron ausgeführt.
Dies bedeutet, dass die Servicedaten nahe Null sind, wenn die asynchrone Methode von einem Thread im Thread-Pool ausgeführt wird, und die Zahlung einmalig für die Übertragung der Ausführung vom Thread der Benutzeroberfläche zum Thread vom Thread-Pool erfolgt.
Weitere Vorteile dieses Ansatzes sind nachstehend aufgeführt.
- Die Fehlerwahrscheinlichkeit wird verringert.
ConfigureAwait(false)
funktioniert nur, wenn es auf alle Aufgaben angewendet wird, die auf ihren Abschluss warten. Es lohnt sich, mindestens eines zu vergessen - und es kann zu einem Deadlock kommen. Denken Sie bei einem benutzerdefinierten Wait-Objekt daran, dass alle Methoden der öffentlichen Bibliothek mit Awaiters.DetachCurrentSyncContext()
beginnen müssen. Fehler sind hier möglich, aber ihre Wahrscheinlichkeit ist viel geringer. - Der resultierende Code ist deklarativer und klarer. Die
ConfigureAwait
Methode mit mehreren Aufrufen scheint mir (aufgrund zusätzlicher Elemente) weniger lesbar und für Anfänger nicht informativ genug zu sein.
Ausnahmebehandlung
Was ist der Unterschied zwischen diesen beiden Optionen:
Task mayFail = Task.FromException (neue ArgumentNullException ());
Im ersten Fall entspricht alles den Erwartungen - die Fehlerverarbeitung wird durchgeführt, im zweiten Fall jedoch nicht. Die TPL-Bibliothek für parallele Aufgaben ist für die asynchrone und parallele Programmierung ausgelegt, und Aufgabe / Aufgabe kann das Ergebnis mehrerer Operationen darstellen. Aus diesem Grund
Task.Result
und
Task.Wait()
immer eine
AggregateException
, die mehrere Fehler enthalten kann.
Unser Hauptszenario ändert jedoch alles: Der Benutzer sollte in der Lage sein, den Operator async / await hinzuzufügen, ohne die Fehlerbehandlungslogik zu berühren. Das heißt, die await-Anweisung muss sich von
Task.Result
/
Task.Wait()
: Sie muss den Wrapper von einer Ausnahme in der
AggregateException
Instanz
Task.Wait()
. Heute werden wir die erste Ausnahme auswählen.
Alles ist in Ordnung, wenn alle auf Task basierenden Methoden asynchron sind und parallele Berechnungen nicht zur Ausführung von Tasks verwendet werden. Aber in einigen Fällen ist alles anders:
try { Task<int> task1 = Task.FromException<int>(new ArgumentNullException()); Task<int> task2 = Task.FromException<int>(new InvalidOperationException());
Task.WhenAll
gibt eine Aufgabe mit zwei Fehlern zurück. Die Anweisung await ruft jedoch nur die erste ab und füllt sie aus.
Es gibt zwei Möglichkeiten, um dieses Problem zu lösen:
- Anzeigen von Aufgaben manuell, wenn sie Zugriff haben, oder
- Konfigurieren Sie die TPL-Bibliothek so, dass die Ausnahme in eine andere
AggregateException
.
try { Task<int> task1 = Task.FromException<int>(new ArgumentNullException()); Task<int> task2 = Task.FromException<int>(new InvalidOperationException());
Async void Methode
Die aufgabenbasierte Methode gibt ein Token zurück, mit dem zukünftige Ergebnisse verarbeitet werden können. Wenn die Aufgabe verloren geht, kann das Token nicht mehr per Benutzercode gelesen werden. Eine asynchrone Operation, die die void-Methode zurückgibt, löst einen Fehler aus, der im Benutzercode nicht behandelt werden kann. In diesem Sinne sind Token nutzlos und sogar gefährlich - jetzt werden wir es sehen. In unserem Hauptszenario wird jedoch von der obligatorischen Verwendung ausgegangen:
private async void buttonOk_ClickAsync(object sender, EventArgs args) { textBox.Text = "Running.."; var result = await _stockPrices.GetStockPricesForAsync("MSFT"); textBox.Text = "Result is: " + result; }
Was aber, wenn
GetStockPricesForAsync
einen Fehler
GetStockPricesForAsync
? Eine nicht behandelte Ausnahme der asynchronen void-Methode wird in den aktuellen Synchronisationskontext gemarshallt und löst dasselbe Verhalten wie für synchronen Code aus (weitere Informationen finden Sie in der
ThrowAsync-Methode auf der Webseite
AsyncMethodBuilder.cs ). Unter Windows Forms löst eine nicht behandelte Ausnahme im Ereignishandler das
Application.ThreadException
Ereignis aus, für WPF wird das
Application.DispatcherUnhandledException
Ereignis ausgelöst und so weiter.
Was ist, wenn die asynchrone void-Methode den Synchronisationskontext nicht erhält? In diesem Fall führt eine nicht behandelte Ausnahme zu einem schwerwiegenden Absturz der Anwendung. Es wird nicht das Ereignis [
TaskScheduler.UnobservedTaskException
]
TaskScheduler.UnobservedTaskException
, das wiederhergestellt wird, sondern das Ereignis
AppDomain.UnhandledException
, das nicht wiederhergestellt wird, und anschließend die Anwendung geschlossen. Dies geschieht absichtlich und genau das Ergebnis, das wir brauchen.
Schauen wir uns nun einen anderen bekannten Weg an: Verwenden von asynchronen Void-Methoden nur für Ereignishandler auf der Benutzeroberfläche.
Leider ist die Asynch-Void-Methode aus Versehen leicht aufzurufen.
public static Task<T> ActionWithRetry<T>(Func<Task<T>> provider, Action<Exception> onError) {
Auf den ersten Blick ist der Lambda-Ausdruck schwer zu sagen, ob es sich bei der Funktion um eine aufgabenbasierte Methode oder eine asynchrone void-Methode handelt. Daher kann sich trotz gründlicher Prüfung ein Fehler in Ihre Codebasis einschleichen.
Fazit
Viele Aspekte der asynchronen Programmierung in C # wurden durch ein einzelnes Benutzerszenario beeinflusst - einfach das Konvertieren des synchronen Codes einer vorhandenen Benutzeroberflächenanwendung in asynchrones:
- Die anschließende Ausführung asynchroner Methoden ist im resultierenden Synchronisationskontext geplant, was zu Deadlocks führen kann.
- Um dies zu verhindern, müssen Aufrufe von
ConfigureAwait(false)
überall im Code der asynchronen Bibliothek platziert werden. - warte auf die Aufgabe; erzeugt den ersten Fehler, was die Erstellung einer Verarbeitungsausnahme für die parallele Programmierung erschwert.
- Es wurden asynchrone Void-Methoden eingeführt, um Benutzeroberflächenereignisse zu verarbeiten. Sie können jedoch leicht versehentlich ausgeführt werden. Dies führt zum Absturz der Anwendung, wenn eine Ausnahme ausgelöst wird.
Freier Käse kommt nur in einer Mausefalle vor. Benutzerfreundlichkeit kann manchmal zu großen Schwierigkeiten in anderen Bereichen führen. Wenn Sie mit der Geschichte der asynchronen Programmierung in C # vertraut sind, scheint das seltsamste Verhalten nicht mehr so seltsam zu sein, und die Wahrscheinlichkeit von Fehlern im asynchronen Code wird erheblich verringert.