
Verallgemeinerte asynchrone Rückgabetypen - Dies ist eine neue C # 7-Funktion, mit der nicht nur Task als Rückgabetyp für asynchrone Methoden verwendet werden kann, sondern auch andere Typen (Klassen oder Strukturen), die bestimmte Anforderungen erfüllen.
Gleichzeitig ist async / await eine Möglichkeit, eine Reihe von "Fortsetzungs" -Funktionen in einem Kontext aufzurufen, der eine Essenz eines anderen Entwurfsmusters darstellt - Monad . Können wir also async / await verwenden , um einen Code zu schreiben, der sich genauso verhält, als ob wir Monaden verwenden würden? Es stellt sich heraus, dass - ja (mit einigen Vorbehalten). Der folgende Code ist beispielsweise kompilierbar und funktioniert:
async Task Main() { foreach (var s in new[] { "1,2", "3,7,1", null, "1" }) { var res = await Sum(s).GetMaybeResult(); Console.WriteLine(res.IsNothing ? "Nothing" : res.GetValue().ToString()); }
Weiter werde ich erklären, wie der Code funktioniert ...
Verallgemeinerte asynchrone Rückgabetypen
Lassen Sie uns zunächst herausfinden, was erforderlich ist, um unseren eigenen Typ (z. B. MyAwaitable <T> ) als Ergebnistyp einer asynchronen Funktion zu verwenden. Die Dokumentation besagt, dass ein solcher Typ Folgendes haben muss:
GetAwaiter () -Methode, die ein Objekt eines Typs zurückgibt, der die INotifyCompletion- Schnittstelle implementiert und über die bool IsCompleted- Eigenschaft und die T GetResult () -Methode verfügt;
Attribut [AsyncMethodBuilder (Type)] , das auf eine "method builder" -Klasse ( oder -Struktur ) verweist, z. B. MyAwaitableTaskMethodBuilder <T> mit den folgenden Methoden:
- statisch Create ()
- Start (stateMachine)
- SetResult (Ergebnis)
- SetException (Ausnahme)
- SetStateMachine (stateMachine)
- AwaitOnCompleted (Kellner, stateMachine)
- AwaitUnsafeOnCompleted (Kellner, stateMachine)
- Aufgabe
Hier ist eine einfache Implementierung von MyAwaitable und MyAwaitableTaskMethodBuilder [AsyncMethodBuilder(typeof(MyAwaitableTaskMethodBuilder<>))] public class MyAwaitable<T> : INotifyCompletion { private Action _continuation; public MyAwaitable() { } public MyAwaitable(T value) { this.Value = value; this.IsCompleted = true; } public MyAwaitable<T> GetAwaiter() => this; public bool IsCompleted { get; private set; } public T Value { get; private set; } public Exception Exception { get; private set; } public T GetResult() { if (!this.IsCompleted) throw new Exception("Not completed"); if (this.Exception != null) { ExceptionDispatchInfo.Throw(this.Exception); } return this.Value; } internal void SetResult(T value) { if (this.IsCompleted) throw new Exception("Already completed"); this.Value = value; this.IsCompleted = true; this._continuation?.Invoke(); } internal void SetException(Exception exception) { this.IsCompleted = true; this.Exception = exception; } void INotifyCompletion.OnCompleted(Action continuation) { this._continuation = continuation; if (this.IsCompleted) { continuation(); } } } public class MyAwaitableTaskMethodBuilder<T> { public MyAwaitableTaskMethodBuilder() => this.Task = new MyAwaitable<T>(); public static MyAwaitableTaskMethodBuilder<T> Create() => new MyAwaitableTaskMethodBuilder<T>(); public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine => stateMachine.MoveNext(); public void SetStateMachine(IAsyncStateMachine stateMachine) { } public void SetException(Exception exception) => this.Task.SetException(exception); public void SetResult(T result) => this.Task.SetResult(result); public void AwaitOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine => this.GenericAwaitOnCompleted(ref awaiter, ref stateMachine); public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine => this.GenericAwaitOnCompleted(ref awaiter, ref stateMachine); public void GenericAwaitOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine => awaiter.OnCompleted(stateMachine.MoveNext); public MyAwaitable<T> Task { get; } }
Jetzt können wir MyAwaitable als Ergebnis für asynchrone Methoden verwenden:
private async MyAwaitable<int> MyAwaitableMethod() { int result = 0; int arg1 = await this.GetMyAwaitable(1); result += arg1; int arg2 = await this.GetMyAwaitable(2); result += arg2; int arg3 = await this.GetMyAwaitable(3); result += arg3; return result; } private async MyAwaitable<int> GetMyAwaitable(int arg) { await Task.Delay(1);
Der Code funktioniert wie erwartet, aber um den Zweck der Anforderungen an MyAwaitable zu verstehen, werfen wir einen Blick darauf, was der C # -Vorprozessor mit MyAwaitableMethod macht . Wenn Sie ein Dekompilierungsprogramm (z. B. dotPeek) ausführen, werden Sie feststellen, dass die ursprüngliche Methode wie folgt geändert wurde:
private MyAwaitable<int> MyAwaitableMethod() { var stateMachine = new MyAwaitableMethodStateMachine(); stateMachine.Owner = this; stateMachine.Builder = MyAwaitableTaskMethodBuilder<int>.Create(); stateMachine.State = 0; stateMachine.Builder.Start(ref stateMachine); return stateMachine.Builder.Task; }
MyAwaitableMethodStateMachineEigentlich ist es ein vereinfachter Code, bei dem ich viele Optimierungen weglasse, um einen vom Compiler generierten Code lesbar zu machen
sealed class MyAwaitableMethodStateMachine : IAsyncStateMachine { public int State; public MyAwaitableTaskMethodBuilder<int> Builder; public BuilderDemo Owner; private int _result; private int _arg1; private int _arg2; private int _arg3; private MyAwaitableAwaiter<int> _awaiter1; private MyAwaitableAwaiter<int> _awaiter2; private MyAwaitableAwaiter<int> _awaiter3; private void SetAwaitCompletion(INotifyCompletion awaiter) { var stateMachine = this; this.Builder.AwaitOnCompleted(ref awaiter, ref stateMachine); } void IAsyncStateMachine.MoveNext() { int finalResult; try { label_begin: switch (this.State) { case 0: this._result = 0; this._awaiter1 = this.Owner.GetMyAwaitable(1).GetAwaiter(); this.State = 1; if (!this._awaiter1.IsCompleted) { this.SetAwaitCompletion(this._awaiter1); return; } goto label_begin; case 1:
Wenn wir den generierten Code überprüfen, können wir feststellen, dass der "Method Builder" die folgenden Verantwortlichkeiten hat:
- Planen des Methodenaufrufs der Zustandsmaschine MoveNext () , wenn eine untergeordnete asynchrone Operation ausgeführt wird (im einfachsten Szenario übergeben wir MoveNext () einfach an OnCompleted () des Wartenden der asynchronen Operation).
- Erstellen eines asynchronen Operationskontextobjekts (
public MyAwaitable<T> Task { get; }
) - Reagieren auf Endzustände generierter Zustandsautomaten: SetResult oder SetException .
Mit anderen Worten, mit "Methodenerstellern" können wir steuern, wie asynchrone Methoden ausgeführt werden, und es sieht nach einer Funktion aus, die uns hilft, unser Ziel zu erreichen - eine Implementierung des Verhaltens von Vielleicht Monaden. Aber was ist gut an dieser Monade? Nun ... Sie können viele Artikel über diese Monade im Internet finden, daher werde ich hier nur die Grundlagen beschreiben.
Vielleicht Monade
Kurz gesagt, Vielleicht ist Monade ein Entwurfsmuster, das die Unterbrechung einer Funktionsaufrufkette ermöglicht, wenn eine Funktion aus der Kette kein wertvolles Ergebnis liefern kann (z. B. Analysefehler).
In der Vergangenheit haben zwingende Programmiersprachen das Problem auf zwei Arten gelöst:
- Viel bedingte Logik
- Ausnahmen
Die beiden Wege haben offensichtliche Nachteile, so dass ein dritter Weg erfunden wurde:
- Erstellen Sie einen Typ, der in zwei Zuständen vorliegen kann: "Some Value" und "Nothing" - nennen wir ihn "Maybe".
- Erstellen Sie eine Funktion (nennen wir sie "SelectMany"), die zwei Argumente abruft:
2.1. Ein Objekt vom Typ "Vielleicht"
2.2. Eine nächste Funktion aus dem Aufrufsatz - die Funktion sollte auch ein Objekt von "Vielleicht" zurückgeben, das ein Ergebnis oder "Nichts" enthält, wenn das Ergebnis nicht ausgewertet werden kann (z. B. sind die Funktionsparameter nicht im richtigen Format). - Die Funktion "SelectMany" prüft, ob "Vielleicht" einen Wert hat, ruft dann die nächste Funktion mit dem Wert (extrahiert aus "Vielleicht") als Argument auf und gibt dann das Ergebnis zurück, andernfalls wird ein "Vielleicht" -Objekt im Zustand "Nichts" zurückgegeben .

In C # kann es folgendermaßen implementiert werden:
public struct Maybe<T> { public static implicit operator Maybe<T>(T value) => Value(value); public static Maybe<T> Value(T value) => new Maybe<T>(false, value); public static readonly Maybe<T> Nothing = new Maybe<T>(true, default); private Maybe(bool isNothing, T value) { this.IsNothing = isNothing; this._value = value; } public readonly bool IsNothing; private readonly T _value; public T GetValue() => this.IsNothing ? throw new Exception("Nothing") : this._value; } public static class MaybeExtensions { public static Maybe<TRes> SelectMany<TIn, TRes>( this Maybe<TIn> source, Func<TIn, Maybe<TRes>> func) => source.IsNothing ? Maybe<TRes>.Nothing : func(source.GetValue()); }
und Verwendung:
static void Main() { for (int i = 0; i < 10; i++) { var res = Function1(i).SelectMany(Function2).SelectMany(Function3); Console.WriteLine(res.IsNothing ? "Nothing" : res.GetValue().ToString()); } Maybe<int> Function1(int acc) => acc < 10 ? acc + 1 : Maybe<int>.Nothing; Maybe<int> Function2(int acc) => acc < 10 ? acc + 2 : Maybe<int>.Nothing; Maybe<int> Function3(int acc) => acc < 10 ? acc + 3 : Maybe<int>.Nothing; }
Warum 'SelectMany'?Ich denke, einige von Ihnen könnten eine Frage stellen: "Warum hat der Autor die Funktion" SelectMany "aufgerufen? Es ist ein so seltsamer Name". Es ist, aber es gibt einen Grund dafür. In C # wird SelectMany vom Präprozessor verwendet, um die Abfrage-Notation zu unterstützen, was die Arbeit mit Anrufketten vereinfacht ( weitere Details finden Sie in meinem vorherigen Artikel ).
Tatsächlich können wir die Anrufkette wie folgt ändern:
var res = Function1(i) .SelectMany(x2 => Function2(x2).SelectMany(x3 => Function3(x3.SelectMany<int, int>(x4 => x2 + x3 + x4)));
Damit wir auf alle Zwischenergebnisse zugreifen können, was praktisch ist, aber der Code schwer zu lesen ist.
Hier hilft uns die Abfrage-Notation :
var res = from x2 in Function1(i) from x3 in Function2(x2) from x4 in Function3(x3) select x2 + x3 + x4;
Um den Code kompilierbar zu machen, benötigen wir eine erweiterte Version von "Select Many".
public static Maybe<TJ> SelectMany<TIn, TRes, TJ>( this Maybe<TIn> source, Func<TIn, Maybe<TRes>> func, Func<TIn, TRes, TJ> joinFunc) { if (source.IsNothing) return Maybe<TJ>.Nothing; var res = func(source.GetValue()); return res.IsNothing ? Maybe<TJ>.Nothing : joinFunc(source.GetValue(), res.GetValue()); }
Lassen Sie uns das Programm aus dem Artikelkopf mit dieser 'klassischen' Vielleicht'-Implementierung implementieren static void Main() { foreach (var s in new[] {"1,2", "3,7,1", null, "1"}) { var res = Sum(s); Console.WriteLine(res.IsNothing ? "Nothing" : res.GetValue().ToString()); } Console.ReadKey(); } static Maybe<int> Sum(string input) => Split(input).SelectMany(items => Acc(0, 0, items));
Der Code sieht nicht gut aus, da C # nicht als funktionale Sprache konzipiert wurde, aber in „echten“ funktionalen Sprachen wie Haskell ist ein solcher Ansatz sehr verbreitet
Async vielleicht
Die Essenz von Vielleicht Monade besteht darin, eine Funktionsaufrufkette zu steuern, aber genau das tut "async / await". Versuchen wir also, sie miteinander zu kombinieren. Zuerst müssen wir den Typ " Vielleicht " mit asynchronen Funktionen kompatibel machen, und wir wissen bereits, wie das geht:
[AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))] public class Maybe<T> : INotifyCompletion { ... public Maybe<T> GetAwaiter() => this; public bool IsCompleted { get; private set; } public void OnCompleted(Action continuation){...} public T GetResult() =>... }
Lassen Sie uns nun einen Blick darauf werfen, wie das "klassische Vielleicht" als Zustandsmaschine umgeschrieben werden kann, um Ähnlichkeiten zu finden:
static void Main() { for (int i = 0; i < 10; i++) { var stateMachine = new StateMachine(); stateMachine.state = 0; stateMachine.i = i; stateMachine.MoveNext(); var res = stateMachine.Result; Console.WriteLine(res.IsNothing ? "Nothing" : res.GetValue().ToString()); } Console.ReadKey(); } class StateMachine { public int state = 0; public int i; public Maybe<int> Result; private Maybe<int> _f1; private Maybe<int> _f2; private Maybe<int> _f3; public void MoveNext() { label_begin: switch (this.state) { case 0: this._f1 = Function1(this.i); this.state = Match ? -1 : 1; goto label_begin; case 1: this._f2 = Function2(this._f1.GetValue()); this.state = this._f2.IsNothing ? -1 : 2; goto label_begin; case 2: this._f3 = Function3(this._f2.GetValue()); this.state = this._f3.IsNothing ? -1 : 3; goto label_begin; case 3: this.Result = this._f3.GetValue(); break; case -1: this.Result = Maybe<int>.Nothing; break; } } }
Wenn wir diese Zustandsmaschine mit der vom C # -Vorprozessor erzeugten übereinstimmen (siehe oben - 'MyAwaitableMethodStateMachine'), können wir feststellen, dass die Statusprüfung möglicherweise in folgenden Funktionen implementiert werden kann:
this.Builder.AwaitOnCompleted(ref awaiter, ref stateMachine);
wo ref awaiter
ein Objekt vom Typ Vielleicht ist . Das einzige Problem hierbei ist, dass wir die Maschine nicht in den "endgültigen" (-1) Zustand versetzen können. Bedeutet das, dass wir den Ausführungsfluss nicht steuern können? Eigentlich nicht. Die Sache ist, dass C # für jede asynchrone Aktion eine Rückrufaktion über die INotifyCompletion- Schnittstelle festlegt. Wenn wir also einen Ausführungsfluss unterbrechen möchten, können wir die Rückrufaktion nur in einem Fall aufrufen, in dem wir den Fluss nicht fortsetzen können.
Eine weitere Herausforderung besteht darin, dass die generierte Zustandsmaschine eine nächste Aktion (als Fortsetzungsrückruf) eines aktuellen Flusses durchläuft. Wir benötigen jedoch einen Fortsetzungsrückruf des anfänglichen Flusses, der es ermöglichen würde, den Rest der asynchronen Operationen zu umgehen:

Wir müssen also eine asynchrone Aktion eines Kindes irgendwie mit seinen Vorfahren verbinden. Wir können dies mit unserem "Method Builder" tun, der eine Verknüpfung zu einer aktuellen asynchronen Operation - Task - hat. Links zu allen AwaitOnCompleted(ref awaiter
asynchronen Vorgängen werden an AwaitOnCompleted(ref awaiter
als Waiter , daher müssen wir nur prüfen, ob der Parameter eine Instanz von Maybe ist und ob dann das aktuelle Maybe als übergeordnetes AwaitOnCompleted(ref awaiter
für das untergeordnete AwaitOnCompleted(ref awaiter
festgelegt wird:
[AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))] public class Maybe<T> : IMaybe, INotifyCompletion { private IMaybe _parent; void IMaybe.SetParent(IMaybe parent) => this._parent = parent; ... } public class MaybeTaskMethodBuilder<T> { ... private void GenericAwaitOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine { if (awaiter is IMaybe maybe) { maybe.SetParent(this.Task); } awaiter.OnCompleted(stateMachine.MoveNext); } ... }
Jetzt können alle Vielleicht- Objekte zu einem Baum zusammengefügt werden, und als Ergebnis erhalten wir von jedem Nachkommenknoten aus Zugriff auf eine Fortsetzung der Wurzel- Vielleicht- Methode ( Exit- Methode):
[AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))] public class Maybe<T> : IMaybe, INotifyCompletion { private Action _continuation; private IMaybe _parent; ... public void OnCompleted(Action continuation) { ... this._continuation = continuation; ... } ... void IMaybe.Exit() { this.IsCompleted = true; if (this._parent != null) { this._parent.Exit(); } else { this._continuation(); } } ... }
Diese Exit- Methode sollte aufgerufen werden, wenn (während des Bewegens über den Baum) ein bereits aufgelöstes Vielleicht- Objekt im Status " Nichts" gefunden wurde . Solche Vielleicht können Objekte mit einer Methode wie dieser zurückgegeben werden:
Maybe<int> Parse(string str) => int.TryParse(str, out var result) ? result : Maybe<int>.Nothing();
So speichern Sie einen aufgelösten Status Vielleicht führen wir eine neue separate Struktur ein:
public struct MaybeResult { ... private readonly T _value; public readonly bool IsNothing; public T GetValue() => this.IsNothing ? throw new Exception("Nothing") : this._value; } [AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))] public class Maybe<T> : IMaybe, INotifyCompletion { private MaybeResult? _result; ... internal Maybe() { }
Wenn eine asynchrone Zustandsmaschine (über den Methoden- Generator ) die OnCompleted- Methode einer bereits aufgelösten Vielleicht- Instanz aufruft und sich im Status " Nichts" befindet, können wir einen gesamten Ablauf unterbrechen :
public void OnCompleted(Action continuation) { this._continuation = continuation; if(this._result.HasValue) { this.NotifyResult(this._result.Value.IsNothing); } } internal void SetResult(T result)
Jetzt bleibt nur noch - wie man ein Ergebnis eines asynchronen Vielleicht außerhalb seines Gültigkeitsbereichs erhält (jede asynchrone Methode, deren Rückgabetyp nicht Vielleicht ist ). Wenn Sie versuchen, das Schlüsselwort just await mit einer Maybe- Instanz zu verwenden, wird aufgrund dieses Codes eine Ausnahme ausgelöst:
[AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))] public class Maybe<T> : IMaybe, INotifyCompletion { private MaybeResult? _result; public T GetResult() => this._result.Value.GetValue(); } ... public struct MaybeResult { ... public T GetValue() => this.IsNothing ? throw new Exception("Nothing") : this._value; }
Um das Problem zu lösen, können wir einfach einen neuen Kellner hinzufügen, der eine gesamte MaybeResult- Struktur zurückgibt , und wir können einen Code wie diesen schreiben:
var res = await GetResult().GetMaybeResult(); if(res.IsNothing){ ... } else{ res.GetValue(); ... };
Das ist alles für jetzt. In den Codebeispielen lasse ich einige Details weg, um mich nur auf die wichtigsten Teile zu konzentrieren. Eine Vollversion finden Sie auf github .
Ich würde jedoch nicht empfehlen, diese Version in einem Produktionscode zu verwenden, da es ein erhebliches Problem gibt - wenn wir einen Ausführungsfluss durch Aufrufen einer Fortsetzung des Stamms bremsen Vielleicht umgehen wir ALLES! Einschließlich aller finally- Blöcke (dies ist eine Antwort auf die Frage "Werden finally- Blöcke immer aufgerufen?"), sodass alle verwendeten Operatoren nicht wie erwartet funktionieren und dies zu einem Verlust von Ressourcen führen kann. Das Problem kann behoben werden, wenn anstelle des Aufrufs des anfänglichen Rückrufs eine spezielle Ausnahme ausgelöst wird, die intern behandelt wird ( hier finden Sie die Version ), diese Lösung jedoch anscheinend Leistungsnachahmungen aufweist (was in einigen Szenarien akzeptabel sein kann). Mit der aktuellen Version des C # -Compilers sehe ich keine andere Lösung, aber das könnte sich in Zukunft ändern.
Diese Einschränkungen bedeuten nicht, dass alle in diesem Artikel beschriebenen Tricks völlig nutzlos sind. Sie können verwendet werden, um andere Monaden zu implementieren, für die keine Änderungen in den Ausführungsabläufen erforderlich sind, z. B. "Reader". Wie man diese "Reader" -Monade durch Async / Warten implementiert, werde ich im nächsten Artikel zeigen .