Fluxos assíncronos
Observação
Este artigo é uma especificação de recurso. A especificação serve como o documento de design para o recurso. Ele inclui mudanças de especificação propostas, juntamente com as informações necessárias durante o design e desenvolvimento do recurso. Estes artigos são publicados até que as alterações de especificações 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 Linguagem (LDM) .
Você pode saber mais sobre o processo de adoção de especificações de recursos no padrão de linguagem C# no artigo sobre as especificações .
Questão campeã: https://github.com/dotnet/csharplang/issues/43
Resumo
C# tem suporte para métodos iteradores e métodos assíncronos, mas nenhum suporte para um método que é um iterador e um método assíncrono. Devemos retificar isto, permitindo que await
seja utilizado numa nova forma de iterador async
, que devolve um IAsyncEnumerable<T>
ou IAsyncEnumerator<T>
em vez de um IEnumerable<T>
ou IEnumerator<T>
, com IAsyncEnumerable<T>
consumível num novo await foreach
. Uma interface IAsyncDisposable
também é usada para habilitar a limpeza assíncrona.
Discussão relacionada
Design detalhado
Interfaces
IAsyncDescartável
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
s, e como os blocos finally
precisam ser executados como parte do descarte de iteradores, precisamos de descarte assíncrono. Também é geralmente útil a qualquer momento em que a limpeza de recursos possa levar um certo período de tempo, por exemplo, ao fechar arquivos (exigindo esvaziamentos), desregistar os callbacks e providenciar uma maneira de saber quando a desregistração está concluída, etc.
A seguinte interface é adicionada às principais bibliotecas .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 invocações subsequentes após a primeira devem ser tratadas como no-ops, retornando uma tarefa bem-sucedida concluída de forma síncrona (DisposeAsync
não precisa ser thread-safe, no entanto, e não precisa suportar invocações simultâneas). Além disso, os tipos podem implementar tanto IDisposable
quanto IAsyncDisposable
e, se o fizerem, é igualmente aceitável invocar Dispose
e, em seguida, DisposeAsync
ou vice-versa, mas apenas a primeira deve ser significativa e as invocações subsequentes de qualquer uma devem resultar em um "nop". Como tal, se um tipo implementar ambos, os consumidores são incentivados a chamar uma única 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 mais adiante nesta proposta.)
Alternativas consideradas:
-
DisposeAsync
aceitar umCancellationToken
: enquanto em teoria faz sentido que qualquer coisa assíncrona possa ser cancelada, o descarte é sobre limpeza, fechamento de coisas, liberação de recursos, etc., o que geralmente não é algo que deve ser cancelado; A limpeza ainda é importante para trabalhos cancelados. O mesmoCancellationToken
que fez com que o trabalho real fosse cancelado seria normalmente o token que seria passado paraDisposeAsync
, tornandoDisposeAsync
inútil porque o cancelamento do trabalho levaria a queDisposeAsync
se tornasse um no-op. Se alguém quiser evitar ser bloqueado à espera de eliminação, pode evitar esperar naValueTask
resultante, ou esperar nela apenas por algum período de tempo. -
DisposeAsync
retornar 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 eventual conclusão assíncrona deDisposeAsync
, salvando uma alocação deTask
no caso em queDisposeAsync
seja concluída de forma assíncrona. -
Configurando
DisposeAsync
com umbool continueOnCapturedContext
(ConfigureAwait
): Embora possa haver problemas relacionados a como tal conceito é exposto ausing
,foreach
e outras construções de linguagem que o utilizam, do ponto de vista da interface, não está realmente a realizar nenhumaawait
'ing e não há nada para configurar. Os utilizadores doValueTask
poderão usá-lo conforme desejarem. -
IAsyncDisposable
herdarIDisposable
: 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 um "algo assíncrono" enquanto as operações são "assíncronas", então os tipos têm "Async" como um prefixo e os métodos têm "Async" como um sufixo.
IAsyncEnumerable / IAsyncEnumerator
Duas interfaces são adicionadas às principais bibliotecas .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 parecido com:
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 doTask<bool>
daria suporte ao uso de um objeto de tarefa armazenado em cache para representar chamadasMoveNextAsync
síncronas e bem-sucedidas, mas uma alocação ainda seria necessária para a conclusão assíncrona. Ao retornarValueTask<bool>
, permitimos que o objeto enumerador implementeIValueTaskSource<bool>
si mesmo e seja usado como suporte para oValueTask<bool>
retornado deMoveNextAsync
, o que, por sua vez, permite despesas gerais significativamente reduzidas. -
ValueTask<(bool, T)> MoveNextAsync();
: Não só é mais difícil de consumir, como significa queT
não pode mais ser covariante. -
ValueTask<T?> TryMoveNextAsync();
: Não covariante. -
Task<T?> TryMoveNextAsync();
: Não covariante, alocações em todas as chamadas, etc. -
ITask<T?> TryMoveNextAsync();
: Não covariante, alocações em todas as chamadas, etc. -
ITask<(bool,T)> TryMoveNextAsync();
: Não covariante, alocações em todas as chamadas, etc. -
Task<bool> TryMoveNextAsync(out T result);
: O resultadoout
precisaria ser definido quando a operação retornasse de forma síncrona, não quando concluísse assincronamente a tarefa potencialmente em algum momento no futuro, momento em que não haveria como comunicar o resultado. -
IAsyncEnumerator<T>
não implementandoIAsyncDisposable
: Poderíamos optar por separá-los. No entanto, isso complica algumas outras áreas da proposta, pois o código deve ser capaz de lidar com a possibilidade de um recenseador não fornecer eliminação, o que dificulta a escrita de auxiliares baseados em padrões. Além disso, será comum que os enumeradores tenham uma necessidade de eliminação (por exemplo, qualquer iterador assíncrono C# que tenha um bloco final, a maioria das coisas enumerando dados de uma conexão de rede, etc.) e, se não tiver, é 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 posteriormente invocar WaitForNextAsync
para aguardar que o próximo item esteja disponível ou para determinar que nunca haverá outro item. O consumo típico (sem recursos de idioma adicionais) seria parecido com:
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 é dupla, uma menor e outra maior:
-
Menor: Permite que um enumerador suporte múltiplos consumidores. Pode haver cenários em que seja valioso para um enumerador oferecer suporte a vários consumidores simultâneos. Isso não pode ser alcançado quando
MoveNextAsync
eCurrent
estão separados de tal forma que uma implementação não pode tornar seu uso atômico. Em contraste, esta abordagem fornece um único métodoTryGetNext
que suporta avançar o enumerador e obter o próximo item, para que o enumerador possa habilitar a atomicidade, se assim for desejado. No entanto, é provável que esses cenários também possam ser possibilitados dando a cada consumidor o seu próprio enumerador a partir de um enumerável compartilhado. Além disso, não queremos impor que todos os enumeradores suportem o uso simultâneo, pois isso adicionaria despesas gerais 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 qualquer maneira. -
Desempenho: Major. 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, existem desvantagens não triviais, incluindo um aumento significativo da complexidade ao consumi-los manualmente e uma maior chance de introduzir bugs ao usá-los. E embora os benefícios de desempenho sejam evidentes em microbenchmarks, não acreditamos que eles serão impactantes na grande maioria da utilização real. Se se verificar que são, 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) que provavelmente implica em uma barreira de escrita em tempo de execução para resultados do tipo referência.
Cancelamento
Existem várias abordagens possíveis para apoiar o cancelamento:
-
IAsyncEnumerable<T>
/IAsyncEnumerator<T>
são agnósticas em relação ao cancelamento:CancellationToken
não aparece em lugar nenhum. O cancelamento é conseguido integrando logicamente oCancellationToken
no enumerável e/ou enumerador da maneira mais 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 é feito com qualquer outro parâmetro. -
IAsyncEnumerator<T>.GetAsyncEnumerator(CancellationToken)
: Você passa umCancellationToken
paraGetAsyncEnumerator
, e as operações deMoveNextAsync
subsequentes respeitam-no da melhor forma possível. -
IAsyncEnumerator<T>.MoveNextAsync(CancellationToken)
: Você passa umCancellationToken
para cada chamada individual deMoveNextAsync
. - 1 && 2: Você incorpora
CancellationToken
s em seu enumerável/enumerador e passaCancellationToken
s paraGetAsyncEnumerator
. - 1 && 3: Você incorpora
CancellationToken
s em seu enumerável/enumerador e passaCancellationToken
s paraMoveNextAsync
.
De uma perspetiva puramente teórica, (5) é o mais robusto, na medida em que (a) a aceitação de um CancellationToken
por MoveNextAsync
permite o controlo mais refinado sobre o que é cancelado, e (b) CancellationToken
é qualquer outro tipo que pode ser passado como argumento para iteradores, embutido em tipos arbitrários, etc.
No entanto, esta abordagem levanta vários problemas:
- Como é que um
CancellationToken
passado paraGetAsyncEnumerator
entra no corpo do iterador? Poderíamos expor uma nova palavra-chave deiterator
que você poderia pontilhar para ter acesso aoCancellationToken
passado paraGetEnumerator
, mas a) isso é um monte de maquinário adicional, b) estamos tornando-o um cidadão de primeira classe, e c) o caso da 99% parece ser o mesmo código chamando um iterador e chamandoGetAsyncEnumerator
nele, nesse caso, ele pode apenas passar oCancellationToken
como um argumento para o método. - Como é que o
CancellationToken
, quando passado para oMoveNextAsync
, entra no corpo do método? Isso é ainda pior, pois se ele for exposto a partir de um objeto localiterator
, seu valor pode mudar ao longo das esperas, o que significa que qualquer código registrado com o token precisaria cancelar o registro dele antes de esperar e, em seguida, registrar novamente depois; Também é potencialmente muito caro precisar fazer esse registro e cancelamento de registro em todas asMoveNextAsync
chamadas, independentemente de ser implementado pelo compilador em um iterador ou por um desenvolvedor manualmente. - Como um desenvolvedor cancela um
foreach
loop? Se isso for feito dando umCancellationToken
a um enumerável/enumerador, então a) precisamos apoiarforeach
'ing sobre os enumeradores, o que os eleva a serem cidadãos de primeira classe, e agora você precisa começar a pensar em um ecossistema construído em torno de enumeradores (por exemplo, métodos LINQ) ou b) precisamos incorporar oCancellationToken
no enumerável de qualquer maneira, tendo algum método de extensãoWithCancellation
fora deIAsyncEnumerable<T>
que armazenaria o token fornecido e, em seguida, passá-lo para oGetAsyncEnumerator
do enumerável encapsulado quando oGetAsyncEnumerator
na struct retornada for invocado (ignorando esse token). Ou, podes apenas usar oCancellationToken
que tens no corpo do ciclo foreach. - Se/quando as compreensões de consultas forem suportadas, de que forma o
CancellationToken
fornecido aGetEnumerator
ouMoveNextAsync
seria passado para cada cláusula? A maneira mais fácil seria que a cláusula simplesmente o capturasse, ponto em que qualquer token passado paraGetAsyncEnumerator
/MoveNextAsync
seria ignorado.
Uma versão anterior deste documento recomendava (1), mas desde então mudámos para (4).
Os dois principais problemas com (1):
- Os produtores de enumeráveis canceláveis têm de implementar algum código padrão e só podem aproveitar o suporte do compilador para iteradores assíncronos para implementar um método
IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken)
. - É provável que muitos produtores fiquem tentados a simplesmente adicionar um parâmetro
CancellationToken
à sua assinatura de enumeração assíncrona, o que impedirá os consumidores de passarem o token de cancelamento que desejam quando lhes é dado um tipoIAsyncEnumerable
.
Existem dois cenários principais de consumo:
-
await foreach (var i in GetData(token)) ...
onde o consumidor chama o método async-iterator, -
await foreach (var i in givenIAsyncEnumerable.WithCancellation(token)) ...
quando o consumidor lida com um determinadoIAsyncEnumerable
exemplo.
Achamos que um compromisso razoável para suportar ambos os cenários de uma forma que seja conveniente para produtores e consumidores de fluxos assíncronos é usar um parâmetro especialmente anotado no método async-iterator. 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 deve ser usado em vez do valor originalmente passado para o parâmetro.
Considere IAsyncEnumerable<int> GetData([EnumeratorCancellation] CancellationToken token = default)
.
O implementador deste método pode simplesmente usar o parâmetro no corpo do método.
O consumidor pode utilizar os padrões de consumo acima indicados:
- 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 async-enumerable.
para cada
foreach
será ampliado para apoiar IAsyncEnumerable<T>
além do suporte existente para IEnumerable<T>
. E suportará o equivalente a IAsyncEnumerable<T>
como padrão se os membros relevantes forem expostos publicamente. Caso contrário, voltará a usar a interface diretamente, permitindo assim extensões baseadas em structs que evitem alocação, bem como utilizando awaitables 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 forma:
await foreach (var i in enumerable)
Nenhuma sintaxe seria fornecida que suportasse o uso de APIs assíncronas ou de sincronização; O desenvolvedor deve escolher com base na sintaxe usada.
Semântica
O processamento de uma instrução await foreach
em tempo de compilação determina primeiro o tipo de coleção , o tipo de enumerador e o 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). Esta determinação procede da seguinte forma:
- Se o tipo
X
da expressão fordynamic
ou um tipo de array, um erro será gerado e nenhum passo adicional será tomado. - Caso contrário, determine se o tipo
X
tem um método deGetAsyncEnumerator
apropriado:- Realizar uma pesquisa de membros no tipo
X
com o identificadorGetAsyncEnumerator
e sem argumentos de tipo. Se a pesquisa de membro não produzir uma correspondência, ou produzir uma ambiguidade, ou produzir uma correspondência que não seja um grupo de método, 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 nenhum método aplicável, resultar em uma ambiguidade ou resultar em um único melhor método, 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 um tipo de classe, struct ou interface, um erro será produzido e nenhuma outra etapa será executada. - A pesquisa de membros é realizada em
E
com o identificadorCurrent
e sem argumentos de tipo. Se a pesquisa de membro não produzir nenhuma correspondência, ou se o resultado for qualquer coisa que não seja uma propriedade de instância pública que permita a leitura, será gerado um erro e nenhuma outra etapa será executada. - A pesquisa de membros é realizada em
E
com o identificadorMoveNextAsync
e sem argumentos de tipo. Se a pesquisa de membro não produzir nenhuma correspondência, o resultado for um erro ou o resultado for qualquer coisa, exceto um grupo de métodos, um erro será produzido e nenhuma outra etapa 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 nenhum método aplicável, resultar em uma ambiguidade ou resultar em um único melhor método, mas esse método for estático ou não público, ou seu tipo de retorno não for aguardado em
bool
, um erro será produzido e nenhuma outra etapa será tomada. - O tipo de coleção é
X
, o tipo de enumerador éE
e o tipo de iteração é o tipo da propriedadeCurrent
.
- Realizar uma pesquisa de membros 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 únicoT
tal queT
não é dinâmico e para todos os outrosTᵢ
há uma conversão implícita deIAsyncEnumerable<T>
paraIAsyncEnumerable<Tᵢ>
, então o tipo de coleção é a interfaceIAsyncEnumerable<T>
, o tipo de enumerador é a interfaceIAsyncEnumerator<T>
, e o tipo de iteração éT
. - Caso contrário, se houver mais de um tipo
T
, então um erro é gerado e nenhuma outra ação é realizada.
- Se entre todos os tipos
- Caso contrário, é produzido um erro e não são tomadas mais medidas.
As etapas acima, se bem-sucedidas, produzem inequivocamente um tipo de coleção C
, tipo de enumerador E
e tipo de iteração T
.
await foreach (V v in x) «embedded_statement»
é então 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 de finally
é construído de acordo com as seguintes etapas:
- Se o tipo
E
tiver um método deDisposeAsync
adequado:- Efetue a pesquisa de membros no tipo
E
com o identificadorDisposeAsync
e sem argumentos de tipo. Se a pesquisa de 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 a interface de eliminação 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 nenhum método aplicável, resultar em uma ambiguidade ou resultar em um único melhor método, mas esse método for estático ou não público, verifique a interface de eliminação conforme descrito abaixo.
- Se o tipo de retorno do método
DisposeAsync
não for esperado, um erro será produzido e nenhuma outra etapa será executada. - A cláusula
finally
é expandida para o equivalente semântico de:
finally { await e.DisposeAsync(); }
- Efetue a pesquisa de membros no tipo
- Caso contrário, se houver uma conversão implícita de
E
para a interfaceSystem.IAsyncDisposable
, então- 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
é expandida para o equivalente semântico de:
exceto que, 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 para um tipo de valor, então a conversão dee
paraSystem.IAsyncDisposable
não deve causar o encaixotamento.
- Se
- Caso contrário, a cláusula
finally
é expandida para um bloco vazio:finally { }
ConfigureAwait
Esta compilação baseada em padrões permitirá que ConfigureAwait
seja usado em todos os awaits, por meio de um método de extensão ConfigureAwait
.
await foreach (T item in enumerable.ConfigureAwait(false))
{
...
}
Isso será baseado em tipos que adicionaremos ao .NET também, 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 permitirá que ConfigureAwait
seja usado com enumeráveis baseados em padrões, mas, novamente, já é o caso de que o ConfigureAwait
só é exposto como uma extensão em Task
/Task<T>
/ValueTask
/ValueTask<T>
e não pode ser aplicado a objetos arbitrários esperáveis, pois só faz sentido quando aplicado a tarefas (controla um comportamento no suporte de continuação de tarefas), e, portanto, não faz sentido quando se usa um padrão onde os objetos esperáveis podem não ser tarefas. Qualquer pessoa que retorne coisas aguardadas pode fornecer seu próprio comportamento personalizado em cenários avançados.
(Se pudermos encontrar alguma maneira de oferecer suporte a uma solução ConfigureAwait
a nível de escopo ou de montagem, isso não será necessário.)
Iteradores assíncronos
A linguagem/compilador não só suportará a produção de IAsyncEnumerable<T>
s e IAsyncEnumerator<T>
s, como também o seu consumo. Hoje a linguagem suporta escrever 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. Acrescentaremos esse apoio.
Sintaxe
O suporte de linguagem existente para iteradores infere a natureza iteradora do método com base em se ele contém algum yield
s. O mesmo será verdade para iteradores assíncronos. Esses iteradores assíncronos serão demarcados e diferenciados dos iteradores síncronos por meio da adição de async
à assinatura, e 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 ele o usa para determinar seawait
é válido nesse contexto. Mas mesmo que não seja obrigatório, estabelecemos queawait
só podem ser usados em métodos marcados comoasync
, e parece importante manter a consistência. -
Ativação de construtores personalizados para
IAsyncEnumerable<T>
: Isso é algo que poderíamos considerar para o futuro, mas a maquinaria é complexa e não damos suporte a isso para as versões síncronas. -
Ter uma palavra-chave
iterator
na assinatura: Os iteradores assíncronos utilizariamasync iterator
na assinatura, eyield
só poderia ser utilizado em métodosasync
que incluíssemiterator
;iterator
seria então opcional nos iteradores síncronos. Dependendo da sua perspetiva, 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 de o compilador produzir uma, dependendo de o código usaryield
ou não. Mas é diferente dos iteradores síncronos, que não exigem nem podem 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
Existem mais de ~200 sobrecargas de métodos na classe System.Linq.Enumerable
, todos os quais funcionam em termos de IEnumerable<T>
; Alguns deles aceitam IEnumerable<T>
, alguns deles produzem IEnumerable<T>
, e muitos fazem as duas coisas. Adicionar suporte LINQ para IAsyncEnumerable<T>
provavelmente implicaria duplicar todas essas sobrecargas para ele, por mais ~200. E como é provável que IAsyncEnumerator<T>
seja mais comum como uma entidade independente no mundo assíncrono do que IEnumerator<T>
no mundo síncrono, podemos potencialmente precisar de outras ~200 sobrecargas que funcionam com IAsyncEnumerator<T>
. Além disso, muitas sobrecargas relacionam-se com predicados (por exemplo, Where
que leva um Func<T, bool>
) e pode ser desejável ter sobrecargas baseadas em IAsyncEnumerable<T>
que abordem tanto predicados síncronos como assíncronos (por exemplo, Func<T, ValueTask<bool>>
além de Func<T, bool>
). Embora isso não seja aplicável a todas as agora ~400 novas sobrecargas, um cálculo aproximado é que seria aplicável à metade, o que significa outras ~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ões como Interactive Extensions (Ix) são consideradas. Mas Ix já tem uma implementação de muitos deles, e não parece haver uma grande razão para duplicar esse trabalho; devemos, em vez disso, 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 consultas. A natureza baseada em padrões das compreensões de consulta permitiria que eles "apenas trabalhassem" com alguns operadores, por exemplo, se Ix fornecer 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);
então este código C# irá "funcionar perfeitamente"
IAsyncEnumerable<int> enumerable = ...;
IAsyncEnumerable<int> result = from item in enumerable
where item % 2 == 0
select item * 2;
No entanto, não há uma sintaxe de compreensão de consultas que suporte o uso de await
nas cláusulas, portanto, se Ix for adicionado, por exemplo:
public static IAsyncEnumerable<TResult> Select<TSource, TResult>(this IAsyncEnumerable<TSource> source, Func<TSource, ValueTask<TResult>> func);
então isto 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 adição de expressões async { ... }
à língua, permitindo que sejam utilizadas em compreensões de consulta, e o exemplo anterior poderia ser escrito como:
IAsyncEnumerable<int> result = from item in enumerable
where item % 2 == 0
select async
{
await Task.Yield();
return item * 2;
};
ou para permitir que await
sejam usados diretamente em expressões, por exemplo, apoiando 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 outros frameworks assíncronos (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 para um IObserver<T>
simplesmente executando await foreach
sobre o enumerador e executando OnNext
os dados para o observador, tornando possível um método de extensão AsObservable<T>
. Consumir um IObservable<T>
em um await foreach
requer o armazenamento em buffer dos dados (no caso de outro item ser enviado por push enquanto o item anterior ainda está sendo processado), mas esse adaptador push-pull pode ser facilmente implementado para permitir que um IObservable<T>
seja extraído com um IAsyncEnumerator<T>
. Etc. Rx/Ix já fornecem protótipos de tais 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. A língua não precisa de ser envolvida nesta fase.
C# feature specifications