ValueTask - por que, por que e como?

Prefácio da tradução


Ao contrário dos artigos científicos, é difícil traduzir artigos desse tipo "perto do texto", e uma adaptação bastante forte deve ser feita. Por esse motivo, peço desculpas por algumas liberdades, da minha parte, ao lidar com o texto do artigo original. Sou guiado por apenas um objetivo - tornar a tradução compreensível, mesmo que em alguns lugares ela se desvie fortemente do artigo original. Eu ficaria grato por críticas construtivas e correções / acréscimos à tradução.


1. Introdução


O namespace System.Threading.Tasks e a classe Task foram introduzidos pela primeira vez no .NET Framework 4. Desde então, esse tipo e sua classe derivada Task<TResult> entraram firmemente na prática de programação no .NET e tornaram-se aspectos-chave do modelo assíncrono. implementado em C # 5, com seu async/await . Neste artigo, falarei sobre novos tipos de ValueTask/ValueTask<TResult> que foram introduzidos com o objetivo de aumentar o desempenho do código assíncrono, nos casos em que a sobrecarga ao trabalhar com memória desempenha um papel fundamental.



Tarefa


Task serve a vários propósitos, mas o principal é a "promessa" - um objeto que representa a capacidade de aguardar a conclusão de uma operação. Você inicia a operação e obtém a Task . Esta Task será concluída quando a operação em si for concluída. Nesse caso, existem três opções:


  1. A operação é concluída de forma síncrona no encadeamento do iniciador. Por exemplo, ao acessar alguns dados que já estão no buffer .
  2. A operação é executada de forma assíncrona, mas consegue concluir quando o iniciador recebe a Task . Por exemplo, ao executar acesso rápido a dados que ainda não foram armazenados em buffer
  3. A operação é executada de forma assíncrona e termina após o iniciador receber a Task exemplo é o recebimento de dados em uma rede .

Para obter o resultado de uma chamada assíncrona, o cliente pode bloquear o segmento de chamada enquanto aguarda a conclusão, o que geralmente contradiz a idéia de assincronia, ou fornecer um método de retorno de chamada que será executado após a conclusão da operação assíncrona. O modelo de retorno de chamada no .NET 4 foi apresentado explicitamente, usando o método ContinueWith de um objeto da classe Task , que recebeu um delegado chamado após a conclusão da operação assíncrona.


 SomeOperationAsync().ContinueWith(task => { try { TResult result = task.Result; UseResult(result); } catch (Exception e) { HandleException(e); } }); 

Com o .NET Frmaework 4.5 e C # 5, o resultado de uma operação assíncrona foi simplificado com a introdução das palavras-chave async/await e o mecanismo por trás delas. Esse mecanismo, o código gerado, é capaz de otimizar todos os casos mencionados acima, manipulando corretamente a conclusão, apesar do caminho em que foi atingido.


 TResult result = await SomeOperationAsync(); UseResult(result); 

A classe Task é bastante flexível e possui várias vantagens. Por exemplo, você pode "esperar" um objeto dessa classe várias vezes, o resultado de forma competitiva, por qualquer número de consumidores. Instâncias de uma classe podem ser armazenadas em um dicionário para qualquer número de chamadas subsequentes, com o objetivo de "aguardar" no futuro. Os cenários descritos permitem considerar objetos de Task como um tipo de cache de resultados obtidos de forma assíncrona. Além disso, o Task fornece a capacidade de bloquear o thread em espera até que a operação seja concluída, se o script exigir. Há também o chamado. combinadores para várias estratégias para aguardar a conclusão de conjuntos de tarefas, por exemplo, "Task.WhenAny" - aguardando assincronamente a conclusão da primeira de muitas tarefas.


Porém, o caso de uso mais comum é simplesmente iniciar uma operação assíncrona e aguardar o resultado de sua execução. Um caso tão simples, bastante comum, não requer a flexibilidade acima:


 TResult result = await SomeOperationAsync(); UseResult(result); 

Isso é muito semelhante à forma como escrevemos código síncrono (por exemplo, TResult result = SomeOperation(); ). Esta opção é naturalmente traduzida em async/await .


