A classe Task/Task<T> simplificou consideravelmente a programação assíncrona com .NET. Entretanto, sendo uma classe, tem suas instâncias alocadas na heap e pode sobrecarregar o Garbage Collector em cenários onde é utilizada no caminho crítico. Daí, o surgimento da estrutura ValueTask/ValueTask<T>.
Entendendo o problema que motivou ValueTask<T>
O uso mais comum para a classe Task é permitir que executemos operações mais pesadas em “segundo plano” enquanto a aplicação se mantem responsiva. Ou seja, para operações com duração mais longa, podemos iniciar uma Task (que irá rodar em outra thread) e “esperar para continuar” assim que a execução desta task estiver completa. Enquanto a task não “retorna”, podemos executar outras operações.
Entretanto, há situações em que nem todas as chamadas para um método potencialmente lento, serão lentas. Considere, por exemplo, o código abaixo, extraído de um post excelente do Stephen Toub. Nesse código, usamos uma Task para preencher um array que serve como buffer para a leitura de um stream byte a byte.
public async Task<int> ReadNextByteAsync() { if (_bufferedCount == 0) { await FillBuffer(); } if (_bufferedCount == 0) { return -1; } _bufferedCount--; return _buffer[_position++]; }
Neste código, o preenchimento do buffer é uma operação custosa e assíncrona (sendo executada como uma Task). Entretanto, perceba que, na maior parte das vezes, as leituras ocorrem a partir de um array e são instantâneas.
No código de exemplo, se o tamanho de _buffer for, por exemplo, 1024 bytes, então, estaremos criando 1023 objetos Tasks na heap sem necessidade(afinal, não há nada a ser executado em “segundo plano”).
Entendendo a solução encontrada
A ideia do time da Microsoft para lidar com cenários onde métodos podem ser, eventualmente lentos, mas são frequentemente rápidos, foi criar o tipo ValueTask<T>.
ValueTask<T> é uma struct que funciona wrapper tanto para um valor do tipo T (resultado do processamento da Task) quanto para uma Task<T>.
public async ValueTask ReadNextByteAsync() { if (_bufferedCount == 0) { await FillBuffer(); } if (_bufferedCount == 0) { return -1; } _bufferedCount--; return _buffer[_position++]; }
ValueTask é uma struct, por isso, fica armazenada na stack e não pressiona o GC. No código acima, somente serão instanciados objetos do tipo Task em situações em que uma chamada a FillBuffer for executada.
As vezes síncrono, as vezes assíncrono
Outro uso comum para ValueTask é em abstrações que podem ou não ser custosas.
interface IPotentialyAsync { ValueTask<int> FooAsync(); } class AsyncImpl : IPotentialyAsync { public ValueTask<int> FooAsync() { return new ValueTask<int>(Task.Run(/* Potentialy slow func */)); //;Task.Run( () => 10;/*.. */); } } class SyncImpl : IPotentialyAsync { public ValueTask<int> FooAsync() { return new ValueTask<int>(42); } }
No código de exemplo, mostramos como ValueTask<T> permite implementações síncronas e assíncronas sem que objetos desnecessários sejam alocados.
Quando um método assíncrono é muito rápido
Por fim, em muitos casos, podemos estar usando um método assíncrono que pode retornar rapidamente (não justificando a criação de uma Task).
public async ValueTask<int> Foo() { var fee = FeeAsync(); var feeResult = fee.IsCompletedSuccessfully ? fee.Result : await fee; var result = /* SOME COMPUTATION USING feeResult */ return result; }
Nesses casos, ValueTask nos permite concluir a execução de maneira síncrona reduzindo, mais uma vez, o impacto sobre o GC.
Por que não usar ValueTask sempre?
O que foi dito até aqui pode estar lhe levando a conclusão de que devemos usar ValueTask sempre. Entretanto, esse não é o caso.
Devemos usar ValueTask somente em cenários onde os ganhos de performance forem fáceis de demonstrar. Afinal, o uso de ValueTask deixa nosso código potencialmente mais difícil de escrever e manter.
ValueTask foi uma das inovações que permitiu ganho de performance assombroso do Kestrel nos últimos tempos. Entretanto, seu uso poderá não fazer efeito positivo algum na performance de sua aplicação (podendo até gerar efeito contrário).
ValueTask é perfeita para cenários onde um método consegue operar de forma síncrona na maior parte das vezes, de forma assíncrona eventualmente, e é executado com muita frequência. Em qualquer outro cenário, não é indicada.
O código gerado pelo compilador quando usamos await com objetos ValueTask tem performance pior do que aquele gerado quando usamos await com objetos Task. Nesses casos, o prejuízo de performance somente será compensado em cenários onde o código é executado com extrema frequência (hotpath).
ValueTask não pode ser utilizada para operações avançadas (de composição, por exemplo). Nesses casos, ela precisaria ser convertida em Task usando o método AsTask o que acabaria neutralizando os ganhos de usar ValueTask.
Concluindo
ValueTask foi uma das inovações da Microsoft para ganhar performance no Kestrel e é mais uma demonstração dos impactos do GC na execução de aplicações .NET. Entretanto, seu uso somente é recomendado para cenários bem específicos.
Dúvidas? Considerações? Compartilhe suas impressões conosco nos comentários.
Obrigado pelo artigo ElemarJr.
Verei no seu canal se há um vídeo sobre isso, acho interessante o assunto.
Gostaria de recomentar a resposta a essa pergunta que fiz ao time do EFCore.
Está no vídeo com título: “Entity Framework Community Standup – Triggers for EF Core”, no tempo 42:59 de 56:34.
O que achou da resposta deles?
Elemar, parabéns pelos conteúdos que está gerando!
Abraços!