Compartir a través de


Flujos asincrónicos

Nota:

Este artículo es una especificación de características. La especificación actúa como documento de diseño de la característica. Incluye cambios de especificación propuestos, junto con la información necesaria durante el diseño y el desarrollo de la característica. Estos artículos se publican hasta que se finalizan los cambios de especificación propuestos y se incorporan en la especificación ECMA actual.

Puede haber algunas discrepancias entre la especificación de características y la implementación completada. Esas diferencias se recogen en las notas de la reunión de diseño de lenguaje (LDM) correspondientes.

Puede obtener más información sobre el proceso de adopción de especificaciones de características en el estándar del lenguaje C#, en el artículo sobre especificaciones.

Problema planteado por el experto: https://github.com/dotnet/csharplang/issues/43

Resumen

C# admite métodos de iteradores y asincrónicos, pero no admite un método que sea un método de iterador y asincrónico a la vez. Debemos rectificar esto al permitir que se use await en una nueva forma de iterador async, uno que devuelva un IAsyncEnumerable<T> o IAsyncEnumerator<T> en lugar de un IEnumerable<T> o IEnumerator<T>, con IAsyncEnumerable<T> consumible en un nuevo await foreach. También se usa una interfaz IAsyncDisposable para habilitar la limpieza asincrónica.

Diseño detallado

Interfaces

IAsyncDisposable

Ha habido mucha discusión sobre IAsyncDisposable (por ejemplo, https://github.com/dotnet/roslyn/issues/114) y si es una buena idea. Sin embargo, es un concepto necesario para agregar compatibilidad con iteradores asincrónicos. Dado que los bloques finally pueden contener awaity, dado que los bloques finally deben ejecutarse como parte del proceso de eliminación de iteradores, necesitamos una eliminación asincrónica. También suele ser útil siempre que la limpieza de recursos tarde algún tiempo, por ejemplo, cerrar archivos (que requieren vaciados), anular el registro de devoluciones de llamada y proporcionar una manera de saber cuándo se han completado las anulaciones de registro, etc.

La siguiente interfaz se agrega a las bibliotecas básicas de .NET (por ejemplo, System.Private.CoreLib / System.Runtime):

namespace System
{
    public interface IAsyncDisposable
    {
        ValueTask DisposeAsync();
    }
}

Al igual que con Dispose, invocar DisposeAsync varias veces es aceptable, y las invocaciones posteriores después de la primera deben tratarse como operaciones que no realizan ninguna acción, devolviendo una tarea completa satisfactoriamente de manera síncrona (no es necesario queDisposeAsync sea seguro para subprocesos y no es necesario que admita la invocación simultánea). Además, los tipos pueden implementar tanto IDisposable como IAsyncDisposable, y, si lo hacen, también es aceptable invocar Dispose y, a continuación, DisposeAsync o viceversa, pero solo el primero debe ser significativo y las invocaciones posteriores deben ser una operación inefectiva. Por lo tanto, si un tipo implementa ambos, se recomienda que los consumidores llamen una sola vez al método más relevante en función del contexto, Dispose en contextos sincrónicos y DisposeAsync en los asincrónicos.

(Cómo interactúa IAsyncDisposable con using es una discusión independiente. Y el análisis de cómo interactúa con foreach se aborda más adelante en esta propuesta).

Alternativas a tener en cuenta:

  • DisposeAsync que acepta CancellationToken: aunque en teoría tiene sentido que todo lo asincrónico pueda ser cancelado, la eliminación se centra en la limpieza, el cierre de cosas, la liberación de recursos, etc., lo cual generalmente no debería ser cancelado; la limpieza sigue siendo importante incluso para el trabajo que se ha cancelado. La misma CancellationToken que causó la cancelación del trabajo típicamente sería el mismo token que se pasa a DisposeAsync, lo que hace que DisposeAsync sea inútil porque la cancelación del trabajo haría que DisposeAsync se convirtiera en una operación inefectiva. Si alguien quiere evitar quedar bloqueado esperando la eliminación, puede evitar esperar en la ValueTask resultante o esperar solo durante algún período de tiempo.
  • DisposeAsync que devuelve una Task: ahora que existe una ValueTask no genérica y que se puede construir a partir de un IValueTaskSource, devolver ValueTask de DisposeAsync permite reutilizar un objeto existente como promesa que representa la finalización asincrónica final de DisposeAsync, guardando una asignación de Task en caso de que DisposeAsync se complete de forma asincrónica.
  • Configuración de DisposeAsync con un bool continueOnCapturedContext (ConfigureAwait): Aunque puede haber problemas relacionados con cómo se expone tal concepto a using, foreach y otras construcciones de lenguaje que lo consumen, desde una perspectiva de interfaz realmente no está haciendo ninguna operación de await y no hay nada que configurar... los consumidores de la ValueTask pueden consumirla de la manera que deseen.
  • IAsyncDisposable heredando IDisposable: puesto que solo se debe usar uno u otro, no tiene sentido forzar a que los tipos implementen ambos.
  • IDisposableAsync en lugar de IAsyncDisposable: hemos seguido la nomenclatura en que las cosas o tipos son "algo asincrónico", mientras que las operaciones se "realizan de manera asincrónica", por lo que los tipos tienen "Async" como prefijo y los métodos tienen "Async" como sufijo.

IAsyncEnumerable / IAsyncEnumerator

Se han agregado dos interfaces a las bibliotecas principales de .NET:

namespace System.Collections.Generic
{
    public interface IAsyncEnumerable<out T>
    {
        IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);
    }

    public interface IAsyncEnumerator<out T> : IAsyncDisposable
    {
        ValueTask<bool> MoveNextAsync();
        T Current { get; }
    }
}