Além disso, por todos os seus méritos, o tipo de Task tem uma falha em potencial. Task é uma classe, o que significa que toda operação que cria uma instância de uma tarefa aloca um objeto no heap. Quanto mais objetos criamos, mais trabalho é exigido do GC e mais recursos são gastos no trabalho do coletor de lixo, recursos que podem ser usados ​​para outros fins. Isso se torna um problema claro para o código, no qual, por um lado, as instâncias de Task são criadas com freqüência e, por outro lado, o que aumenta os requisitos de taxa de transferência e desempenho.


O tempo de execução e as principais bibliotecas, em muitas situações, conseguem mitigar esse efeito. Por exemplo, se você escrever um método como o abaixo:


 public async Task WriteAsync(byte value) { if (_bufferedCount == _buffer.Length) { await FlushAsync(); } _buffer[_bufferedCount++] = value; } 

e, na maioria das vezes, haverá espaço suficiente no buffer, a operação será finalizada de forma síncrona. Nesse caso, não há nada de especial na tarefa retornada, não há valor de retorno e a operação já está concluída. Em outras palavras, estamos lidando com a Task , o equivalente a uma operação void síncrona. Nessas situações, o tempo de execução simplesmente armazena em cache o objeto Task e o usa sempre como resultado para qualquer async Task - um método que termina de forma síncrona ( Task.ComletedTask ). Outro exemplo, digamos que você escreva:


 public async Task<bool> MoveNextAsync() { if (_bufferedCount == 0) { await FillBuffer(); } return _bufferedCount > 0; } 

Suponha, da mesma maneira, que na maioria dos casos, haja alguns dados no buffer. O método verifica _bufferedCount , vê que a variável é maior que zero e retorna true . Somente se no momento da verificação os dados não foram armazenados em buffer, uma operação assíncrona é necessária. Seja como for, existem apenas dois resultados lógicos possíveis ( true e false ) e apenas dois estados de retorno possíveis por meio da Task<bool> . Com base na conclusão síncrona, ou assíncrona, mas antes de sair do método, o tempo de execução armazena em cache duas instâncias da Task<bool> (uma para true e outra para false ) e retorna a desejada, evitando alocações adicionais. A única opção quando você precisa criar um novo objeto Task<bool> é um caso de execução assíncrona, que termina após o "retorno". Nesse caso, o método precisa criar um novo objeto Task<bool> , porque no momento da saída do método, o resultado da conclusão da operação ainda não é conhecido. O objeto retornado deve ser exclusivo, porque ele armazenará o resultado da operação assíncrona.


Existem outros exemplos de armazenamento em cache semelhante no tempo de execução. Mas essa estratégia não é aplicável em todos os lugares. Por exemplo, o método:


 public async Task<int> ReadNextByteAsync() { if (_bufferedCount == 0) { await FillBuffer(); } if (_bufferedCount == 0) { return -1; } _bufferedCount--; return _buffer[_position++]; } 

também frequentemente termina de forma síncrona. Mas, diferentemente do exemplo anterior, esse método retorna um resultado inteiro que possui aproximadamente quatro bilhões de valores possíveis. Para armazenar em cache a Task<int> , nessa situação, seriam necessárias centenas de gigabytes de memória. O ambiente aqui também suporta um pequeno cache para a Task<int> , para vários valores pequenos. Portanto, por exemplo, se a operação for concluída de forma síncrona (os dados estão presentes no buffer), com um resultado de 4, o cache será usado. Porém, se o resultado, embora síncrono, for 42, um novo objeto Task<int> será criado, semelhante à chamada Task.FromResult(42) .


Muitas implementações de bibliotecas tentam atenuar essas situações, suportando seus próprios caches. Um exemplo é a sobrecarga do MemoryStream.ReadAsync . Esta operação, introduzida no .NET Framework 4.5, sempre termina de forma síncrona, porque é apenas uma leitura da memória. ReadAsync retorna uma Task<int> onde o resultado inteiro representa o número de bytes lidos. Muitas vezes, no código, ocorre uma situação quando o ReadAsync usado em um loop. Além disso, se houver os seguintes sintomas:


  • O número de bytes solicitados não muda para a maioria das iterações do loop;
  • Na maioria das iterações, o ReadAsync pode ler o número solicitado de bytes.

