Fluxos assíncronos
Nota
Este artigo é uma especificação de recurso. A especificação serve como o documento de design para o recurso. Ele inclui alterações de especificação propostas, juntamente com as informações necessárias durante o design e o desenvolvimento do recurso. Esses artigos são publicados até que as alterações de especificação propostas sejam finalizadas e incorporadas na especificação ECMA atual.
Pode haver algumas discrepâncias entre a especificação do recurso e a implementação concluída. Essas diferenças são capturadas nas notas pertinentes da reunião de design de idioma (LDM).
Você pode saber mais sobre o processo de adoção de speclets de recursos no padrão de linguagem C# no artigo sobre as especificações de .
Resumo
O C# tem suporte para métodos de iterador e métodos assíncronos, mas não há suporte para um método que seja um iterador e um método assíncrono. Devemos corrigir isso permitindo que await
seja usado em uma nova forma de iterador async
, que retorne um IAsyncEnumerable<T>
ou IAsyncEnumerator<T>
em vez de um IEnumerable<T>
ou IEnumerator<T>
, com IAsyncEnumerable<T>
consumível em um novo await foreach
. Uma interface IAsyncDisposable
também é usada para habilitar a limpeza assíncrona.
Discussão relacionada
Design detalhado
Interfaces
IAsyncDisposable
Tem havido muita discussão sobre IAsyncDisposable
(por exemplo, https://github.com/dotnet/roslyn/issues/114) e se é uma boa ideia. No entanto, é um conceito necessário para adicionar suporte a iteradores assíncronos. Como os blocos finally
podem conter await
, e como os blocos finally
precisam ser executados como parte do processo de descarte de iteradores, precisamos de um descarte assíncrono. Também é útil, de modo geral, sempre que a limpeza de recursos pode levar algum tempo, por exemplo, fechando arquivos (exigindo flushes), cancelando o registro de retornos de chamada e fornecendo uma maneira de saber quando o cancelamento do registro foi concluído, etc.
A interface a seguir é adicionada às principais bibliotecas do .NET (por exemplo, System.Private.CoreLib/System.Runtime):
namespace System
{
public interface IAsyncDisposable
{
ValueTask DisposeAsync();
}
}
Assim como acontece com Dispose
, invocar DisposeAsync
várias vezes é aceitável e as invocações subsequentes após a primeira devem ser tratadas como não operações, retornando uma tarefa bem-sucedida concluída de forma síncrona (DisposeAsync
não precisam ser thread-safe, no entanto, e não precisam dar suporte à invocação simultânea). Além disso, os tipos podem implementar IDisposable
e IAsyncDisposable
, e se o fizerem, é aceitável da mesma forma invocar Dispose
e depois DisposeAsync
ou vice-versa, mas apenas a primeira deve ser significativa e as invocações subsequentes de qualquer deles devem ser um nop. Dessa forma, se um tipo implementar ambos, os consumidores serão incentivados a chamar uma vez e apenas uma vez o método mais relevante com base no contexto, Dispose
em contextos síncronos e DisposeAsync
em assíncronos.
(Como IAsyncDisposable
interage com using
é uma discussão separada. E a cobertura de como ele interage com foreach
é tratada posteriormente nesta proposta.)
Alternativas consideradas:
-
DisposeAsync
aceitando umCancellationToken
: embora, em teoria, faça sentido que qualquer coisa assíncrona possa ser cancelada, o descarte tem a ver com limpeza, fechamento de coisas, liberação de recursos etc., o que geralmente não é algo que deva ser cancelado; a limpeza ainda é importante para o trabalho que é cancelado. A mesmaCancellationToken
que causou o cancelamento do trabalho em questão normalmente seria o mesmo token passado paraDisposeAsync
, tornandoDisposeAsync
inútil porque o cancelamento do trabalho fariaDisposeAsync
se tornar um no-op. Se alguém quiser evitar ser bloqueado aguardando o descarte, poderá evitar aguardar oValueTask
resultante ou aguardar apenas por algum período de tempo. DisposeAsync
retornando umTask
: agora que umValueTask
não genérico existe e pode ser construído a partir de umIValueTaskSource
, retornarValueTask
deDisposeAsync
permite que um objeto existente seja reutilizado como a promessa que representa a conclusão assíncrona eventual deDisposeAsync
, salvando uma alocação deTask
no caso em queDisposeAsync
é concluída de forma assíncrona.- Configurando
DisposeAsync
com umbool continueOnCapturedContext
(ConfigureAwait
): embora possa haver problemas relacionados à forma como esse conceito é exposto ausing
,foreach
e outros constructos de linguagem que o consomem, de uma perspectiva de interface, não se realiza realmente nenhumawait
e não há nada para configurar... os consumidores doValueTask
podem consumir como preferirem. IAsyncDisposable
herdandoIDisposable
: como apenas um ou outro deve ser usado, não faz sentido forçar os tipos a implementar ambos.IDisposableAsync
em vez deIAsyncDisposable
: temos seguido a nomenclatura de que coisas/tipos são uma "coisa assíncrona", enquanto as operações são "feitas assíncronas", portanto, os tipos têm "Assíncrono" como prefixo e os métodos têm "Assíncrono" como sufixo.
IAsyncEnumerable / IAsyncEnumerator
Duas interfaces são adicionadas às principais bibliotecas do .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; }
}
}
O consumo típico (sem recursos de idioma adicionais) seria semelhante a:
IAsyncEnumerator<T> enumerator = enumerable.GetAsyncEnumerator();
try
{
while (await enumerator.MoveNextAsync())
{
Use(enumerator.Current);
}
}
finally { await enumerator.DisposeAsync(); }
Opções descartadas consideradas:
Task<bool> MoveNextAsync(); T current { get; }
: o uso deTask<bool>
seria compatível com o uso de um objeto de tarefa armazenado em cache para representar chamadas deMoveNextAsync
síncronas e bem-sucedidas, mas uma alocação ainda seria necessária para conclusão assíncrona. RetornandoValueTask<bool>
, permitimos que o objeto enumerador implemente por si mesmoIValueTaskSource<bool>
e seja usado como base para oValueTask<bool>
retornado deMoveNextAsync
, o que permite, por sua vez, reduzir significativamente as sobrecargas.ValueTask<(bool, T)> MoveNextAsync();
: Não é apenas mais difícil de consumir, mas significa queT
não pode mais ser covariante.ValueTask<T?> TryMoveNextAsync();
: não covariante.Task<T?> TryMoveNextAsync();
: não covariante, alocações em cada chamada etc.ITask<T?> TryMoveNextAsync();
: não covariante, alocações em cada chamada etc.ITask<(bool,T)> TryMoveNextAsync();
: não covariante, alocações em cada chamada etc.Task<bool> TryMoveNextAsync(out T result);
: o resultado doout
precisaria ser definido quando a operação retornasse de forma síncrona, não quando ela concluisse a tarefa de forma assíncrona, potencialmente em algum momento no futuro, momento em que não haveria como comunicar o resultado.-
IAsyncEnumerator<T>
não implementandoIAsyncDisposable
: podemos optar por separá-los. No entanto, isso complica certas outras áreas da proposta, pois o código deve ser capaz de lidar com a possibilidade de que um enumerador não forneça recursos de descarte, tornando difícil a escrita de ferramentas auxiliares baseadas em padrões. Além disso, é comum que os enumeradores precisem ser descartados (por exemplo, qualquer iterador assíncrono do C# que tenha um bloco finally, a maioria das coisas que enumeram dados de uma conexão de rede etc.) e, se não for o caso, é simples implementar o método puramente comopublic ValueTask DisposeAsync() => default(ValueTask);
com sobrecarga adicional mínima. - _
IAsyncEnumerator<T> GetAsyncEnumerator()
: nenhum parâmetro de token de cancelamento.
A subseção a seguir discute alternativas que não foram escolhidas.
Alternativa viável:
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
é usado em um loop interno para consumir itens com uma única chamada de interface, desde que estejam disponíveis de forma síncrona. Quando o próximo item não pode ser recuperado de forma síncrona, ele retorna false, e sempre que retorna false, um chamador deve invocar posteriormente WaitForNextAsync
para aguardar o próximo item estar disponível ou para determinar que nunca haverá outro item. O consumo típico (sem recursos de idioma adicionais) seria semelhante a:
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(); }
A vantagem disso é dupla, uma menor e outra maior:
- Menor: permite que um enumerador dê suporte a vários consumidores. Pode haver cenários em que é valioso para um enumerador dar suporte a vários consumidores simultâneos. Isso não pode ser alcançado quando
MoveNextAsync
eCurrent
são independentes, de forma que uma implementação não possa tornar seu uso atômico. Por outro lado, essa abordagem fornece um único métodoTryGetNext
que dá suporte a avançar o enumerador e obter o próximo item, de modo que o enumerador possa habilitar a atomicidade, se assim for desejado. No entanto, é provável que esses cenários também possam ser habilitados dando a cada consumidor seu próprio enumerador de um enumerável compartilhado. Além disso, não queremos impor que cada enumerador dê suporte ao uso simultâneo, pois isso adicionaria sobrecargas não triviais ao caso majoritário que não o exige, o que significa que um consumidor da interface geralmente não poderia confiar nisso de forma alguma. - Maior: performance. A abordagem
MoveNextAsync
/Current
requer duas chamadas de interface por operação, enquanto o melhor caso paraWaitForNextAsync
/TryGetNext
é que a maioria das iterações é concluída de forma síncrona, permitindo um loop interno apertado comTryGetNext
, de modo que tenhamos apenas uma chamada de interface por operação. Isso pode ter um impacto mensurável em situações em que as chamadas de interface dominam a computação.
No entanto, há desvantagens não triviais, incluindo uma complexidade significativamente maior ao consumi-las manualmente e uma maior chance de introduzir bugs ao usá-los. E embora os benefícios de desempenho apareçam em microbenchmarks, não acreditamos que serão impactantes na grande maioria dos casos de uso real. Se for o caso, podemos introduzir um segundo conjunto de interfaces de forma destacada.
Opções descartadas consideradas:
ValueTask<bool> WaitForNextAsync(); bool TryGetNext(out T result);
:out
parâmetros não podem ser covariantes. Há também um pequeno impacto aqui (um problema com o padrão try em geral), pois isso provavelmente incorre em uma barreira de gravação em tempo de execução para resultados de tipos de referência.
Cancelamento
Há várias abordagens possíveis para dar suporte ao cancelamento:
-
IAsyncEnumerable<T>
/IAsyncEnumerator<T>
são independentes de cancelamento:CancellationToken
não aparece em lugar nenhum. O cancelamento é alcançado ao integrar logicamente oCancellationToken
no enumerável e/ou no enumerador da maneira que for apropriada, por exemplo, ao chamar um iterador, passando oCancellationToken
como um argumento para o método do iterador e usando-o no corpo do iterador, como se faz com qualquer outro parâmetro. -
IAsyncEnumerator<T>.GetAsyncEnumerator(CancellationToken)
: você passa umCancellationToken
paraGetAsyncEnumerator
e as operações deMoveNextAsync
subsequentes o respeitam na medida do possível. -
IAsyncEnumerator<T>.MoveNextAsync(CancellationToken)
: você passa umaCancellationToken
para cada chamadaMoveNextAsync
individual. - 1 && 2: você pode incorporar
CancellationToken
s em seu enumerável/enumerador e passarCancellationToken
s paraGetAsyncEnumerator
. - 1 && 3: você pode incorporar
CancellationToken
s em seu enumerável/enumerador e passarCancellationToken
s paraMoveNextAsync
.
De uma perspectiva puramente teórica, (5) é o mais robusto, pois (a) MoveNextAsync
aceitando uma CancellationToken
habilita o controle mais preciso sobre o que é cancelado, e (b) CancellationToken
é qualquer outro tipo que pode ser passado como argumento em iteradores, embutido em tipos arbitrários etc.
No entanto, há vários problemas com essa abordagem:
- Como um
CancellationToken
passado paraGetAsyncEnumerator
chega ao corpo do iterador? Poderíamos expor uma nova palavra-chaveiterator
, da qual você poderia derivar para obter acesso aoCancellationToken
passado paraGetEnumerator
, mas a) é uma grande quantidade de maquinário adicional, b) estamos tratando-o como importante, e c) o caso 99% parece ser o mesmo código que chama um iterador e chamaGetAsyncEnumerator
sobre ele, podendo, nesse caso, simplesmente passar oCancellationToken
como argumento para o método. - Como um
CancellationToken
passado paraMoveNextAsync
entra dentro do corpo do método? Isso é ainda pior, como se ele fosse exposto de um objeto localiterator
, seu valor poderia ser alterado entre esperas, o que significa que qualquer código registrado com o token precisaria cancelar o registro dele antes de aguardar e depois se registrar novamente; também é potencialmente muito caro a necessidade de fazer esse registro e cancelamento de registro em cada chamadaMoveNextAsync
, independentemente de ser implementada pelo compilador em um iterador ou por um desenvolvedor manualmente. - Como um desenvolvedor cancela um loop de
foreach
? Se for feito dando umCancellationToken
a um enumerável/enumerador, nesse caso, a) precisamos dar suporte aforeach
'ing sobre enumeradores, elevando-os a cidadãos de primeira classe, e agora você precisa começar a pensar sobre um ecossistema criado em torno de enumeradores (por exemplo, métodos LINQ) ou b) precisamos inserir oCancellationToken
no enumerável de qualquer maneira, tendo algum método de extensãoWithCancellation
doIAsyncEnumerable<T>
que armazenaria o token fornecido e, em seguida, passe-o para oGetAsyncEnumerator
do enumerável encapsulado quando oGetAsyncEnumerator
no struct retornado é invocado (ignorando esse token). Ou você pode usar apenas oCancellationToken
que você tem no corpo do foreach. - Se/quando houver suporte para compreensões de consulta, como o
CancellationToken
fornecido paraGetEnumerator
ouMoveNextAsync
seria passado para cada cláusula? A maneira mais fácil seria simplesmente que a cláusula a capturasse, momento em que qualquer token passado paraGetAsyncEnumerator
/MoveNextAsync
é ignorado.
Uma versão anterior deste documento recomendava (1), mas desde então, nós mudamos para (4).
Os dois principais problemas com (1):
- os produtores de enumeráveis canceláveis precisam implementar algum código padrão e só podem aproveitar o suporte do compilador para iteradores assíncronos ao implementar um método
IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken)
. - é provável que muitos produtores sejam tentados a simplesmente adicionar um parâmetro
CancellationToken
à sua assinatura de enumeração assíncrona, o que evitará que os consumidores passem o token de cancelamento desejado quando lhes for dado um tipoIAsyncEnumerable
.
Há dois cenários principais de consumo:
-
await foreach (var i in GetData(token)) ...
onde o consumidor chama o método de iterador assíncrono, -
await foreach (var i in givenIAsyncEnumerable.WithCancellation(token)) ...
onde o consumidor lida com uma instância determinada deIAsyncEnumerable
.
Achamos que um meio-termo razoável para dar suporte a ambos os cenários de uma forma conveniente para produtores e consumidores de fluxos assíncronos é usar um parâmetro especialmente anotado no método do iterador assíncrono. O atributo [EnumeratorCancellation]
é usado para essa finalidade. Colocar esse atributo em um parâmetro informa ao compilador que, se um token for passado para o método GetAsyncEnumerator
, esse token deverá ser usado em vez do valor passado originalmente para o parâmetro.
Considere o IAsyncEnumerable<int> GetData([EnumeratorCancellation] CancellationToken token = default)
.
O implementador desse método pode simplesmente usar o parâmetro no corpo do método.
O consumidor pode usar os padrões de consumo acima:
- Se você usar
GetData(token)
, o token será salvo no "async-enumerable" e será usado na iteração. - se você usar
givenIAsyncEnumerable.WithCancellation(token)
, o token passado paraGetAsyncEnumerator
substituirá qualquer token salvo no enumerável assíncrono.
foreach
foreach
será aumentada para dar suporte a IAsyncEnumerable<T>
além de seu suporte existente para IEnumerable<T>
. E ele dará suporte ao equivalente de IAsyncEnumerable<T>
como um padrão se os membros relevantes forem expostos publicamente, voltando a usar a interface diretamente caso contrário, para permitir extensões baseadas em struct que evitem a alocação, bem como o uso de aguardáveis alternativos como o tipo de retorno de MoveNextAsync
e DisposeAsync
.
Sintaxe
Usando a sintaxe:
foreach (var i in enumerable)
O C# continuará a tratar enumerable
como um enumerável síncrono, de modo que, mesmo que exponha as APIs relevantes para enumeráveis assíncronos (expondo o padrão ou implementando a interface), ele considerará apenas as APIs síncronas.
Para forçar foreach
a considerar apenas as APIs assíncronas, await
é inserido da seguinte maneira:
await foreach (var i in enumerable)
Nenhuma sintaxe seria fornecida para dar suporte ao uso das APIs assíncronas ou de sincronização; o desenvolvedor deve escolher com base na sintaxe usada.
Semântica
O processamento em tempo de compilação de uma instrução await foreach
primeiro determina o tipo de coleção , tipo de enumerador e tipo de iteração da expressão (muito semelhante a https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/statements.md#1395-the-foreach-statement). Essa determinação prossegue da seguinte maneira:
- Se o tipo
X
da expressão fordynamic
ou um tipo de matriz, um erro será produzido e nenhuma outra etapa será executada. - Caso contrário, determine se o tipo
X
tem um método deGetAsyncEnumerator
apropriado:- Realize a pesquisa de membro no tipo
X
com o identificadorGetAsyncEnumerator
e sem argumentos de tipo. Se a busca por membro não produzir uma correspondência, ou produzir uma ambiguidade, ou produzir uma correspondência que não seja um grupo de métodos, verifique se há uma interface enumerável, conforme descrito abaixo. - Execute a resolução de sobrecarga usando o grupo de métodos resultante e uma lista de argumentos vazia. Se a resolução de sobrecarga não resultar em métodos aplicáveis, resultar em uma ambiguidade ou resultar em um único método melhor, mas esse método for estático ou não público, verifique se há uma interface enumerável, conforme descrito abaixo.
- Se o tipo de retorno
E
do métodoGetAsyncEnumerator
não for uma classe, struct ou tipo de interface, um erro será produzido e nenhuma etapa adicional será executada. - A busca por membro é realizada em
E
com o identificadorCurrent
e nenhum argumento de tipo. Se a pesquisa de membro não resultar em nenhuma correspondência, ou se o resultado for qualquer coisa exceto uma propriedade de instância pública que permita a leitura, um erro será gerado e nenhuma etapa adicional será executada. - A consulta de membro é realizada em
E
com o identificadorMoveNextAsync
e sem argumentos de tipo. Se a pesquisa de membro não produzir correspondência, o resultado será um erro ou o resultado for qualquer coisa, exceto um grupo de métodos, um erro será produzido e nenhuma etapa adicional será executada. - A resolução de sobrecarga é executada no grupo de métodos com uma lista de argumentos vazia. Se a resolução de sobrecarga não resultar em métodos aplicáveis, resultar em uma ambiguidade ou resultar em um único melhor método, mas esse método for estático, não for público ou seu tipo de retorno não puder ser aguardado em
bool
, um erro é produzido e nenhuma outra etapa é seguida. - O tipo de coleção é
X
, o tipo de enumerador éE
e o tipo de iteração é o tipo da propriedadeCurrent
.
- Realize a pesquisa de membro no tipo
- Caso contrário, verifique se há uma interface enumerável:
- Se entre todos os tipos
Tᵢ
para os quais há uma conversão implícita deX
paraIAsyncEnumerable<ᵢ>
, há um tipo exclusivoT
de modo queT
não seja dinâmico e para todos os outrosTᵢ
há uma conversão implícita deIAsyncEnumerable<T>
paraIAsyncEnumerable<Tᵢ>
, então o tipo de coleção é oIAsyncEnumerable<T>
de interface, o tipo de enumerador é a interfaceIAsyncEnumerator<T>
, e o tipo de iteração éT
. - Caso contrário, se houver mais de um tipo desse tipo
T
, um erro será produzido e nenhuma etapa adicional será executada.
- Se entre todos os tipos
- Caso contrário, um erro será produzido e nenhuma etapa adicional será executada.
As etapas acima, se bem-sucedidas, produzem sem ambiguidade um tipo de coleção C
, tipo de enumerador E
e tipo de iteração T
.
await foreach (V v in x) «embedded_statement»
em seguida, é expandido para:
{
E e = ((C)(x)).GetAsyncEnumerator();
try {
while (await e.MoveNextAsync()) {
V v = (V)(T)e.Current;
«embedded_statement»
}
}
finally {
... // Dispose e
}
}
O corpo do bloco finally
é construído de acordo com as seguintes etapas:
- Se o tipo
E
tiver um método deDisposeAsync
apropriado:- Realize uma pesquisa de membros no tipo
E
com o identificadorDisposeAsync
e sem argumentos de tipo. Se a pesquisa de membros não produzir uma correspondência, ou produzir uma ambiguidade, ou produzir uma correspondência que não seja um grupo de métodos, verifique a interface de descarte conforme descrito abaixo. - Execute a resolução de sobrecarga usando o grupo de métodos resultante e uma lista de argumentos vazia. Se a resolução de sobrecarga não resultar em métodos aplicáveis, resultar em uma ambiguidade ou resultar em um único método melhor, mas esse método for estático ou não público, verifique a interface de descarte, conforme descrito abaixo.
- Se o tipo de retorno do método
DisposeAsync
não for aguardado, um erro será produzido e nenhuma etapa adicional será executada. - A cláusula
finally
é expandida para o equivalente semântico de:
finally { await e.DisposeAsync(); }
- Realize uma pesquisa de membros no tipo
- Caso contrário, se houver uma conversão implícita de
E
para a interfaceSystem.IAsyncDisposable
,- Se
E
for um tipo de valor não anulável, a cláusulafinally
será expandida para o equivalente semântico de:
finally { await ((System.IAsyncDisposable)e).DisposeAsync(); }
- Caso contrário, a cláusula
finally
será expandida para o equivalente semântico de:
exceto sefinally { System.IAsyncDisposable d = e as System.IAsyncDisposable; if (d != null) await d.DisposeAsync(); }
E
for um tipo de valor ou um parâmetro de tipo instanciado em um tipo de valor, a conversão dee
paraSystem.IAsyncDisposable
não fará com que o boxing ocorra.
- Se
- Caso contrário, a cláusula
finally
será expandida para um bloco vazio:finally { }
ConfigureAwait
Essa compilação baseada em padrão permitirá que ConfigureAwait
sejam usados em todas as esperas, por meio de um método de extensão ConfigureAwait
:
await foreach (T item in enumerable.ConfigureAwait(false))
{
...
}
Isso também será baseado nos tipos que adicionaremos ao .NET, provavelmente 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);
}
}
}
}
Observe que essa abordagem não habilitará que ConfigureAwait
para ser usado com enumeráveis baseados em padrões, mas, novamente, já é o caso que o ConfigureAwait
seja exposto apenas como uma extensão em Task
/Task<T>
/ValueTask
/ValueTask<T>
e não possa ser aplicado a objetos aguardáveis arbitrários, pois só faz sentido quando aplicado a tarefas (controla um comportamento implementado no suporte à continuação da tarefa) e, portanto, não faz sentido ao usar um padrão em que os objetos aguardáveis possam não ser tarefas. Qualquer pessoa que retorne coisas aguardáveis pode fornecer seu próprio comportamento personalizado em tais cenários avançados.
(Se pudermos criar uma maneira de dar suporte a uma solução de ConfigureAwait
no nível do escopo ou do assembly, isso não será necessário.)
Iteradores assíncronos
A linguagem/compilador suportará a produção de IAsyncEnumerable<T>
s e IAsyncEnumerator<T>
s além de consumi-los. Hoje, o idioma dá suporte à escrita de um 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");
}
}
mas await
não pode ser usado no corpo desses iteradores. Adicionaremos esse suporte.
Sintaxe
O suporte de linguagem existente para iteradores infere a natureza de iterador do método com base no fato de ele conter qualquer yield
s. O mesmo será verdadeiro para iteradores assíncronos. Tais iteradores assíncronos serão demarcados e diferenciados de iteradores síncronos por meio da adição de async
à assinatura e, em seguida, também devem ter IAsyncEnumerable<T>
ou IAsyncEnumerator<T>
como seu tipo de retorno. Por exemplo, o exemplo acima pode ser escrito como um iterador assíncrono da seguinte maneira:
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 consideradas:
- Não usar
async
node assinatura: usarasync
provavelmente é tecnicamente exigido pelo compilador, pois o usa para determinar seawait
é válido nesse contexto. Mas mesmo que não seja necessário, estabelecemos queawait
só podem ser usados em métodos marcados comoasync
, e parece importante manter a consistência. - Habilitar construtores personalizados para
IAsyncEnumerable<T>
: isso é algo que poderíamos examinar para o futuro, mas a máquina é complicada e não damos suporte a isso para os equivalentes síncronos. - Ter uma palavra-chave
iterator
na assinatura: os iteradores assíncronos usariamasync iterator
na assinatura, eyield
só poderia ser utilizado em métodosasync
que incluíssemiterator
;iterator
se tornaria opcional em iteradores síncronos. Dependendo de sua perspectiva, isso tem o benefício de deixar muito claro pela assinatura do método seyield
é permitido e se o método é realmente destinado a retornar instâncias do tipoIAsyncEnumerable<T>
em vez do compilador fabricando um com base em se o código usayield
ou não. Mas é diferente dos iteradores síncronos, que não são e não podem ser feitos para exigir um. Além disso, alguns desenvolvedores não gostam da sintaxe extra. Se estivéssemos projetando do zero, provavelmente tornaríamos isso necessário, mas neste ponto há muito mais valor em manter iteradores assíncronos próximos aos iteradores de sincronização.
LINQ
Há mais de ~200 sobrecargas de métodos na classe System.Linq.Enumerable
, todas elas funcionam em termos de IEnumerable<T>
; algumas delas aceitam IEnumerable<T>
, algumas delas produzem IEnumerable<T>
e muitas fazem ambos. Adicionar suporte LINQ para IAsyncEnumerable<T>
provavelmente implicaria a duplicação de todas essas sobrecargas para ele, o que representaria mais ~200. E como IAsyncEnumerator<T>
provavelmente será mais comum como uma entidade autônoma no mundo assíncrono do que IEnumerator<T>
está no mundo síncrono, poderíamos potencialmente precisar de outras ~200 sobrecargas que funcionam com IAsyncEnumerator<T>
. Além disso, um grande número de sobrecargas lida com predicados (por exemplo, Where
que usa um Func<T, bool>
), e pode ser desejável ter sobrecargas baseadas em IAsyncEnumerable<T>
que lidam com predicados síncronos e assíncronos (por exemplo, Func<T, ValueTask<bool>>
além de Func<T, bool>
). Embora isso não seja aplicável a todas as cerca de 400 novas sobrecargas agora, um cálculo aproximado é que ele seria aplicável à metade, o que significa outras cerca de 200 sobrecargas, para um total de ~600 novos métodos.
Esse é um número impressionante de APIs, com potencial para ainda mais quando bibliotecas de extensão como extensões interativas (Ix) são consideradas. Mas o Ix já tem uma implementação de muitas delas, e não parece haver uma grande razão para duplicar esse trabalho; Em vez disso, devemos ajudar a comunidade a melhorar o Ix e recomendá-lo para quando os desenvolvedores quiserem usar o LINQ com IAsyncEnumerable<T>
.
Há também a questão da sintaxe de compreensão de consulta. A natureza baseada em padrão de compreensões de consulta lhes permitiria "apenas trabalhar" com alguns operadores, por exemplo, se o Ix fornecesse os seguintes métodos:
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);
em seguida, esse código C# "funcionará sem problemas":
IAsyncEnumerable<int> enumerable = ...;
IAsyncEnumerable<int> result = from item in enumerable
where item % 2 == 0
select item * 2;
No entanto, não há nenhuma sintaxe de compreensão de consulta que dê suporte ao uso de await
nas cláusulas, portanto, se Ix tiver adicionado, por exemplo:
public static IAsyncEnumerable<TResult> Select<TSource, TResult>(this IAsyncEnumerable<TSource> source, Func<TSource, ValueTask<TResult>> func);
então isso "simplesmente funcionaria"
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;
}
mas não haveria como escrevê-lo com o await
embutido na cláusula select
. Como um esforço separado, poderíamos considerar a inclusão de expressões async { ... }
na linguagem. Nesse momento, poderíamos permitir que elas fossem usadas em compreensões de consulta e as acima poderiam, em vez disso, ser escritas como:
IAsyncEnumerable<int> result = from item in enumerable
where item % 2 == 0
select async
{
await Task.Yield();
return item * 2;
};
ou para habilitar await
a ser usada diretamente em expressões, como suportando async from
. No entanto, é improvável que um design aqui impacte o resto do conjunto de recursos de uma forma ou de outra, e isso não é uma coisa particularmente de alto valor para investir agora, então a proposta é não fazer nada adicional aqui agora.
Integração com outras estruturas assíncronas
A integração com IObservable<T>
e outras estruturas assíncronas (por exemplo, fluxos reativos) seria feita no nível da biblioteca e não no nível da linguagem. Por exemplo, todos os dados de um IAsyncEnumerator<T>
podem ser publicados em um IObserver<T>
simplesmente ao await foreach
sobre o enumerador e ao OnNext
os dados para o observador, assim, é possível ter um método de extensão AsObservable<T>
. Consumir um IObservable<T>
em um await foreach
requer o armazenamento em buffer dos dados (caso outro item faça push enquanto o item anterior ainda estiver sendo processado), mas esse adaptador de push-pull pode ser facilmente implementado para habilitar que um IObservable<T>
seja puxado por um IAsyncEnumerator<T>
. Etc. O Rx/Ix já fornece protótipos dessas implementações e bibliotecas como https://github.com/dotnet/corefx/tree/master/src/System.Threading.Channels fornecem vários tipos de estruturas de dados de buffer. O idioma não precisa estar envolvido neste estágio.
C# feature specifications