El consumo típico (sin características de lenguaje adicionales) tendría el siguiente aspecto:

IAsyncEnumerator<T> enumerator = enumerable.GetAsyncEnumerator();
try
{
    while (await enumerator.MoveNextAsync())
    {
        Use(enumerator.Current);
    }
}
finally { await enumerator.DisposeAsync(); }

Opciones descartadas consideradas:

  • Task<bool> MoveNextAsync(); T current { get; }: usar Task<bool> admitiría el uso de un objeto de tarea almacenado en caché para representar llamadas de MoveNextAsync sincrónicas y correctas, pero seguiría siendo necesaria una asignación para la finalización asincrónica. Al devolver ValueTask<bool>, habilitamos que el objeto enumerador implemente IValueTaskSource<bool> y se use como respaldo para la ValueTask<bool> devuelta desde MoveNextAsync, lo que a su vez permite una reducción significativa de las sobrecargas.
  • ValueTask<(bool, T)> MoveNextAsync();: no solo es más difícil de consumir, sino que significa que T ya no puede ser covariante.
  • ValueTask<T?> TryMoveNextAsync();: no covariante.
  • Task<T?> TryMoveNextAsync();: no covariante, asignaciones en cada llamada, etc.
  • ITask<T?> TryMoveNextAsync();: no covariante, asignaciones en cada llamada, etc.
  • ITask<(bool,T)> TryMoveNextAsync();: no covariante, asignaciones en cada llamada, etc.
  • Task<bool> TryMoveNextAsync(out T result);: el resultado out tendría que establecerse cuando la operación devuelve de forma sincrónica, no cuando completa de forma asincrónica la tarea potencialmente durante algún tiempo en el futuro, en cuyo momento no habría ninguna manera de comunicar el resultado.
  • IAsyncEnumerator<T> sin implementar IAsyncDisposable: podríamos optar por separarlos. Sin embargo, al hacerlo, se complican algunas otras áreas de la propuesta, ya que el código debe poder tratar con la posibilidad de que un enumerador no proporcione eliminación, lo que dificulta la escritura de asistentes basados en patrones. Además, será común que los enumeradores tengan la necesidad de eliminarse (por ejemplo, cualquier iterador asincrónico de C# que tenga un bloque final, la mayoría de las cosas que enumeran datos de una conexión de red, etc.), y si un enumerador no lo tiene, es sencillo implementar el método únicamente como public ValueTask DisposeAsync() => default(ValueTask); con una sobrecarga adicional mínima.
  • _ IAsyncEnumerator<T> GetAsyncEnumerator(): sin parámetro de token de cancelación.

En la subsección siguiente se describen las alternativas que no se han elegido.

Alternativa viable:

namespace System.Collections.Generic
{
    public interface IAsyncEnumerable<out T>
    {
        IAsyncEnumerator<T> GetAsyncEnumerator();
    }

    public interface IAsyncEnumerator<out T> : IAsyncDisposable
    {
        ValueTask<bool> WaitForNextAsync();
        T TryGetNext(out bool success);
    }
}

TryGetNext se usa en un bucle interno para consumir elementos con una sola llamada de la interfaz siempre que estén disponibles de forma sincrónica. Cuando el elemento siguiente no se puede recuperar de forma sincrónica, devuelve false y, en cualquier momento, devuelve false, un llamador debe invocar posteriormente WaitForNextAsync para esperar a que el siguiente elemento esté disponible o para determinar que nunca habrá otro elemento. El consumo típico (sin características de lenguaje adicionales) tendría el siguiente aspecto:

IAsyncEnumerator<T> enumerator = enumerable.GetAsyncEnumerator();
try
{
    while (await enumerator.WaitForNextAsync())
    {
        while (true)
        {
            int item = enumerator.TryGetNext(out bool success);
            if (!success) break;
            Use(item);
        }
    }
}
finally { await enumerator.DisposeAsync(); }

Esto tiene dos ventajas, una menor y otra mayor:

  • Menor: permite que un enumerador admita varios consumidores. Puede haber escenarios en los que sea útil que un enumerador admita varios consumidores simultáneos. Esto no se puede lograr cuando MoveNextAsync y Current son independientes de modo que una implementación no pueda hacer que su uso sea atómico. Por el contrario, este enfoque proporciona un único método TryGetNext que admite mover el enumerador hacia adelante y obtener el siguiente elemento, por lo que el enumerador puede habilitar la atomicidad si se desea. Sin embargo, es probable que estos escenarios también puedan habilitarse proporcionando a cada consumidor su propio enumerador de un enumerador compartido. Además, no queremos imponer que todos los enumeradores admitan el uso simultáneo, ya que eso agregaría sobrecargas no triviales al caso mayoritario que no lo requiere, lo que significa que un consumidor de la interfaz generalmente no podía confiar en esto.
  • Mayor: rendimiento. El enfoque MoveNextAsync/Current requiere dos llamadas de interfaz por operación, mientras que el mejor caso para WaitForNextAsync/TryGetNext es que la mayoría de las iteraciones se completen sincrónicamente, lo que permite un bucle interno ajustado con TryGetNext, de modo que solo tenemos una llamada de interfaz por operación. Esto puede tener un impacto medible en situaciones en las que las llamadas de interfaz dominan el cálculo.

Sin embargo, hay desventajas no triviales, incluida una mayor complejidad al consumirlas manualmente y una mayor posibilidad de introducir errores al usarlas. Y aunque las ventajas de rendimiento se muestran en los micropuntos de referencia, no creemos que afecten a la gran mayoría de los casos de uso reales. Si resulta que sí lo hacen, podemos introducir un segundo conjunto de interfaces de manera llamativa.

Opciones descartadas consideradas:

  • ValueTask<bool> WaitForNextAsync(); bool TryGetNext(out T result);: los parámetros out no pueden ser covariantes. También existe un pequeño impacto (un problema con el patrón try en general) que probablemente resulte en una barrera de escritura en tiempo de ejecución para los resultados del tipo de referencia.

Cancelación

Hay varios enfoques posibles para admitir la cancelación:

  1. IAsyncEnumerable<T>/IAsyncEnumerator<T> son ajenos a la cancelación: CancellationToken no aparece en ningún lugar. La cancelación se logra mediante la elaboración lógica del CancellationToken en el elemento enumerable y/o enumerador de la manera que sea apropiada, por ejemplo, al llamar a un iterador, pasando el CancellationToken como argumento al método del iterador y utilizándolo en el cuerpo del iterador, como se hace con cualquier otro parámetro.
  2. IAsyncEnumerator<T>.GetAsyncEnumerator(CancellationToken): Se pasa un CancellationToken a GetAsyncEnumerator, y las operaciones de MoveNextAsync posteriores lo respetan en la medida de lo posible.
  3. IAsyncEnumerator<T>.MoveNextAsync(CancellationToken): se pasa un CancellationToken a cada llamada de MoveNextAsync individual.
  4. 1 && 2: Ambos insertáis CancellationTokenen vuestro enumerable/enumerador y pasáis CancellationTokena GetAsyncEnumerator.
  5. 1 && 3: Ambos insertáis CancellationTokens en vuestro enumerable/enumerador y pasáis CancellationTokens a MoveNextAsync.

Desde una perspectiva puramente teórica, (5) es la más sólida, en que (a) cuando MoveNextAsync acepta un CancellationToken permite el control más específico sobre lo que se cancela y (b CancellationToken) es simplemente cualquier otro tipo que se puede pasar como argumento a iteradores, incrustados en tipos arbitrarios, etc.

Sin embargo, hay varios problemas con ese enfoque:

  • ¿Cómo se hace para que un CancellationToken pasado a GetAsyncEnumerator llegue al cuerpo del iterador? Podríamos exponer una nueva palabra clave de iterator que podría dejar de tener acceso al CancellationToken pasado a GetEnumerator, pero a) eso es una gran cantidad de maquinaria adicional, b) lo estamos convirtiendo en un ciudadano de primera clase y c) el caso 99% parecería ser el mismo código que llamar a un iterador y llamar a GetAsyncEnumerator, en cuyo caso simplemente puede pasar el CancellationToken como argumento al método.
  • ¿Cómo entra un CancellationToken pasado a MoveNextAsync en el cuerpo del método? Esto es aún peor, ya que si se expone fuera de un objeto local de iterator, su valor podría cambiar durante los awaits, lo que significa que cualquier código que esté registrado con el token tendría que darse de baja antes de los awaits y luego volver a registrarse después; también es potencialmente bastante costoso tener que realizar este proceso de registro y cancelación en cada llamada a MoveNextAsync, ya sea implementado por el compilador en un iterador o manualmente por un desarrollador.
  • ¿Cómo cancela un desarrollador un bucle foreach? Si se hace dando un CancellationToken a un enumerable o enumerador, entonces a) necesitamos admitir foreach sobre los enumeradores, lo que los eleva a ciudadanos de primera clase, lo que significa que ahora debe empezar a considerar un ecosistema desarrollado en torno a los enumeradores (por ejemplo, métodos LINQ) o b) necesitamos insertar el CancellationToken en el enumerable de todos modos, teniendo algún método de extensión WithCancellation fuera de IAsyncEnumerable<T> que almacenaría el token proporcionado y, a continuación, lo pasaría al GetAsyncEnumerator del enumerable ajustado cuando se invoca GetAsyncEnumerator en la estructura devuelta (ignorando ese token). O bien, puede usar el CancellationToken que tiene en el cuerpo del foreach.
  • Si/cuando se admiten las comprensiones de consultas, ¿cómo se proporcionaría el CancellationToken a GetEnumerator o MoveNextAsync para introducirse en cada cláusula? La manera más fácil sería simplemente que la cláusula lo capture, momento en el cual cualquier token que pase a GetAsyncEnumerator/MoveNextAsync es ignorado.