Ou seja, para chamadas repetidas, o ReadAsync é executado de forma síncrona e retorna um objeto Task<int> , com o mesmo resultado de iteração para iteração. É lógico que o MemoryStream cache a última tarefa concluída com êxito e, para todas as chamadas subseqüentes, se o novo resultado corresponder ao anterior, ele retornará uma instância do cache. Se o resultado não corresponder, o Task.FromResult usado para criar uma nova instância, que, por sua vez, também será armazenada em cache antes de retornar.


Porém, há muitos casos em que uma operação é forçada a criar novos objetos Task<TResult> , mesmo quando concluídos de forma síncrona.


ValueTask <TResult> e conclusão síncrona


Tudo isso serviu de motivação para a introdução de um novo tipo de ValueTask<TResult> no .NET Core 2.0. Além disso, por meio do pacote nuget System.Threading.Tasks.Extensions , esse tipo foi disponibilizado em outras versões do .NET.


ValueTask<TResult> foi introduzido no .NET Core 2.0 como uma estrutura capaz de agrupar TResult ou Task<TResult> . Isso significa que objetos desse tipo podem ser retornados do método async . A primeira vantagem da introdução desse tipo é imediatamente visível: se o método for concluído com êxito e de forma síncrona, não será necessário criar nada no heap, apenas o suficiente para criar uma instância do ValueTask<TResult> com o valor resultante. Somente se o método sair assincronamente, precisamos criar uma Task<TResult> . Nesse caso, ValueTask<TResult> usado como um invólucro na Task<TResult> . A decisão de tornar o ValueTask<TResult> capaz de agregar a Task<TResult> foi tomada com o objetivo de otimização: em caso de sucesso e em caso de falha, o método assíncrono cria a Task<TResult> , do ponto de vista da otimização da memória, é melhor agregar a Task<TResult> próprio objeto Task<TResult> que para manter campos adicionais no ValueTask<TResult> para vários casos de conclusão (por exemplo, para armazenar uma exceção).


Dado o exposto, não é mais necessário armazenar em cache métodos como o MemoryStream.ReadAsync acima, mas pode ser implementado da seguinte maneira:


 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 finalização assíncrona


Ter a capacidade de escrever métodos assíncronos que não exigem alocações de memória adicionais para o resultado, com conclusão síncrona, é realmente uma grande vantagem. Conforme mencionado acima, esse era o principal objetivo da introdução do novo tipo ValueTask<TResult> no .NET Core 2.0. Todos os novos métodos que devem ser usados ​​nas "estradas quentes" agora usam ValueTask<TResult> vez de Task<TResult> como o tipo de retorno. Por exemplo, uma nova sobrecarga do método ReadAsync para Stream , no .NET Core 2.1 (que usa a Memory<byte> vez de byte[] como parâmetro), retorna uma instância de ValueTask<int> . Isso permitiu reduzir significativamente o número de alocações ao trabalhar com fluxos (muitas vezes o método ReadAsync termina de forma síncrona, como no exemplo com MemoryStream ).


No entanto, ao desenvolver serviços com alta largura de banda, em que a terminação assíncrona não é incomum, precisamos fazer o possível para evitar alocações adicionais.


Como mencionado anteriormente, no modelo async/await , qualquer operação que seja concluída de forma assíncrona deve retornar um objeto exclusivo para aguardar a conclusão. Único porque servirá como um canal para realizar retornos de chamada. Observe, no entanto, que essa construção não diz nada sobre se o objeto de espera retornado pode ser reutilizado após a conclusão da operação assíncrona. Se um objeto puder ser reutilizado, a API poderá manter um pool para esses tipos de objetos. Mas, nesse caso, esse pool não pode suportar acesso simultâneo - um objeto do pool passará do estado "concluído" para o estado "não concluído" e vice-versa.


Para oferecer suporte à possibilidade de trabalhar com esses pools, a interface IValueTaskSource<TResult> foi adicionada ao .NET Core 2.1 e a estrutura ValueTask<TResult> foi expandida: agora os objetos desse tipo podem envolver não apenas os objetos do tipo TResult ou Task<TResult> , mas também instâncias de IValueTaskSource<TResult> . A nova interface fornece funcionalidade básica que permite que objetos ValueTask<TResult> trabalhem com IValueTaskSource<TResult> da mesma maneira que com a Task<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); } 

