Esta tradução surgiu graças ao bom comentário 0x1000000 .

O .NET Framework 4 introduziu o espaço System.Threading.Tasks e, com ele, a classe Task. Esse tipo e a tarefa <TResult> gerada aguardam muito tempo até serem reconhecidos pelos padrões no .NET como os principais aspectos do modelo de programação assíncrona que foi introduzido no C # 5 com suas instruções async / waitit. Neste artigo, falarei sobre novos tipos de ValueTask / ValueTask <TResult>, projetados para melhorar o desempenho de métodos assíncronos nos casos em que a sobrecarga da alocação de memória deve ser levada em consideração.
Tarefa
A tarefa atua em diferentes funções, mas a principal é a “promessa” (promessa), um objeto que representa a possível conclusão de alguma operação. Você inicia uma operação e obtém um objeto Task, que será executado quando a operação for concluída, o que pode ocorrer no modo síncrono como parte da inicialização da operação (por exemplo, recebendo dados que já estão no buffer), no modo assíncrono com execução no momento em que você obtém a tarefa (recebendo dados não do buffer, mas muito rapidamente) ou no modo assíncrono, mas depois de já ter a tarefa (recebendo dados de um recurso remoto). Como a operação pode terminar de forma assíncrona, você bloqueia o fluxo de execução, aguarda o resultado (o que geralmente torna a assincronia da chamada sem sentido) ou cria uma função de retorno de chamada que será ativada após a conclusão da operação. No .Net 4, a criação de um retorno de chamada é implementada pelos métodos ContinueWith do objeto Task, que demonstram explicitamente esse modelo ao aceitar uma função delegada para executá-lo após a execução da tarefa:
SomeOperationAsync().ContinueWith(task => { try { TResult result = task.Result; UseResult(result); } catch (Exception e) { HandleException(e); } });
Mas, no .NET Framework 4.5 e C # 5, os objetos Task podem ser simplesmente chamados pelo operador wait, o que facilita o resultado de uma operação assíncrona, e o código gerado otimizado para as opções acima funcionará corretamente em todos os casos quando a operação for concluída no modo síncrono, rápido assíncrono ou assíncrono ao fazer callbacka:
TResult result = await SomeOperationAsync(); UseResult(result);
A tarefa é uma classe muito flexível e possui várias vantagens. Por exemplo, você pode esperar várias vezes para qualquer número de consumidores de uma só vez. Você pode colocá-lo em uma coleção (dicionário) para aguardar repetidamente no futuro, para usá-lo como um cache dos resultados de chamadas assíncronas. Você pode bloquear a execução enquanto aguarda a conclusão da tarefa, se necessário. E você pode escrever e aplicar várias operações nos objetos Task (às vezes chamados de "combinadores"), por exemplo, "quando houver" por aguardar de forma assíncrona a primeira conclusão de várias tarefas.
Mas essa flexibilidade se torna supérflua no caso mais comum: basta chamar a operação assíncrona e aguardar a conclusão da tarefa:
TResult result = await SomeOperationAsync(); UseResult(result);
Aqui não precisamos aguardar a execução várias vezes. Não precisamos garantir que as expectativas sejam competitivas. Não precisamos executar o bloqueio síncrono. Não escreveremos combinadores. Estamos apenas aguardando a promessa de uma operação assíncrona ser concluída. No final, é assim que escrevemos o código síncrono (por exemplo, TResult result = SomeOperation ();), e normalmente é traduzido para async / waitit.
Além disso, o Task possui uma fraqueza potencial, especialmente quando um grande número de instâncias é criado, e alto rendimento e desempenho são os principais requisitos - o Task é uma classe. Isso significa que qualquer operação que necessite de uma tarefa é forçada a criar e colocar um objeto, e quanto mais objetos são criados, mais trabalho para o coletor de lixo (GC) e esse trabalho consome recursos que poderíamos gastar em algo mais útil.
O tempo de execução e as bibliotecas do sistema ajudam a atenuar esse problema em muitas situações. Por exemplo, se escrevermos um método como este:
public async Task WriteAsync(byte value) { if (_bufferedCount == _buffer.Length) { await FlushAsync(); } _buffer[_bufferedCount++] = value; }
como regra, haverá espaço livre suficiente no buffer e a operação será executada de forma síncrona. Quando isso acontece, não há necessidade de fazer nada com a tarefa, que deve ser retornada, já que não há valor de retorno, isso está usando a tarefa como o equivalente a um método síncrono que retorna um valor vazio (vazio). Portanto, o ambiente pode simplesmente armazenar em cache uma tarefa não genérica e usá-la repetidamente como resultado da execução de qualquer método assíncrono que termina de forma síncrona (esse singleton em cache pode ser obtido via Task.CompletedTask). Ou, por exemplo, você escreve:
public async Task<bool> MoveNextAsync() { if (_bufferedCount == 0) { await FillBuffer(); } return _bufferedCount > 0; }
e, em geral, espere que os dados já estejam no buffer, portanto, o método simplesmente verifica o valor de _bufferedCount, vê que é maior que 0 e retorna verdadeiro; e somente se ainda não houver dados no buffer, você precisará executar uma operação assíncrona. E como existem apenas dois resultados possíveis do tipo Booleano (verdadeiro e falso), existem apenas dois objetos de Tarefas possíveis necessários para representar esses resultados, o ambiente pode armazenar em cache esses objetos e retorná-los com o valor correspondente sem alocar memória. Somente no caso de conclusão assíncrona, o método precisará criar uma nova tarefa, pois precisará ser retornada antes que o resultado da operação seja conhecido.
O ambiente fornece armazenamento em cache para alguns outros tipos, mas não é realista armazenar em cache todos os tipos possíveis. Por exemplo, o seguinte método:
public async Task<int> ReadNextByteAsync() { if (_bufferedCount == 0) { await FillBuffer(); } if (_bufferedCount == 0) { return -1; } _bufferedCount--; return _buffer[_position++]; }
também será frequentemente executado de forma síncrona. Mas, diferentemente de uma variante com resultado do tipo Boolean, esse método retorna Int32, que possui cerca de 4 bilhões de valores, e o armazenamento em cache de todas as variantes da tarefa <int> exigirá centenas de gigabytes de memória. O ambiente fornece um cache pequeno para a tarefa <int>, mas um conjunto muito limitado de valores, por exemplo, se esse método terminar de forma síncrona (os dados já estão no buffer) com o valor de retorno 4, será uma tarefa em cache, mas se o valor 42 for retornado, será necessário criar um novo. Tarefa <int>, semelhante a chamar Task.FromResult (42).
Muitos métodos de biblioteca tentam suavizar isso fornecendo seu próprio cache. Por exemplo, uma sobrecarga no .NET Framework 4.5 do método MemoryStream.ReadAsync sempre termina de forma síncrona, pois ele lê dados da memória. ReadAsync retorna uma tarefa <int>, onde um resultado Int32 indica quantos bytes foram lidos. Esse método é frequentemente usado em um loop, geralmente com o mesmo número necessário de bytes para cada chamada e, geralmente, essa necessidade é atendida na íntegra. Portanto, para chamadas repetidas para o ReadAsync, é razoável esperar que a tarefa <int> retorne de forma síncrona com o mesmo valor da chamada anterior. Portanto, um MemoryStream cria um cache para um objeto que retornou na última chamada bem-sucedida. E na próxima chamada, se o resultado for repetido, ele retornará o objeto em cache e, se não, criará um novo com Task.FromResult, salve-o no cache e retorne-o.
No entanto, existem muitos outros casos em que a operação é executada de forma síncrona, mas o objeto Task <TResult> é forçado a ser criado.
ValueTask <TResult> e execução síncrona
Tudo isso exigiu a implementação de um novo tipo no .NET Core 2.0, disponível nas versões anteriores do .NET no pacote NuGet System.Threading.Tasks.Extensions: ValueTask <TResult>.
O ValueTask <TResult> foi criado no .NET Core 2.0 como uma estrutura capaz de agrupar TResult e Task <TResult>. Isso significa que ele pode ser retornado do método assíncrono e, se esse método for executado de forma síncrona e bem-sucedida, você não precisará colocar nenhum objeto no heap: basta inicializar essa estrutura ValueTask <TResult> com o valor TResult e retorná-lo. Somente no caso de execução assíncrona, o objeto Task <TResult> será colocado e o ValueTask <TResult> o envolverá (para minimizar o tamanho da estrutura e otimizar o caso de execução bem-sucedida, o método assíncrono, que termina com uma exceção não suportada, também colocará a tarefa <TResult>. O ValueTask <TResult> também apenas envolve a tarefa <TResult> e não carrega consigo um campo adicional para armazenar Exceção).
Com base nisso, um método como MemoryStream.ReadAsync, mas retornando um ValueTask <int>, não deve lidar com o cache, mas pode ser escrito assim:
public override ValueTask<int> ReadAsync(byte[] buffer, int offset, int count) { try { int bytesRead = Read(buffer, offset, count); return new ValueTask<int>(bytesRead); } catch (Exception e) { return new ValueTask<int>(Task.FromException<int>(e)); } }
ValueTask <TResult> e execução assíncrona
A capacidade de escrever um método assíncrono que pode ser concluído de forma síncrona sem a necessidade de posicionamento adicional para o resultado é uma grande vitória. É por isso que o ValueTask <TResult> foi adicionado no .NET Core 2.0, e novos métodos que provavelmente serão usados em aplicativos que exigem desempenho agora são anunciados com o retorno do ValueTask <TResult> em vez da Tarefa <TResult>. Por exemplo, quando adicionamos uma nova sobrecarga ReadAsync da classe Stream ao .NET Core 2.1, para poder passar a Memória em vez do byte [], retornamos o tipo ValueTask <int>. Nesse formulário, os objetos Stream (nos quais o método ReadAsync é frequentemente executado de forma síncrona, como no exemplo anterior do MemoryStream) podem ser usados com muito menos alocação de memória.
No entanto, quando trabalhamos com serviços com largura de banda muito alta, ainda queremos evitar a alocação de memória o máximo possível, o que significa reduzir e eliminar a alocação de memória ao longo da rota de execução assíncrona.
No modelo de espera, para qualquer operação que seja concluída de forma assíncrona, precisamos da capacidade de retornar um objeto que represente a possível conclusão da operação: o chamador precisa redirecionar o retorno de chamada que será iniciado no final da operação, e isso requer um objeto exclusivo no heap, que pode servir como canal de transmissão para esta operação em particular. Isso, ao mesmo tempo, não significa nada se esse objeto será reutilizado após a conclusão da operação. Se esse objeto puder ser reutilizado, a API poderá organizar um cache para um ou mais desses objetos e usá-lo para operações sequenciais, no sentido de não usar o mesmo objeto para várias operações assíncronas intermediárias, mas usá-lo para acesso não competitivo.
No .NET Core 2.1, a classe ValueTask <TResult> foi aprimorada para oferecer suporte a pool e reutilização semelhantes. Em vez de apenas agrupar TResult ou Task <TResult>, uma classe revisada pode agrupar uma nova interface IValueTaskSource <TResult>. Essa interface fornece a funcionalidade básica necessária para acompanhar uma operação assíncrona com um objeto ValueTask <TResult> da mesma maneira que a Tarefa <TResult>:
public interface IValueTaskSource<out TResult> { ValueTaskSourceStatus GetStatus(short token); void OnCompleted(Action<object> continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags); TResult GetResult(short token); }
O método GetStatus é usado para implementar propriedades como ValueTask <TResult> .IsCompleted, que retorna informações se uma operação assíncrona é executada ou concluída e como é concluída (bem-sucedida ou não). O método OnCompleted é usado pelo objeto em espera para anexar um retorno de chamada para continuar a execução a partir do ponto de espera quando a operação for concluída. E o método GetResult é necessário para obter o resultado da operação; portanto, após a conclusão da operação, o chamador pode obter o objeto TResult ou passar qualquer exceção lançada.
A maioria dos desenvolvedores não precisa dessa interface: os métodos simplesmente retornam um objeto ValueTask <TResult>, que pode ser criado como um wrapper para um objeto que implementa essa interface, e o método de chamada permanecerá no escuro. Essa interface é para desenvolvedores que precisam evitar a alocação de memória ao usar uma API de desempenho crítico.
Existem vários exemplos dessa API no .NET Core 2.1. Os métodos mais famosos são Socket.ReceiveAsync e Socket.SendAsync com novas sobrecargas adicionadas no 2.1, por exemplo
public ValueTask<int> ReceiveAsync(Memory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken = default);
Essa sobrecarga retorna uma ValueTask <int>. Se a operação for concluída de forma síncrona, ela pode simplesmente retornar uma ValueTask <int> com o valor correspondente:
int result = …; return new ValueTask<int>(result);
Quando finalizado de forma assíncrona, ele pode usar um objeto do pool que implementa a interface:
IValueTaskSource<int> vts = …; return new ValueTask<int>(vts);
A implementação do soquete suporta um desses objetos no pool para recepção e outro para transmissão, pois não pode haver mais de um objeto para cada direção aguardando para ser executado ao mesmo tempo. Essas sobrecargas não alocam memória, mesmo no caso de uma operação assíncrona. Esse comportamento é mais aparente na classe NetworkStream.
Por exemplo, no .NET Core 2.1 Stream fornece:
public virtual ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken);
que é redefinido no NetworkStream. O método NetworkStream.ReadAsync simplesmente usa o método Socket.ReceiveAsync, para que os ganhos no Socket sejam transmitidos para o NetworkStream, e o NetworkStream.ReadAsync também não aloca memória.
ValueTask não compartilhado
Quando ValueTask <TResult> apareceu no .NET Core 2.0, somente o caso de execução síncrona foi otimizado para excluir o posicionamento do objeto Task <TResult> se o valor TResult já estiver pronto. Isso significava que a classe ValueTask não genérica não era necessária: para o caso de execução síncrona, o singleton Task.CompletedTask poderia simplesmente ser retornado do método, e isso foi feito pelo ambiente implicitamente nos métodos assíncronos que retornam Task.
No entanto, com a obtenção de operações assíncronas sem alocar memória, o uso do ValueTask não compartilhado voltou a ser relevante. No .NET Core 2.1, introduzimos os genéricos ValueTask e IValueTaskSource. Eles fornecem equivalentes diretos para versões genéricas, para uso semelhante, com apenas um valor de retorno vazio.
Implementar IValueTaskSource / IValueTaskSource <T>
A maioria dos desenvolvedores não deve implementar essas interfaces. Além disso, não é tão fácil. Se você decidir fazer isso, várias implementações no .NET Core 2.1 podem servir como ponto de partida, por exemplo:
- AwaitableSocketAsyncEventArgs
- AsyncOperation <TResult>
- DefaultPipeReader
Para facilitar isso, no .NET Core 3.0, planejamos apresentar toda a lógica necessária incluída no tipo ManualResetValueTaskSourceCore <TResult>, uma estrutura que pode ser incorporada em outro objeto que implementa IValueTaskSource <TResult> e / ou IValueTaskSource, para que possa ser delegada a Essa estrutura é a maior parte da funcionalidade. Você pode aprender mais sobre isso em https://github.com/dotnet/corefx/issues/32664 no repositório dotnet / corefx.
Padrões de aplicativos ValueTasks
À primeira vista, o escopo de ValueTask e ValueTask <TResult> é muito mais limitado que Task e Task <TResult>. Isso é bom e até esperado, já que a principal maneira de usá-los é simplesmente usar o operador wait.
No entanto, como eles podem quebrar objetos que são reutilizados, há restrições significativas em seu uso em comparação com a Tarefa e a Tarefa <TResult>, se você se afastar da maneira usual de espera simples. Em casos gerais, as seguintes operações nunca devem ser executadas com ValueTask / ValueTask <TResult>:
- Espera repetida ValueTask / ValueTask <TResult> O objeto de resultado já pode ser descartado e usado em outra operação. Por outro lado, a Tarefa / Tarefa <TResult> nunca faz a transição de um estado concluído para um incompleto; portanto, você pode esperar novamente quantas vezes forem necessárias e obter o mesmo resultado todas as vezes.
- Espera paralela ValueTask / ValueTask <TResult> O objeto de resultado espera processar com apenas um retorno de chamada de um consumidor por vez, e tentar esperar de diferentes fluxos ao mesmo tempo pode facilmente levar a corridas e erros sutis do programa. Além disso, também é um caso mais específico da operação anterior de “re-espera” inválida. Em comparação, a Tarefa / Tarefa <TResult> fornece qualquer número de espera paralela.
- Usando .GetAwaiter (). GetResult () quando a operação ainda não foi concluída. A implementação de IValueTaskSource / IValueTaskSource <TResult> não precisa de suporte de bloqueio até que a operação seja concluída e, provavelmente, não o fará, portanto, essa operação definitivamente levará a corridas e provavelmente não será executado como o método de chamada espera. Tarefa / Tarefa <TResult> bloqueia o encadeamento de chamada até que a tarefa seja concluída.
Se você recebeu um ValueTask ou ValueTask <TResult>, mas precisa executar uma dessas três operações, pode usar .AsTask (), obter Task / Task <TResult> e trabalhar com o objeto recebido. Depois disso, você não poderá mais usar esse ValueTask / ValueTask <TResult>.
Em resumo, a regra é a seguinte: ao usar ValueTask / ValueTask <TResult>, você deve aguardá-lo diretamente (possivelmente com .ConfigureAwait (false)) ou chamar AsTask () e não usá-lo mais:
// , ValueTask<int> public ValueTask<int\> SomeValueTaskReturningMethodAsync(); ... // GOOD int result = await SomeValueTaskReturningMethodAsync(); // GOOD int result = await SomeValueTaskReturningMethodAsync().ConfigureAwait(false); // GOOD Task<int> t = SomeValueTaskReturningMethodAsync().AsTask(); // WARNING ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); // , // // BAD: await ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); int result = await vt; int result2 = await vt; // BAD: await ( ) ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); Task.Run(async () => await vt); Task.Run(async () => await vt); // BAD: GetAwaiter().GetResult(), ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); int result = vt.GetAwaiter().GetResult();
Espero que exista um padrão mais avançado que os programadores possam aplicar somente após medições cuidadosas e obtenção de vantagens significativas. As classes ValueTask / ValueTask <TResult> têm várias propriedades que relatam o estado atual da operação, por exemplo, a propriedade IsCompleted retornará true se a operação for concluída (ou seja, ela não será mais executada e concluída com êxito ou sem êxito) e a propriedade IsCompletedSuccessfully retornará true, apenas se for concluído com êxito (enquanto aguarda e recebe o resultado, nenhuma exceção foi lançada). Para os segmentos de execução mais exigentes, nos quais o desenvolvedor deseja evitar os custos que surgem no modo assíncrono, essas propriedades podem ser verificadas antes de uma operação que realmente destrói o objeto ValueTask / ValueTask <TResult>, por exemplo, aguarde, .AsTask (). Por exemplo, na implementação do SocketsHttpHandler no .NET Core 2.1, o código lê da conexão e recebe uma ValueTask <int>. Se esta operação for realizada de forma síncrona, não precisamos nos preocupar com o encerramento antecipado da operação. Mas, se for executado de forma assíncrona, precisamos conectar o processamento de interrupção para que a solicitação de interrupção interrompa a conexão. Como esse é um trecho de código muito estressante, se o perfil mostrar a necessidade das seguintes pequenas alterações, ele poderá ser estruturado da seguinte maneira:
int bytesRead; { ValueTask<int> readTask = _connection.ReadAsync(buffer); if (readTask.IsCompletedSuccessfully) { bytesRead = readTask.Result; } else { using (_connection.RegisterCancellation()) { bytesRead = await readTask; } } }
Todo novo método de API assíncrono deve retornar um ValueTask / ValueTask <TResult>?
Para responder brevemente: não, por padrão ainda vale a pena escolher Tarefa / Tarefa <TResult>.
Conforme destacado acima, a Tarefa e a Tarefa <TResult> são mais fáceis de usar corretamente do que o ValueTask e o ValueTask <TResult>, e desde que os requisitos de desempenho não superem os requisitos de praticidade, a Tarefa e a Tarefa <TResult> são preferidas. Além disso, existem pequenos custos associados ao retorno de uma ValueTask <TResult> em vez de uma tarefa <TResult>, ou seja, os micro-benchmarks mostram que aguardar a tarefa <TResult> é mais rápido do que esperar ValueTask <TResult>. Portanto, se você usar o cache de tarefas, por exemplo, seu método retornará Tarefa ou Tarefa, para desempenho, vale a pena ficar com Tarefa ou Tarefa. Os objetos ValueTask / ValueTask <TResult> ocupam várias palavras na memória; portanto, quando são esperados e seus campos são reservados na máquina de estado que chama o método assíncrono, eles ocupam mais memória.
- ValueTask/ValueTask<TResult> : ) , await, ) , ) , . , / .
ValueTask ValueTask<TResult>?
.NET , Task/Task<TResult>, , ValueTask/ValueTask<TResult>, , . – IAsyncEnumerator<T>, .NET Core 3.0. IEnumerator<T> MoveNext, bool, IAsyncEnumerator<T> MoveNextAsync. , , Task, . , , , ( ), await foreach, ValueTask. , . C# , , , .