Una versión anterior de este documento recomendaba (1), pero desde entonces hemos cambiado a (4).

Los dos principales problemas con (1):

  • los productores de enumerables cancelables tienen que implementar algo de código estándar y solo pueden aprovechar la compatibilidad del compilador con los iteradores asincrónicos para implementar un método IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken).
  • es probable que muchos productores se vean tentados a simplemente agregar un parámetro CancellationToken a su firma asincrónica enumerable en su lugar, lo que impedirá que los consumidores pasen el token de cancelación que desean cuando se les proporciona un tipo de IAsyncEnumerable.

Hay dos escenarios de consumo principales:

  1. await foreach (var i in GetData(token)) ... donde el consumidor llama al método async-iterator,
  2. await foreach (var i in givenIAsyncEnumerable.WithCancellation(token)) ... donde el consumidor se ocupa de una instancia de IAsyncEnumerable determinada.

Creemos que un compromiso razonable para soportar ambos escenarios de una manera que sea conveniente tanto para los productores como para los consumidores de async-streams es utilizar un parámetro especialmente anotado en el método async-iterator. El atributo [EnumeratorCancellation] se usa para este propósito. Al colocar este atributo en un parámetro, se indica al compilador que si se pasa un token al método GetAsyncEnumerator, ese token debe usarse en lugar del valor pasado originalmente para el parámetro.