GetStatus destinado ao uso na propriedade ValueTask<TResult>.IsCompleted/IsCompletedSuccessfully - permite descobrir se a operação foi concluída ou não (com êxito ou não). OnCompleted usado no ValueTask<TResult> para acionar um retorno de chamada. GetResult usado para obter o resultado ou lançar uma exceção.


É improvável que a maioria dos desenvolvedores precise lidar com a interface IValueTaskSource<TResult> , porque métodos assíncronos, quando retornados, ocultam-no atrás da ValueTask<TResult> . A interface em si é direcionada principalmente para aqueles que desenvolvem APIs de alto desempenho e procura evitar trabalho desnecessário com muitos.


No .NET Core 2.1, existem vários exemplos desse tipo de API. O mais famoso deles são as novas sobrecargas dos métodos Socket.ReceiveAsync e Socket.SendAsync . Por exemplo:


 public ValueTask<int> ReceiveAsync( Memory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken = default); 

Objetos do tipo ValueTask<int> são usados ​​como o valor de retorno.
Se o método sair de forma síncrona, ele retornará uma ValueTask<int> com o valor correspondente:


 int result = …; return new ValueTask<int>(result); 

Se a operação for concluída de forma assíncrona, será usado um objeto em cache que implementa a interface IValueTaskSource<TResult> :


 IValueTaskSource<int> vts = …; return new ValueTask<int>(vts); 

A implementação do Socket suporta um objeto em cache para recebimento e outro para envio de dados, desde que cada um deles seja usado sem concorrência (não, por exemplo, envio de dados competitivos). Essa estratégia reduz a quantidade de memória adicional alocada, mesmo no caso de execução assíncrona.
A otimização descrita do Socket no .NET Core 2.1 teve um impacto positivo no desempenho do NetworkStream . Sua sobrecarga é o método ReadAsync da classe Stream :


 public virtual ValueTask<int> ReadAsync( Memory<byte> buffer, CancellationToken cancellationToken); 

apenas delega o trabalho para o método Socket.ReceiveAsync . Aumentar a eficiência do método soquete, em termos de trabalho com memória, aumenta a eficiência do método NetworkStream .


ValueTask não genérico


Anteriormente, observei várias vezes que o objetivo original do ValueTask<T> , no .NET Core 2.0, era otimizar casos de conclusão síncrona de métodos com um resultado "não vazio". Isso significa que não havia necessidade de uma ValueTask não digitada: nos casos de conclusão síncrona, os métodos usam um singleton por meio da propriedade Task.CompletedTask , e o tempo de execução dos métodos de async Task também é recebido implicitamente.


Mas, com o advento da capacidade de evitar alocações desnecessárias e com execução assíncrona, a necessidade de um ValueTask não digitado novamente se tornou relevante. Por esse motivo, no .NET Core 2.1, introduzimos ValueTask não ValueTask e IValueTaskSource . Eles são análogos dos tipos genéricos correspondentes e são usados ​​da mesma maneira, mas para métodos com um retorno vazio ( void ).


Implementar IValueTaskSource / IValueTaskSource <T>


A maioria dos desenvolvedores não precisará implementar essas interfaces. E sua implementação não é uma tarefa fácil. Se você decidir implementá-los, então, dentro do .NET Core 2.1, há várias implementações que podem servir como exemplos:



Para simplificar essas tarefas (implementações de IValueTaskSource / IValueTaskSource<T> ), planejamos introduzir o tipo ManualResetValueTaskSourceCore<TResult> no .NET Core 3.0. Essa estrutura encapsulará toda a lógica necessária. A ManualResetValueTaskSourceCore<TResult> pode ser usada em outro objeto que implementa IValueTaskSource<TResult> e / ou IValueTaskSource e delegar a maior parte do trabalho. Você pode aprender mais sobre isso em ttps: //github.com/dotnet/corefx/issues/32664.


O modelo correto para usar ValueTasks


Mesmo um exame superficial ValueTask que ValueTask e ValueTask<TResult> mais limitados que Task e Task<TResult> . E isso é normal, até desejável, porque o objetivo principal é aguardar a conclusão da execução assíncrona.


Em particular, surgem limitações significativas devido ao fato de que ValueTask e ValueTask<TResult> podem agregar objetos reutilizáveis. Em geral, as seguintes operações * NUNCA devem ser executadas ao usar ValueTask / ValueTask<TResult> * ( deixe-me reformular através de "Never" *):


  • Nunca use o mesmo ValueTask / ValueTask<TResult> repetidamente

