Partilhar via


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.

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 um CancellationToken: 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 mesma CancellationToken que causou o cancelamento do trabalho em questão normalmente seria o mesmo token passado para DisposeAsync, tornando DisposeAsync inútil porque o cancelamento do trabalho faria DisposeAsync se tornar um no-op. Se alguém quiser evitar ser bloqueado aguardando o descarte, poderá evitar aguardar o ValueTask resultante ou aguardar apenas por algum período de tempo.
  • DisposeAsync retornando um Task: agora que um ValueTask não genérico existe e pode ser construído a partir de um IValueTaskSource, retornar ValueTask de DisposeAsync permite que um objeto existente seja reutilizado como a promessa que representa a conclusão assíncrona eventual de DisposeAsync, salvando uma alocação de Task no caso em que DisposeAsync é concluída de forma assíncrona.
  • Configurando DisposeAsync com um bool continueOnCapturedContext (ConfigureAwait): embora possa haver problemas relacionados à forma como esse conceito é exposto a using, foreache outros constructos de linguagem que o consomem, de uma perspectiva de interface, não se realiza realmente nenhum awaite não há nada para configurar... os consumidores do ValueTask podem consumir como preferirem.
  • IAsyncDisposable herdando IDisposable: como apenas um ou outro deve ser usado, não faz sentido forçar os tipos a implementar ambos.
  • IDisposableAsync em vez de IAsyncDisposable: 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 de Task<bool> seria compatível com o uso de um objeto de tarefa armazenado em cache para representar chamadas de MoveNextAsync síncronas e bem-sucedidas, mas uma alocação ainda seria necessária para conclusão assíncrona. Retornando ValueTask<bool>, permitimos que o objeto enumerador implemente por si mesmo IValueTaskSource<bool> e seja usado como base para o ValueTask<bool> retornado de MoveNextAsync, o que permite, por sua vez, reduzir significativamente as sobrecargas.
  • ValueTask<(bool, T)> MoveNextAsync();: Não é apenas mais difícil de consumir, mas significa que T 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 do out 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 implementando IAsyncDisposable: 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 como public 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 e Current 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étodo TryGetNext 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 para WaitForNextAsync/TryGetNext é que a maioria das iterações é concluída de forma síncrona, permitindo um loop interno apertado com TryGetNext, 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:

  1. IAsyncEnumerable<T> / IAsyncEnumerator<T> são independentes de cancelamento: CancellationToken não aparece em lugar nenhum. O cancelamento é alcançado ao integrar logicamente o CancellationToken no enumerável e/ou no enumerador da maneira que for apropriada, por exemplo, ao chamar um iterador, passando o CancellationToken como um argumento para o método do iterador e usando-o no corpo do iterador, como se faz com qualquer outro parâmetro.
  2. IAsyncEnumerator<T>.GetAsyncEnumerator(CancellationToken): você passa um CancellationToken para GetAsyncEnumerator e as operações de MoveNextAsync subsequentes o respeitam na medida do possível.
  3. IAsyncEnumerator<T>.MoveNextAsync(CancellationToken): você passa uma CancellationToken para cada chamada MoveNextAsync individual.
  4. 1 && 2: você pode incorporar CancellationTokens em seu enumerável/enumerador e passar CancellationTokens para GetAsyncEnumerator.
  5. 1 && 3: você pode incorporar CancellationTokens em seu enumerável/enumerador e passar CancellationTokens para MoveNextAsync.

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 para GetAsyncEnumerator chega ao corpo do iterador? Poderíamos expor uma nova palavra-chave iterator, da qual você poderia derivar para obter acesso ao CancellationToken passado para GetEnumerator, 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 chama GetAsyncEnumerator sobre ele, podendo, nesse caso, simplesmente passar o CancellationToken como argumento para o método.
  • Como um CancellationToken passado para MoveNextAsync entra dentro do corpo do método? Isso é ainda pior, como se ele fosse exposto de um objeto local iterator, 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 chamada MoveNextAsync, 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 um CancellationToken a um enumerável/enumerador, nesse caso, a) precisamos dar suporte a foreach'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 o CancellationToken no enumerável de qualquer maneira, tendo algum método de extensão WithCancellation do IAsyncEnumerable<T> que armazenaria o token fornecido e, em seguida, passe-o para o GetAsyncEnumerator do enumerável encapsulado quando o GetAsyncEnumerator no struct retornado é invocado (ignorando esse token). Ou você pode usar apenas o CancellationToken que você tem no corpo do foreach.
  • Se/quando houver suporte para compreensões de consulta, como o CancellationToken fornecido para GetEnumerator ou MoveNextAsync 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 para GetAsyncEnumerator/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 tipo IAsyncEnumerable.

Há dois cenários principais de consumo:

  1. await foreach (var i in GetData(token)) ... onde o consumidor chama o método de iterador assíncrono,
  2. await foreach (var i in givenIAsyncEnumerable.WithCancellation(token)) ... onde o consumidor lida com uma instância determinada de IAsyncEnumerable.

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:

  1. Se você usar GetData(token), o token será salvo no "async-enumerable" e será usado na iteração.
  2. se você usar givenIAsyncEnumerable.WithCancellation(token), o token passado para GetAsyncEnumerator 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 for dynamic 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 de GetAsyncEnumerator apropriado:
    • Realize a pesquisa de membro no tipo X com o identificador GetAsyncEnumerator 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étodo GetAsyncEnumerator 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 identificador Current 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 identificador MoveNextAsync 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 é Ee o tipo de iteração é o tipo da propriedade Current.
  • 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 de X para IAsyncEnumerable<ᵢ>, há um tipo exclusivo T de modo que T não seja dinâmico e para todos os outros Tᵢ há uma conversão implícita de IAsyncEnumerable<T> para IAsyncEnumerable<Tᵢ>, então o tipo de coleção é o IAsyncEnumerable<T>de interface, o tipo de enumerador é a interface IAsyncEnumerator<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.
  • 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 de DisposeAsync apropriado:
    • Realize uma pesquisa de membros no tipo E com o identificador DisposeAsync 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();
      }
    
  • Caso contrário, se houver uma conversão implícita de E para a interface System.IAsyncDisposable,
    • Se E for um tipo de valor não anulável, a cláusula finally 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:
      finally {
          System.IAsyncDisposable d = e as System.IAsyncDisposable;
          if (d != null) await d.DisposeAsync();
      }
      
      exceto se E for um tipo de valor ou um parâmetro de tipo instanciado em um tipo de valor, a conversão de e para System.IAsyncDisposable não fará com que o boxing ocorra.
  • 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 yields. 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: usar async provavelmente é tecnicamente exigido pelo compilador, pois o usa para determinar se await é válido nesse contexto. Mas mesmo que não seja necessário, estabelecemos que await só podem ser usados em métodos marcados como async, 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 usariam async iterator na assinatura, e yield só poderia ser utilizado em métodos async que incluíssem iterator; 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 se yield é permitido e se o método é realmente destinado a retornar instâncias do tipo IAsyncEnumerable<T> em vez do compilador fabricando um com base em se o código usa yield 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 foreachsobre o enumerador e ao OnNextos 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.