
Tipos de retorno asíncrono generalizado : es una nueva característica de C # 7 que permite usar no solo la Tarea como un tipo de retorno de métodos asíncronos , sino también otros tipos (clases o estructuras) que satisfacen algunos requisitos específicos.
Al mismo tiempo, async / await es una forma de llamar a un conjunto de funciones de "continuación" dentro de un contexto que es la esencia de otro patrón de diseño: Monad . Entonces, ¿podemos usar async / await para escribir un código que se comportará de la misma manera que si usáramos mónadas? Resulta que sí (con algunas reservas). Por ejemplo, el siguiente código es compilable y funciona:
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()); }
Además, explicaré cómo funciona el código ...
Tipos de retorno asíncrono generalizado
En primer lugar, descubramos qué se necesita para usar nuestro propio tipo (por ejemplo, MyAwaitable <T> ) como tipo de resultado de alguna función asíncrona. La documentación dice que ese tipo tiene que tener:
Método GetAwaiter () que devuelve un objeto de un tipo que implementa la interfaz INotifyCompletion y tiene la propiedad boCom IsCompleted y el método T GetResult () ;
Atributo [AsyncMethodBuilder (Type)] que apunta a una clase ( o estructura ) de " creador de métodos", por ejemplo, MyAwaitableTaskMethodBuilder <T> con los siguientes métodos:
- Crear estático ()
- Inicio (stateMachine)
- SetResult (resultado)
- SetException (excepción)
- SetStateMachine (stateMachine)
- AwaitOnCompleted (camarero, stateMachine)
- AwaitUnsafeOnCompleted (camarero, stateMachine)
- Tarea
Aquí hay una implementación simple de MyAwaitable y 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; } }
Ahora podemos usar MyAwaitable como un tipo de resultado de métodos asíncronos:
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);
El código funciona como se esperaba, pero para comprender el propósito de los requisitos de MyAwaitable, echemos un vistazo a lo que hace el preprocesador de C # con MyAwaitableMethod . Si ejecuta alguna utilidad de descompilación (por ejemplo, dotPeek) verá que el método original se cambió de la siguiente manera:
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 realidad, es un código simplificado donde omito muchas optimizaciones para que un código generado por el compilador sea legible
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:
Al revisar el código generado, podemos ver que el "creador de métodos" tiene las siguientes responsabilidades:
- Programación de la llamada al método MoveNext () de la máquina de estado cuando se realiza una operación asincrónica secundaria (en el escenario más simple, simplemente pasamos MoveNext () a OnCompleted () del camarero de la operación asíncrona).
- Creación de un objeto de contexto de operación asíncrono (
public MyAwaitable<T> Task { get; }
) - Reacción en estados finales de máquinas de estado generadas: SetResult o SetException .
En otras palabras, con los "creadores de métodos" podemos controlar cómo se ejecutan los métodos asincrónicos y parece una característica que nos ayudará a lograr nuestro objetivo: una implementación del comportamiento de Mónada Quizás . Pero, ¿qué tiene de bueno esa mónada? Bueno ... puedes encontrar muchos artículos sobre esa mónada en Internet, así que aquí describiré solo lo básico.
Tal vez mónada
En resumen, Quizás mónada es un patrón de diseño que permite la interrupción de una cadena de llamada de función si alguna función de la cadena no puede producir un resultado valioso (por ejemplo, error de análisis).
Históricamente, los lenguajes de programación imperativos han estado resolviendo el problema de dos maneras:
- Mucha lógica condicional
- Excepciones
Las dos formas tienen desventajas obvias, por lo que se inventó una tercera forma:
- Cree un tipo que pueda estar en 2 estados: "Algún valor" y "Nada". Llamémoslo "Quizás"
- Cree una función (llamémosla "SelectMany") que recupera 2 argumentos:
2.1. Un objeto de tipo "Quizás"
2.2. Una siguiente función del conjunto de llamadas: la función también debe devolver un objeto de "Quizás" que contendrá un resultado o "Nada" si su resultado no puede evaluarse (por ejemplo, los parámetros de la función no están en el formato correcto) - La función "SelectMany" verifica si "Quizás" tiene algún valor y luego llama a la siguiente función utilizando el valor (extraído de "Quizás") como argumento y luego devuelve su resultado; de lo contrario, devuelve un objeto "Quizás" en estado "Nada" .

