
Types de retour asynchrones généralisés - il s'agit d'une nouvelle fonctionnalité C # 7 qui permet d'utiliser non seulement Task comme type de retour de méthodes asynchrones mais également d'autres types (classes ou structures) qui satisfont certaines exigences spécifiques.
Dans le même temps, async / wait est un moyen d'appeler un ensemble de fonctions de "continuation" à l'intérieur d'un contexte qui est l'essence d'un autre modèle de conception - Monad . Alors, pouvons-nous utiliser async / wait pour écrire un code qui se comportera de la même manière que si nous utilisions des monades? Il s'avère que - oui (avec quelques réserves). Par exemple, le code ci-dessous est compilable et fonctionne:
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()); }
De plus, je vais expliquer comment fonctionne le code ...
Types de retour asynchrones généralisés
Tout d'abord, découvrons ce qui est nécessaire pour utiliser notre propre type (par exemple MyAwaitable <T> ) comme type de résultat d'une fonction asynchrone. La documentation indique qu'un tel type doit avoir:
GetAwaiter (), méthode qui renvoie un objet d'un type qui implémente l'interface INotifyCompletion et a la propriété bool IsCompleted et la méthode T GetResult () ;
Attribut [AsyncMethodBuilder (Type)] qui pointe vers une classe ( ou structure ) de "constructeur de méthodes", par exemple MyAwaitableTaskMethodBuilder <T> avec les méthodes suivantes:
- statique Create ()
- Démarrer (stateMachine)
- SetResult (résultat)
- SetException (exception)
- SetStateMachine (stateMachine)
- AwaitOnCompleted (serveur, stateMachine)
- AwaitUnsafeOnCompleted (serveur, stateMachine)
- Tâche
Voici une implémentation simple de MyAwaitable et 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; } }
Maintenant, nous pouvons utiliser MyAwaitable comme type de résultat des méthodes asynchrones:
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);
Le code fonctionne comme prévu, mais pour comprendre l'objectif des exigences de MyAwaitable , examinons ce que fait le préprocesseur C # avec MyAwaitableMethod . Si vous exécutez un utilitaire de décompilation (par exemple dotPeek), vous verrez que la méthode d'origine a été modifiée comme suit:
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; }
MyAwaitableMethodStateMachineEn fait, c'est un code simplifié où j'omet de nombreuses optimisations pour rendre un code généré par le compilateur lisible
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:
En examinant le code généré, nous pouvons voir que le "générateur de méthodes" a les responsabilités suivantes:
- Planification de l'appel de méthode MoveNext () de la machine à états lorsqu'une opération asynchrone enfant est effectuée (dans le scénario le plus simple, nous passons simplement MoveNext () dans OnCompleted () du serveur d'attente asynchrone).
- Création d'un objet de contexte d'opération asynchrone (
public MyAwaitable<T> Task { get; }
) - Réaction sur les états finaux des machines à états générées: SetResult ou SetException .
En d'autres termes, avec les «constructeurs de méthodes», nous pouvons obtenir un contrôle sur la façon dont les méthodes asynchrones sont exécutées et cela ressemble à une fonctionnalité qui nous aidera à atteindre notre objectif - une implémentation du comportement de peut - être monade. Mais qu'est-ce qui est bien avec cette monade? Eh bien ... vous pouvez trouver beaucoup d'articles sur cette monade sur Internet, donc je vais décrire ici les bases.
Peut-être monade
En bref, Maybe monad est un modèle de conception qui permet d'interrompre une chaîne d'appel de fonction si une fonction de la chaîne ne peut pas produire un résultat valable (par exemple une erreur d'analyse).
Historiquement, les langages de programmation impératifs ont résolu le problème de deux manières:
- Beaucoup de logique conditionnelle
- Exceptions
Les deux voies ont des inconvénients évidents, donc une troisième voie a été inventée:
- Créez un type qui peut être dans 2 états: "Une certaine valeur" et "Rien" - appelons-le "Peut-être"
- Créez une fonction (appelons-la "SelectMany") qui récupère 2 arguments:
2.1. Un objet de type "Peut-être"
2.2. Une fonction suivante de l'ensemble d'appels - la fonction doit également renvoyer un objet "Peut-être" qui contiendra un résultat ou "Rien" si son résultat ne peut pas être évalué (par exemple, les paramètres de la fonction ne sont pas au format correct) - La fonction "SelectMany" vérifie si "Peut-être" a une certaine valeur, puis appelle la fonction suivante en utilisant la valeur (extraite de "Peut-être") comme argument, puis retourne son résultat, sinon elle retourne un objet "Peut-être" dans l'état "Rien" .

En C #, il peut être implémenté comme ça:
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()); }
et utilisation:
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; }
Pourquoi «SelectMany»?Je pense que certains d'entre vous pourraient poser une question: "Pourquoi l'auteur at-il appelé la fonction" SelectMany "? C'est un nom si étrange". C'est vrai, mais il y a une raison à cela. En C #, SelectMany est utilisé par le préprocesseur pour prendre en charge la notation des requêtes, ce qui simplifie le travail avec les chaînes d'appel (vous pouvez trouver plus de détails dans mon article précédent )).
En fait, nous pouvons changer la chaîne d'appel comme suit:
var res = Function1(i) .SelectMany(x2 => Function2(x2).SelectMany(x3 => Function3(x3.SelectMany<int, int>(x4 => x2 + x3 + x4)));
afin que nous puissions accéder à tous les résultats intermédiaires, ce qui est pratique mais le code est difficile à lire.
Ici, la notation de requête nous aide:
var res = from x2 in Function1(i) from x3 in Function2(x2) from x4 in Function3(x3) select x2 + x3 + x4;
Pour rendre le code compilable, nous avons besoin d'une version améliorée de "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()); }
Implémentons le programme à partir de l'en-tête de l'article en utilisant cette implémentation "classique" peut-être " 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));
Le code n'a pas l'air génial car C # n'a pas été conçu comme un langage fonctionnel, mais dans les "vrais" langages fonctionnels comme Haskell, une telle approche est très courante
Async peut-être
L'essence de Maybe monad est de contrôler une chaîne d'appels de fonctions, mais c'est exactement ce que fait "async / wait". Essayons donc de les combiner ensemble. Tout d'abord, nous devons rendre le type Maybe compatible avec les fonctions asynchrones et nous savons déjà comment faire:
[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() =>... }
Voyons maintenant comment le "classique peut-être" peut être réécrit comme une machine à états pour pouvoir trouver des similitudes:
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; } } }
Si nous associons cette machine à états à celle générée par le préprocesseur C # (voir ci-dessus - 'MyAwaitableMethodStateMachine'), nous pouvons remarquer que la vérification d'état peut-être peut être implémentée à l'intérieur:
this.Builder.AwaitOnCompleted(ref awaiter, ref stateMachine);
où l' ref awaiter
est un objet de type Maybe . Le seul problème ici est que nous ne pouvons pas mettre la machine dans l'état "final" (-1). Est-ce à dire que nous ne pouvons pas contrôler le flux d'exécution? En fait, ce n'est pas le cas. Le fait est que pour chaque action asynchrone C # définit une action de rappel via l' interface INotifyCompletion , donc si nous voulons interrompre un flux d'exécution, nous pouvons simplement appeler l'action de rappel dans un cas où nous ne pouvons pas continuer le flux.
Un autre défi ici est que la machine d'état générée passe une action suivante (en tant que rappel de continuation) d'un flux actuel, mais nous avons besoin d'un rappel de continuation du flux initial qui permettrait de contourner le reste des opérations asynchrones:

