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.
Discusión relacionada
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 await
y, 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 aceptaCancellationToken
: 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 mismaCancellationToken
que causó la cancelación del trabajo típicamente sería el mismo token que se pasa aDisposeAsync
, lo que hace queDisposeAsync
sea inútil porque la cancelación del trabajo haría queDisposeAsync
se convirtiera en una operación inefectiva. Si alguien quiere evitar quedar bloqueado esperando la eliminación, puede evitar esperar en laValueTask
resultante o esperar solo durante algún período de tiempo.-
DisposeAsync
que devuelve unaTask
: ahora que existe unaValueTask
no genérica y que se puede construir a partir de unIValueTaskSource
, devolverValueTask
deDisposeAsync
permite reutilizar un objeto existente como promesa que representa la finalización asincrónica final deDisposeAsync
, guardando una asignación deTask
en caso de queDisposeAsync
se complete de forma asincrónica. - Configuración de
DisposeAsync
con unbool continueOnCapturedContext
(ConfigureAwait
): Aunque puede haber problemas relacionados con cómo se expone tal concepto ausing
,foreach
y otras construcciones de lenguaje que lo consumen, desde una perspectiva de interfaz realmente no está haciendo ninguna operación deawait
y no hay nada que configurar... los consumidores de laValueTask
pueden consumirla de la manera que deseen. IAsyncDisposable
heredandoIDisposable
: puesto que solo se debe usar uno u otro, no tiene sentido forzar a que los tipos implementen ambos.IDisposableAsync
en lugar deIAsyncDisposable
: 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; }
: usarTask<bool>
admitiría el uso de un objeto de tarea almacenado en caché para representar llamadas deMoveNextAsync
sincrónicas y correctas, pero seguiría siendo necesaria una asignación para la finalización asincrónica. Al devolverValueTask<bool>
, habilitamos que el objeto enumerador implementeIValueTaskSource<bool>
y se use como respaldo para laValueTask<bool>
devuelta desdeMoveNextAsync
, 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 queT
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 resultadoout
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 implementarIAsyncDisposable
: 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 comopublic 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
yCurrent
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étodoTryGetNext
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 paraWaitForNextAsync
/TryGetNext
es que la mayoría de las iteraciones se completen sincrónicamente, lo que permite un bucle interno ajustado conTryGetNext
, 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ámetrosout
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:
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 delCancellationToken
en el elemento enumerable y/o enumerador de la manera que sea apropiada, por ejemplo, al llamar a un iterador, pasando elCancellationToken
como argumento al método del iterador y utilizándolo en el cuerpo del iterador, como se hace con cualquier otro parámetro.IAsyncEnumerator<T>.GetAsyncEnumerator(CancellationToken)
: Se pasa unCancellationToken
aGetAsyncEnumerator
, y las operaciones deMoveNextAsync
posteriores lo respetan en la medida de lo posible.-
IAsyncEnumerator<T>.MoveNextAsync(CancellationToken)
: se pasa unCancellationToken
a cada llamada deMoveNextAsync
individual. - 1 && 2: Ambos insertáis
CancellationToken
en vuestro enumerable/enumerador y pasáisCancellationToken
aGetAsyncEnumerator
. - 1 && 3: Ambos insertáis
CancellationToken
s en vuestro enumerable/enumerador y pasáisCancellationToken
s aMoveNextAsync
.
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 aGetAsyncEnumerator
llegue al cuerpo del iterador? Podríamos exponer una nueva palabra clave deiterator
que podría dejar de tener acceso alCancellationToken
pasado aGetEnumerator
, 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 aGetAsyncEnumerator
, en cuyo caso simplemente puede pasar elCancellationToken
como argumento al método. - ¿Cómo entra un
CancellationToken
pasado aMoveNextAsync
en el cuerpo del método? Esto es aún peor, ya que si se expone fuera de un objeto local deiterator
, 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 aMoveNextAsync
, 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 unCancellationToken
a un enumerable o enumerador, entonces a) necesitamos admitirforeach
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 elCancellationToken
en el enumerable de todos modos, teniendo algún método de extensiónWithCancellation
fuera deIAsyncEnumerable<T>
que almacenaría el token proporcionado y, a continuación, lo pasaría alGetAsyncEnumerator
del enumerable ajustado cuando se invocaGetAsyncEnumerator
en la estructura devuelta (ignorando ese token). O bien, puede usar elCancellationToken
que tiene en el cuerpo del foreach. - Si/cuando se admiten las comprensiones de consultas, ¿cómo se proporcionaría el
CancellationToken
aGetEnumerator
oMoveNextAsync
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 aGetAsyncEnumerator
/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 deIAsyncEnumerable
.
Hay dos escenarios de consumo principales:
-
await foreach (var i in GetData(token)) ...
donde el consumidor llama al método async-iterator, -
await foreach (var i in givenIAsyncEnumerable.WithCancellation(token)) ...
donde el consumidor se ocupa de una instancia deIAsyncEnumerable
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:
- Si utilizas
GetData(token)
, entonces el token se guarda en la enumeración asincrónica y se usará durante la iteración. - Si usas
givenIAsyncEnumerable.WithCancellation(token)
, entonces el token pasado aGetAsyncEnumerator
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 esdynamic
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 apropiadoGetAsyncEnumerator
:- Realice la búsqueda de miembros en el tipo
X
con identificadorGetAsyncEnumerator
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étodoGetAsyncEnumerator
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 identificadorCurrent
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 identificadorMoveNextAsync
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 esE
, y el tipo de iteración elemento es el tipo de propiedadCurrent
.
- Realice la búsqueda de miembros en el tipo
- En caso contrario, comprueba si existe una interfaz enumerable:
- Si entre todos los tipos
Tᵢ
para los que hay una conversión implícita deX
aIAsyncEnumerable<ᵢ>
, hay un único tipoT
tal queT
no es dinámico y para todos los demásTᵢ
hay una conversión implícita deIAsyncEnumerable<T>
aIAsyncEnumerable<Tᵢ>
, el tipo de colección es la interfazIAsyncEnumerable<T>
, el tipo de enumerador es la interfazIAsyncEnumerator<T>
y el tipo de iteración esT
. - En caso contrario, si hay más de un tipo
T
, se produce un error y no se realizan más pasos.
- Si entre todos los tipos
- 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étodoDisposeAsync
adecuado:- Realice la búsqueda de miembros en el tipo
E
con identificadorDisposeAsync
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(); }
- Realice la búsqueda de miembros en el tipo
- De lo contrario, si hay una conversión implícita de
E
a la interfazSystem.IAsyncDisposable
,- Si
E
es un tipo de valor que no acepta valores NULL, la cláusulafinally
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:
excepto sifinally { System.IAsyncDisposable d = e as System.IAsyncDisposable; if (d != null) await d.DisposeAsync(); }
E
es un tipo de valor o un parámetro de tipo instanciado como un tipo de valor, la conversión dee
aSystem.IAsyncDisposable
no hará que se produzca boxing.
- Si
- 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 deasync
, ya que lo usa para determinar siawait
es válido en ese contexto. Pero aunque no sea necesario, hemos establecido queawait
solo se pueda usar en métodos marcados comoasync
, 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íanasync iterator
en la firma yyield
solo se podría usar en métodosasync
que incluyeraniterator
;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 siyield
está permitido y si el método realmente está destinado a devolver instancias de tipoIAsyncEnumerable<T>
en lugar de que el compilador genere una basada en si el código utilizayield
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.
C# feature specifications