En C # se puede implementar así:
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()); }
y uso:
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; }
¿Por qué 'SelectMany'?Creo que algunos de ustedes podrían hacer una pregunta: "¿Por qué el autor llamó a la función" SelectMany "? Es un nombre tan extraño". Lo es, pero hay una razón para eso. En C #, SelectMany es utilizado por el preprocesador para admitir la notación de consultas que simplifica el trabajo con cadenas de llamadas (puede encontrar más detalles en mi artículo anterior ).
De hecho, podemos cambiar la cadena de llamadas de la siguiente manera:
var res = Function1(i) .SelectMany(x2 => Function2(x2).SelectMany(x3 => Function3(x3.SelectMany<int, int>(x4 => x2 + x3 + x4)));
para que podamos acceder a todos los resultados intermedios, lo cual es conveniente pero el código es difícil de leer.
Aquí la notación de consulta nos ayuda:
var res = from x2 in Function1(i) from x3 in Function2(x2) from x4 in Function3(x3) select x2 + x3 + x4;
Para que el código sea compilable, necesitamos una versión mejorada de "Seleccionar muchos"
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()); }
Implementemos el programa desde el encabezado del artículo usando esta implementación 'clásica' Quizás ' 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));
El código no se ve muy bien ya que C # no fue diseñado como un lenguaje funcional, pero en lenguajes funcionales "verdaderos" como Haskell, este enfoque es muy común
Tal vez asíncrono
La esencia de Quizás monad es controlar una cadena de llamadas de función, pero es exactamente lo que hace "async / wait". Así que intentemos combinarlos. Primero, debemos hacer que el tipo Quizás sea compatible con funciones asincrónicas y ya sabemos cómo hacerlo:
[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() =>... }
Ahora echemos un vistazo a cómo el "clásico Quizás" puede reescribirse como una máquina de estado para poder encontrar cualquier similitud:
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 hacemos coincidir esta máquina de estado con la generada por el preprocesador de C # uno (ver arriba - 'MyAwaitableMethodStateMachine') podemos notar que tal vez la verificación de estado puede implementarse dentro de:
this.Builder.AwaitOnCompleted(ref awaiter, ref stateMachine);
donde ref awaiter
es un objeto de tipo Quizás . El único problema aquí es que no podemos configurar la máquina en el estado "final" (-1). ¿Eso significa que no podemos controlar el flujo de ejecución? En realidad no lo hace. La cuestión es que para cada acción asincrónica, C # establece una acción de devolución de llamada a través de la interfaz INotifyCompletion , por lo que si queremos interrumpir un flujo de ejecución, podemos llamar a la acción de devolución de llamada en un caso en el que no podemos continuar el flujo.
Otro desafío aquí es que la máquina de estado generada pasa una próxima acción (como una devolución de llamada de continuación) de un flujo actual, pero necesitamos una devolución de llamada de continuación del flujo inicial que permitiría omitir el resto de las operaciones asíncronas:

Por lo tanto, necesitamos conectar de alguna manera una acción asíncrona secundaria con sus antepasados. Podemos hacerlo utilizando nuestro "generador de métodos" que tiene un enlace a una operación asincrónica actual: Tarea . Los enlaces a todas las operaciones asíncronas secundarias se pasarán a AwaitOnCompleted(ref awaiter
como camarero , por lo que solo debemos verificar si el parámetro es una instancia de Quizás y si se establece el tal vez actual como padre para el hijo:
[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); } ... }
Ahora todos los objetos tal vez se pueden unir en un árbol y, como resultado, tendremos acceso a una continuación de la raíz tal vez (método de salida ) desde cualquier nodo descendente:
[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(); } } ... }
Debe llamarse a ese método Exit cuando (durante el movimiento sobre el árbol) encontramos un objeto Quizás ya resuelto en el estado Nothing . Tales objetos pueden ser devueltos por un método como este:
Maybe<int> Parse(string str) => int.TryParse(str, out var result) ? result : Maybe<int>.Nothing();
Para almacenar un estado de resuelto Quizás introduzcamos una nueva estructura separada:
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() { }
Cuando una máquina de estado asíncrona llama (a través del generador de métodos) Método OnCompleted de una instancia tal vez ya resuelta y está en estado Nothing , podremos romper un flujo completo:
public void OnCompleted(Action continuation) { this._continuation = continuation; if(this._result.HasValue) { this.NotifyResult(this._result.Value.IsNothing); } } internal void SetResult(T result)
Ahora lo único que queda es cómo obtener un resultado de un Asíncrono Quizás fuera de su alcance (cualquier método asíncrono cuyo tipo de retorno no sea Quizás ). Si intenta usar la palabra clave aguardar con una instancia de Maybe , se generará una excepción debido a este código:
[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; }
Para resolver el problema, solo podemos agregar un nuevo Awaiter que devolvería una estructura completa de QuizásResult y podremos escribir un código como este:
var res = await GetResult().GetMaybeResult(); if(res.IsNothing){ ... } else{ res.GetValue(); ... };
Eso es todo por ahora. En los ejemplos de código, omito algunos detalles para centrarme solo en las partes más importantes. Puedes encontrar una versión completa en github .
Sin embargo , no recomendaría usar esta versión en ningún código de producción, ya que tiene un problema importante: cuando frenamos un flujo de ejecución llamando a una continuación de la raíz. ¡ Quizás omitiremos TODO! incluyendo todos los bloques finalmente (es una respuesta a la pregunta "¿Se llaman siempre los bloques finalmente ?"), por lo que todos los operadores que usan no funcionarán como se esperaba y eso podría conducir a la fuga de recursos. El problema se puede resolver si, en lugar de llamar a la devolución de llamada de continuación inicial, lanzaremos una excepción especial que se manejará internamente ( aquí puede encontrar la versión ), pero esta solución aparentemente tiene imitaciones de rendimiento (que pueden ser aceptables en algunos escenarios). Con la versión actual del compilador de C # no veo ninguna otra solución, pero eso podría cambiar en el futuro.
Estas limitaciones no significan que todos los trucos descritos en este artículo sean completamente inútiles, se pueden usar para implementar otras mónadas que no requieren cambios en los flujos de ejecución, por ejemplo, "Reader". Cómo implementar esa mónada "Reader" a través de async / await mostraré en el próximo artículo .