Fíjese en IAsyncEnumerable<int> GetData([EnumeratorCancellation] CancellationToken token = default). El implementador de este método simplemente puede usar el parámetro en el cuerpo del método. El consumidor puede usar cualquiera de los patrones de consumo anteriores:

  1. Si utilizas GetData(token), entonces el token se guarda en la enumeración asincrónica y se usará durante la iteración.
  2. Si usas givenIAsyncEnumerable.WithCancellation(token), entonces el token pasado a GetAsyncEnumerator reemplazará cualquier token guardado en el enumerable asíncrono.

foreach

foreach se ampliará para admitir IAsyncEnumerable<T> además de su soporte existente para IEnumerable<T>. Además, admitirá el equivalente de IAsyncEnumerable<T> como patrón si los miembros pertinentes se exponen públicamente, volviendo al uso de la interfaz directamente si no, para habilitar extensiones basadas en estructuras que eviten asignar y usar awaitables alternativos como el tipo de valor devuelto de MoveNextAsync y DisposeAsync.

Sintaxis

Uso de la sintaxis:

foreach (var i in enumerable)

C# seguirá tratando enumerable como enumerable sincrónico, de modo que incluso si expone las API pertinentes para enumerables asincrónicos (exponer el patrón o implementar la interfaz), solo tendrá en cuenta las API sincrónicas.