Motivação: As instâncias Task e Task<TResult> nunca passam do estado "concluído" para o estado "incompleto", podemos usá-las para aguardar o resultado quantas vezes quisermos - após a conclusão, sempre obteremos o mesmo resultado. Pelo contrário, desde ValueTask / ValueTask<TResult> , eles podem atuar como invólucros sobre objetos reutilizados, o que significa que seu estado pode mudar, porque o estado dos objetos reutilizados muda por definição - para passar de "concluído" para "incompleto" e vice-versa.


  • Nunca ValueTask / ValueTask&lt;TResult&gt; no modo competitivo.

Motivação: um objeto empacotado espera trabalhar com apenas um retorno de chamada, de um único consumidor de cada vez, e tentar competir antecipadamente pode facilmente levar a condições de corrida e sutis erros de programação. Expectativas competitivas, esta é uma das opções descritas acima, múltiplas expectativas . Observe que a Task / Task<TResult> permite qualquer número de expectativas competitivas.


  • Nunca use .GetAwaiter().GetResult() até que a operação seja concluída .

Motivação: As implementações de IValueTaskSource / IValueTaskSource<TResult> não devem suportar o bloqueio até que a operação seja concluída. De fato, o bloqueio leva a uma condição de corrida; é improvável que esse seja o comportamento esperado por parte do consumidor. Enquanto Task / Task<TResult> permite fazer isso, bloqueando o encadeamento de chamada até que a operação seja concluída.


Mas e se, no entanto, você precisar ValueTask uma das operações descritas acima e o método chamado retornar instâncias de ValueTask / ValueTask<TResult> ? Para esses casos, o ValueTask / ValueTask<TResult> fornece o método .AsTask() . Ao chamar esse método, você obterá uma instância de Task / Task<TResult> e já poderá executar a operação necessária. Reutilizar o objeto original após chamar .AsTask() não é permitido .


: ValueTask / ValueTask<TResult> , ( await ) (, .ConfigureAwait(false) ), .AsTask() , ValueTask / ValueTask<TResult> .


 // Given this ValueTask<int>-returning method… 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(); ... // storing the instance into a local makes it much more likely it'll be misused, // but it could still be ok // BAD: awaits multiple times ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); int result = await vt; int result2 = await vt; // BAD: awaits concurrently (and, by definition then, multiple times) ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); Task.Run(async () => await vt); Task.Run(async () => await vt); // BAD: uses GetAwaiter().GetResult() when it's not known to be done ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); int result = vt.GetAwaiter().GetResult(); 

, "", , ( , ).


ValueTask / ValueTask<TResult> , . , IsCompleted true , ( , ), — false , IsCompletedSuccessfully true . " " , , , , , . await / .AsTask() .Result . , SocketsHttpHandler .NET Core 2.1, .ReadAsync , ValueTask<int> . , , , . , .. . Porque , , , , :


 int bytesRead; { ValueTask<int> readTask = _connection.ReadAsync(buffer); if (readTask.IsCompletedSuccessfully) { bytesRead = readTask.Result; } else { using (_connection.RegisterCancellation()) { bytesRead = await readTask; } } } 

, .. ValueTask<int> , .Result , await , .


API ValueTask / ValueTask<TResult>?


, . Task / ValueTask<TResult> .


, Task / Task<TResult> . , "" / , Task / Task<TResult> . , , ValueTask<TResult> Task<TResult> : , , await Task<TResult> ValueTask<TResult> . , (, API Task Task<bool> ), , , Task ( Task<bool> ). , ValueTask / ValueTask<TResult> . , async-, ValueTask / ValueTask<TResult> , .


, ValueTask / ValueTask<TResult> , :


  1. , API ,
  2. API ,
  3. , , , .

, abstract / virtual , , / ?


O que vem a seguir?


.NET, API, Task / Task<TResult> . , , API c ValueTask / ValueTask<TResult> , . IAsyncEnumerator<T> , .NET Core 3.0. IEnumerator<T> MoveNext , . — IAsyncEnumerator<T> MoveNextAsync . , Task<bool> , , . , , , ( ), , , await foreach -, , MoveNextAsync , ValueTask<bool> . , , , " " , . , C# , .


Source: https://habr.com/ru/post/pt465259/


All Articles