Partilhar via


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.

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 awaits, 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 IAsyncDisposablee, 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 um CancellationToken: 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 mesmo CancellationToken que fez com que o trabalho real fosse cancelado seria normalmente o token que seria passado para DisposeAsync, tornando DisposeAsync inútil porque o cancelamento do trabalho levaria a que DisposeAsync se tornasse um no-op. Se alguém quiser evitar ser bloqueado à espera de eliminação, pode evitar esperar na ValueTaskresultante, ou esperar nela apenas por algum período de tempo.
  • DisposeAsync retornar 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 eventual conclusão assíncrona de DisposeAsync, salvando uma alocação de Task no caso em que DisposeAsync seja concluída de forma assíncrona.
  • Configurando DisposeAsync com um bool continueOnCapturedContext (ConfigureAwait): Embora possa haver problemas relacionados a como tal conceito é exposto a using, foreache outras construções de linguagem que o utilizam, do ponto de vista da interface, não está realmente a realizar nenhuma await'ing e não há nada para configurar. Os utilizadores do ValueTask poderão usá-lo conforme desejarem.
  • IAsyncDisposable herdar 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 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 do Task<bool> daria suporte ao uso de um objeto de tarefa armazenado em cache para representar chamadas MoveNextAsync síncronas e bem-sucedidas, mas uma alocação ainda seria necessária para a conclusão assíncrona. Ao retornar ValueTask<bool>, permitimos que o objeto enumerador implemente IValueTaskSource<bool> si mesmo e seja usado como suporte para o ValueTask<bool> retornado de MoveNextAsync, o que, por sua vez, permite despesas gerais significativamente reduzidas.
  • ValueTask<(bool, T)> MoveNextAsync();: Não só é mais difícil de consumir, como significa que T 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 resultado out 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 implementando IAsyncDisposable: 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 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 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 e Current 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étodo TryGetNext 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 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, 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:

  1. 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 o CancellationToken no enumerável e/ou enumerador da maneira mais 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 é feito com qualquer outro parâmetro.
  2. IAsyncEnumerator<T>.GetAsyncEnumerator(CancellationToken): Você passa um CancellationToken para GetAsyncEnumerator, e as operações de MoveNextAsync subsequentes respeitam-no da melhor forma possível.
  3. IAsyncEnumerator<T>.MoveNextAsync(CancellationToken): Você passa um CancellationToken para cada chamada individual de MoveNextAsync.
  4. 1 && 2: Você incorpora CancellationTokens em seu enumerável/enumerador e passa CancellationTokens para GetAsyncEnumerator.
  5. 1 && 3: Você incorpora CancellationTokens em seu enumerável/enumerador e passa CancellationTokens para MoveNextAsync.

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 para GetAsyncEnumerator entra no corpo do iterador? Poderíamos expor uma nova palavra-chave de iterator que você poderia pontilhar para ter acesso ao CancellationToken passado para GetEnumerator, 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 chamando GetAsyncEnumerator nele, nesse caso, ele pode apenas passar o CancellationToken como um argumento para o método.
  • Como é que o CancellationToken, quando passado para o MoveNextAsync, entra no corpo do método? Isso é ainda pior, pois se ele for exposto a partir de um objeto local iterator, 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 as MoveNextAsync 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 um CancellationToken a um enumerável/enumerador, então a) precisamos apoiar foreach'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 o CancellationToken no enumerável de qualquer maneira, tendo algum método de extensão WithCancellation fora de IAsyncEnumerable<T> que armazenaria o token fornecido e, em seguida, passá-lo para o GetAsyncEnumerator do enumerável encapsulado quando o GetAsyncEnumerator na struct retornada for invocado (ignorando esse token). Ou, podes apenas usar o CancellationToken que tens no corpo do ciclo foreach.
  • Se/quando as compreensões de consultas forem suportadas, de que forma o CancellationToken fornecido a GetEnumerator ou MoveNextAsync 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 para GetAsyncEnumerator/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 tipo IAsyncEnumerable.

Existem dois cenários principais de consumo:

  1. await foreach (var i in GetData(token)) ... onde o consumidor chama o método async-iterator,
  2. await foreach (var i in givenIAsyncEnumerable.WithCancellation(token)) ... quando o consumidor lida com um determinado IAsyncEnumerable 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:

  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 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 for dynamic 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 de GetAsyncEnumerator apropriado:
    • Realizar uma pesquisa de membros no tipo X com o identificador GetAsyncEnumerator 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étodo GetAsyncEnumerator 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 identificador Current 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 identificador MoveNextAsync 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 é 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 único T tal que T não é 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 é a interface IAsyncEnumerable<T>, o tipo de enumerador é a interface IAsyncEnumerator<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.
  • 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 de DisposeAsync adequado:
    • Efetue a pesquisa de membros no tipo E com o identificador DisposeAsync 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();
      }
    
  • Caso contrário, se houver uma conversão implícita de E para a interface System.IAsyncDisposable, então
    • 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 é expandida para o equivalente semântico de:
      finally {
          System.IAsyncDisposable d = e as System.IAsyncDisposable;
          if (d != null) await d.DisposeAsync();
      }
      
      exceto que, se E for um tipo de valor, ou um parâmetro de tipo instanciado para um tipo de valor, então a conversão de e para System.IAsyncDisposable não deve causar o encaixotamento.
  • 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 yields. 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: Usar async é provavelmente tecnicamente exigido pelo compilador, pois ele o usa para determinar se await é válido nesse contexto. Mas mesmo que não seja obrigatório, estabelecemos que await só podem ser usados em métodos marcados como async, 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 utilizariam async iterator na assinatura, e yield só poderia ser utilizado em métodos async que incluíssem iterator; 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, se yield é permitido e se o método é realmente destinado a retornar instâncias do tipo IAsyncEnumerable<T>, em vez de o compilador produzir uma, dependendo de o código usar yield 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 foreachsobre o enumerador e executando OnNextos 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.