Para forzar que foreach en su lugar solo tenga en cuenta las API asincrónicas, await se inserta de la siguiente manera:

await foreach (var i in enumerable)

No se proporcionaría ninguna sintaxis que admita el uso de las API asincrónicas o sincrónicas; el desarrollador debe elegir en función de la sintaxis utilizada.

Semántica

El procesamiento en tiempo de compilación de una instrucción await foreach determina primero el tipo de colección , tipo de enumerador y tipo de iteración de la expresión (muy similar a https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/statements.md#1395-the-foreach-statement). La determinación procede de la siguiente manera:

  • Si el tipo X de expresión es dynamic o un tipo de matriz, se produce un error y no se realizan pasos adicionales.
  • En caso contrario, se determina si el tipo X tiene un método apropiado GetAsyncEnumerator:
    • Realice la búsqueda de miembros en el tipo X con identificador GetAsyncEnumerator y sin argumentos de tipo. Si la búsqueda de miembros no produce una coincidencia, o produce una ambigüedad, o produce una coincidencia que no es un grupo de métodos, compruebe si hay una interfaz enumerable como se describe a continuación.
    • Realiza la resolución de sobrecarga utilizando el grupo de métodos resultante y una lista de argumentos vacía. Si la resolución de sobrecarga no da como resultado ningún método aplicable, da como resultado una ambigüedad o da como resultado un único método mejor pero ese método es estático o no es público, compruebe si hay una interfaz enumerable como se describe a continuación.
    • Si el tipo de devolución E del método GetAsyncEnumerator no es un tipo de clase, estructura o interfaz, se produce un error y no se realizan más pasos.
    • La búsqueda de miembros se realiza E con el identificador Current y sin argumentos de tipo. Si la búsqueda de miembros no produce ninguna coincidencia, el resultado es un error, o el resultado es cualquier cosa excepto una propiedad pública de instancia que permite la lectura, se produce un error y no se realizan más pasos.
    • La búsqueda de miembros se realiza E con el identificador MoveNextAsync y sin argumentos de tipo. Si la búsqueda de miembros no produce ninguna coincidencia, el resultado es un error, o el resultado es cualquier cosa excepto un grupo de métodos, se produce un error y no se toman más medidas.
    • La resolución de sobrecarga se realiza en el grupo de métodos con una lista de argumentos vacía. Si la resolución de sobrecargas no da como resultado ningún método aplicable, da como resultado una ambigüedad, o da como resultado un único mejor método pero ese método es estático o no es público, o su tipo de devolución no admite await en bool, se produce un error y no se siguen más pasos.
    • El tipo de la colección es X, el tipo del enumerador es E, y el tipo de iteración elemento es el tipo de propiedad Current.
  • En caso contrario, comprueba si existe una interfaz enumerable:
    • Si entre todos los tipos Tᵢ para los que hay una conversión implícita de X a IAsyncEnumerable<ᵢ>, hay un único tipo T tal que T no es dinámico y para todos los demás Tᵢ hay una conversión implícita de IAsyncEnumerable<T> a IAsyncEnumerable<Tᵢ>, el tipo de colección es la interfaz IAsyncEnumerable<T>, el tipo de enumerador es la interfaz IAsyncEnumerator<T> y el tipo de iteración es T.
    • En caso contrario, si hay más de un tipo T, se produce un error y no se realizan más pasos.
  • En caso contrario, se produce un error y no se realizan más pasos.

Los pasos anteriores, si se realizan correctamente, generan de forma inequívoca un tipo de colección C, un tipo de enumerador E y un tipo de iteración T.

await foreach (V v in x) «embedded_statement»

se expande a:

{
    E e = ((C)(x)).GetAsyncEnumerator();
    try {
        while (await e.MoveNextAsync()) {
            V v = (V)(T)e.Current;
            «embedded_statement»
        }
    }
    finally {
        ... // Dispose e
    }
}

El cuerpo del bloque finally se construye según los pasos siguientes:

  • Si el tipo E tiene un método DisposeAsync adecuado:
    • Realice la búsqueda de miembros en el tipo E con identificador DisposeAsync y sin argumentos de tipo. Si la búsqueda de miembros no produce una coincidencia, o produce una ambigüedad, o produce una coincidencia que no es un grupo de métodos, compruebe si hay una interfaz de eliminación como se describe a continuación.
    • Realiza la resolución de sobrecarga utilizando el grupo de métodos resultante y una lista de argumentos vacía. Si la resolución de sobrecarga no da como resultado ningún método aplicable, da como resultado una ambigüedad o da como resultado un único método mejor pero ese método es estático o no es público, compruebe si hay una interfaz de eliminación como se describe a continuación.
    • Si el tipo de retorno del método DisposeAsync no es esperable, se produce un error y no se realizan más pasos.
    • La cláusula finally se expande al equivalente semántico de:
      finally {
          await e.DisposeAsync();
      }
    
  • De lo contrario, si hay una conversión implícita de E a la interfaz System.IAsyncDisposable,
    • Si E es un tipo de valor que no acepta valores NULL, la cláusula finally se expande al equivalente semántico de:
      finally {
          await ((System.IAsyncDisposable)e).DisposeAsync();
      }
    
    • Si no es así, la cláusula finally se expande al equivalente semántico de:
      finally {
          System.IAsyncDisposable d = e as System.IAsyncDisposable;
          if (d != null) await d.DisposeAsync();
      }
      
      excepto si E es un tipo de valor o un parámetro de tipo instanciado como un tipo de valor, la conversión de e a System.IAsyncDisposable no hará que se produzca boxing.
  • De lo contrario, la cláusula finally se expande a un bloque vacío:
    finally {
    }
    

ConfigureAwait

Esta compilación basada en patrones permitirá usar ConfigureAwait en todos los awaits a través de un método de extensión ConfigureAwait.

await foreach (T item in enumerable.ConfigureAwait(false))
{
   ...
}

Esto se basará en los tipos que agregaremos también a .NET, probablemente System.Threading.Tasks.Extensions.dll:

// Approximate implementation, omitting arg validation and the like
namespace System.Threading.Tasks
{
    public static class AsyncEnumerableExtensions
    {
        public static ConfiguredAsyncEnumerable<T> ConfigureAwait<T>(this IAsyncEnumerable<T> enumerable, bool continueOnCapturedContext) =>
            new ConfiguredAsyncEnumerable<T>(enumerable, continueOnCapturedContext);

        public struct ConfiguredAsyncEnumerable<T>
        {
            private readonly IAsyncEnumerable<T> _enumerable;
            private readonly bool _continueOnCapturedContext;

            internal ConfiguredAsyncEnumerable(IAsyncEnumerable<T> enumerable, bool continueOnCapturedContext)
            {
                _enumerable = enumerable;
                _continueOnCapturedContext = continueOnCapturedContext;
            }

            public ConfiguredAsyncEnumerator<T> GetAsyncEnumerator() =>
                new ConfiguredAsyncEnumerator<T>(_enumerable.GetAsyncEnumerator(), _continueOnCapturedContext);

            public struct ConfiguredAsyncEnumerator<T>
            {
                private readonly IAsyncEnumerator<T> _enumerator;
                private readonly bool _continueOnCapturedContext;

                internal ConfiguredAsyncEnumerator(IAsyncEnumerator<T> enumerator, bool continueOnCapturedContext)
                {
                    _enumerator = enumerator;
                    _continueOnCapturedContext = continueOnCapturedContext;
                }

                public ConfiguredValueTaskAwaitable<bool> MoveNextAsync() =>
                    _enumerator.MoveNextAsync().ConfigureAwait(_continueOnCapturedContext);

                public T Current => _enumerator.Current;

                public ConfiguredValueTaskAwaitable DisposeAsync() =>
                    _enumerator.DisposeAsync().ConfigureAwait(_continueOnCapturedContext);
            }
        }
    }
}

Tenga en cuenta que este enfoque no permitirá que ConfigureAwait se usen con enumerables basados en patrones; sin embargo, ya es el caso que el ConfigureAwait solo se expone como una extensión en Task/Task<T>/ValueTask/ValueTask<T> y no se puede aplicar a elementos esperables arbitrarios, ya que solo tiene sentido cuando se aplica a Tasks (controla un comportamiento implementado en el soporte para la continuación de la Task), y, por lo tanto, no tiene sentido cuando se usa un patrón en el que es posible que los elementos esperables no sean Tasks. Cualquier persona que devuelva elementos que admitan await puede proporcionar su propio comportamiento personalizado en estos escenarios avanzados.

(Si podemos encontrar una forma de apoyar una solución de nivel de alcance o ensamblado ConfigureAwait, esto no será necesario).

Iteradores asincrónicos

El compilador y el lenguaje admitirán la producción de los IAsyncEnumerable<T>s y los IAsyncEnumerator<T>s, además de consumirlos. Hoy en día, el lenguaje admite la escritura de un iterador como:

static IEnumerable<int> MyIterator()
{
    try
    {
        for (int i = 0; i < 100; i++)
        {
            Thread.Sleep(1000);
            yield return i;
        }
    }
    finally
    {
        Thread.Sleep(200);
        Console.WriteLine("finally");
    }
}

pero await no se puede usar en el cuerpo de estos iteradores. Añadiremos ese soporte.

Sintaxis

La compatibilidad actual del idioma con los iteradores infiere la naturaleza del iterador del método en función de si contiene yield. Lo mismo ocurrirá para los iteradores asincrónicos. Estos iteradores asincrónicos se demarcarán y diferenciarán de los iteradores sincrónicos mediante la adición de async a la firma y, a continuación, también deben tener IAsyncEnumerable<T> o IAsyncEnumerator<T> como su tipo de valor devuelto. Por ejemplo, el caso anterior podría escribirse como iterador asincrónico de la siguiente manera:

static async IAsyncEnumerable<int> MyIterator()
{
    try
    {
        for (int i = 0; i < 100; i++)
        {
            await Task.Delay(1000);
            yield return i;
        }
    }
    finally
    {
        await Task.Delay(200);
        Console.WriteLine("finally");
    }
}

Alternativas a tener en cuenta:

  • No usar async en la firma: es probable que el compilador requiera técnicamente el uso de async, ya que lo usa para determinar si await es válido en ese contexto. Pero aunque no sea necesario, hemos establecido que await solo se pueda usar en métodos marcados como async, y parece importante mantener la coherencia.
  • Habilitación de constructores personalizados para IAsyncEnumerable<T>: esto es algo que podríamos considerar en el futuro, pero la maquinaria es complicada y no lo admitimos para los homólogos síncronos.
  • Tener una palabra clave iterator en la firma: los iteradores asincrónicos usarían async iterator en la firma y yield solo se podría usar en métodos async que incluyeran iterator; iterator se haría opcional en iteradores sincrónicos. Dependiendo de tu perspectiva, esto tiene la ventaja de hacerlo muy claro a partir de la firma del método si yield está permitido y si el método realmente está destinado a devolver instancias de tipo IAsyncEnumerable<T> en lugar de que el compilador genere una basada en si el código utiliza yield o no. Sin embargo, es diferente de los iteradores sincrónicos, que no requieren uno y no se pueden hacer para que lo requieran. Además, a algunos desarrolladores no les gusta sintaxis adicional. Si estuviéramos diseñándolo desde cero, probablemente lo haríamos obligatorio, pero en este momento hay mucho más valor en mantener los iteradores asincrónicos cerca de los sincrónicos.

LINQ

Hay más de 200 sobrecargas de métodos en la clase System.Linq.Enumerable, todas las cuales funcionan en términos de IEnumerable<T>; alguna aceptan IEnumerable<T>, otras producen IEnumerable<T>, y muchas hacen ambas cosas. Agregar compatibilidad con LINQ para IAsyncEnumerable<T> probablemente implicaría duplicar todas estas sobrecargas, para unas ~200. Y dado que IAsyncEnumerator<T> probablemente sea más común como entidad independiente en el mundo asincrónico que IEnumerator<T> lo sea en el mundo sincrónico, podríamos necesitar unas ~200 sobrecargas que sean compatibles con IAsyncEnumerator<T>. Además, un gran número de sobrecargas se ocupan de predicados (por ejemplo, Where que toma un Func<T, bool>), y puede ser conveniente tener sobrecargas basadas en IAsyncEnumerable<T>que manejen predicados síncronos y asíncronos (por ejemplo, Func<T, ValueTask<bool>> además de Func<T, bool>). Aunque esto no es aplicable a todas las aproximadamente ~400 nuevas sobrecargas, un cálculo aproximado es que sería aplicable a la mitad, lo que significa otras ~200 sobrecargas adicionales, para un total de aproximadamente ~600 nuevos métodos.

Se trata de un número asombroso de API, con un potencial aún mayor si se tienen en cuenta las bibliotecas de extensiones como Extensiones interactivas (Ix). Pero Ix ya tiene una implementación de muchas de ellas, y no parece haber una gran razón para duplicar ese trabajo; en su lugar, deberíamos ayudar a la comunidad a mejorar Ix y recomendarlo para cuando los desarrolladores quieran utilizar LINQ con IAsyncEnumerable<T>.

También está el problema de la sintaxis de comprensión de consultas. La naturaleza basada en patrones de las comprensiones de consulta les permitiría "trabajar" con algunos operadores, por ejemplo, si Ix proporciona los métodos siguientes:

public static IAsyncEnumerable<TResult> Select<TSource, TResult>(this IAsyncEnumerable<TSource> source, Func<TSource, TResult> func);
public static IAsyncEnumerable<T> Where(this IAsyncEnumerable<T> source, Func<T, bool> func);

a continuación, este código de C# "solo funcionará":

IAsyncEnumerable<int> enumerable = ...;
IAsyncEnumerable<int> result = from item in enumerable
                               where item % 2 == 0
                               select item * 2;

Sin embargo, no hay ninguna sintaxis de comprensión de consultas que admita el uso de await en las cláusulas , por lo que si Ix agregó, por ejemplo:

public static IAsyncEnumerable<TResult> Select<TSource, TResult>(this IAsyncEnumerable<TSource> source, Func<TSource, ValueTask<TResult>> func);

entonces esto funcionaría sin problemas

IAsyncEnumerable<string> result = from url in urls
                                  where item % 2 == 0
                                  select SomeAsyncMethod(item);

async ValueTask<int> SomeAsyncMethod(int item)
{
    await Task.Yield();
    return item * 2;
}

pero no habría forma de escribirlo con el await insertado en la cláusula select. Como esfuerzo independiente, podríamos examinar la adición de expresiones async { ... } al lenguaje, en cuyo punto podríamos permitirles usarse en las comprensiones de consulta y lo anterior podría escribirse como:

IAsyncEnumerable<int> result = from item in enumerable
                               where item % 2 == 0
                               select async
                               {
                                   await Task.Yield();
                                   return item * 2;
                               };

o para permitir que await se utilice directamente en expresiones, por ejemplo, mediante el soporte de async from. Sin embargo, es poco probable que un diseño aquí tenga un impacto en el resto del conjunto de características de una manera u otra, y esto no es una cosa particularmente de alto valor para invertir en este momento, por lo que la propuesta es no hacer nada adicional aquí en este momento.

Integración con otros marcos asincrónicos

La integración con IObservable<T> y otros marcos asincrónicos (por ejemplo, flujos reactivos) se realizaría en el nivel de biblioteca en lugar de en el nivel de lenguaje. Por ejemplo, todos los datos de un IAsyncEnumerator<T> se pueden publicar en un IObserver<T> simplemente con await foreach sobre el enumerador y con OnNext sobre los datos al observador, por lo que es posible un método de extensión AsObservable<T>. Consumir un IObservable<T> en un await foreach requiere almacenar en búfer el dato (en caso de que se inserte otro elemento mientras el elemento anterior sigue procesando), pero este adaptador push-pull se puede implementar fácilmente para permitir que se extraiga un IObservable<T> con un IAsyncEnumerator<T>. Etc. Rx/Ix ya proporcionan prototipos de estas implementaciones y bibliotecas como https://github.com/dotnet/corefx/tree/master/src/System.Threading.Channels proporcionan varios tipos de estructuras de datos de almacenamiento en búfer. El lenguaje no debe intervenir en esta fase.