Donc, nous devons en quelque sorte connecter une action asynchrone enfant à ses ancêtres. Nous pouvons le faire en utilisant notre "constructeur de méthodes" qui a un lien vers une opération asynchrone en cours - Tâche . Les liens vers toutes les opérations asynchrones enfants seront passés dans AwaitOnCompleted(ref awaiter
comme attendant , nous avons donc juste besoin de vérifier si le paramètre est une instance de Maybe et s'il est ensuite défini le Maybe actuel en tant que parent pour l'enfant un:
[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); } ... }
Maintenant, tous les objets Maybe peuvent être joints dans une arborescence et, par conséquent, nous aurons accès à une continuation de la racine Maybe (méthode Exit ) à partir de n'importe quel nœud descendant:
[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(); } } ... }
Cette méthode Exit doit être appelée lorsque (lors du déplacement sur l'arborescence) nous avons trouvé un objet Maybe déjà résolu dans l'état Nothing . De tels objets peut-être peuvent être retournés par une méthode comme celle-ci:
Maybe<int> Parse(string str) => int.TryParse(str, out var result) ? result : Maybe<int>.Nothing();
Pour stocker un état de résolution Peut - être introduisons une nouvelle structure distincte:
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() { }
Lorsqu'une machine d'état asynchrone appelle (via le générateur de méthodes) Méthode OnCompleted d'une instance de Peut-être déjà résolue et qu'elle est à l'état Rien , nous pourrons interrompre un flux entier:
public void OnCompleted(Action continuation) { this._continuation = continuation; if(this._result.HasValue) { this.NotifyResult(this._result.Value.IsNothing); } } internal void SetResult(T result)
Maintenant, la seule chose qui reste - comment obtenir un résultat d'un Peut-être asynchrone en dehors de sa portée (toute méthode asynchrone dont le type de retour n'est pas Peut-être ). Si vous essayez d'utiliser juste un mot clé en attente avec une instance de Maybe , une exception sera levée en raison de ce code:
[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; }
Pour résoudre le problème, nous pouvons simplement ajouter un nouvel Awaiter qui retournerait une structure entière de MaybeResult et nous pourrons écrire un code comme celui-ci:
var res = await GetResult().GetMaybeResult(); if(res.IsNothing){ ... } else{ res.GetValue(); ... };
C'est tout pour l'instant. Dans les exemples de code, j'omet certains détails pour me concentrer uniquement sur les parties les plus importantes. Vous pouvez trouver une version complète sur github .
Cependant , je ne recommanderais pas d'utiliser cette version dans n'importe quel code de production car elle a un problème important - lorsque nous freinons un flux d'exécution en appelant une continuation de la racine Peut-être que nous contournerons TOUT! y compris tous les blocs finally (c'est une réponse à la question "Les blocs finalement sont-ils toujours appelés?"), donc tous les opérateurs utilisateurs ne fonctionneront pas comme prévu et cela pourrait entraîner une fuite de ressources. Le problème peut être résolu si au lieu d'appeler le rappel de continuation initial, nous lançons une exception spéciale qui sera gérée en interne ( ici vous pouvez trouver la version ), mais cette solution a apparemment des imitations de performances (qui pourraient être acceptables dans certains scénarios). Avec la version actuelle du compilateur C #, je ne vois aucune autre solution mais cela pourrait être changé à l'avenir.
Ces limitations ne signifient pas que toutes les astuces décrites dans cet article sont complètement inutiles, elles peuvent être utilisées pour implémenter d'autres monades qui ne nécessitent pas de changements dans les flux d'exécution, par exemple "Reader". Comment implémenter cette monade "Reader" via async / wait, je vais montrer dans le prochain article .