ValueTask: Porque Nem Toda Task Precisa Existir
Este artigo ilustra uma decisão que eu mesmo vou ignorar enquanto digito Task<T> para tudo.

Todo desenvolvedor .NET usa Task<T> como se fosse a única opção assíncrona disponível. E isso é verdade em 95% dos casos. Mas e quando o resultado da sua operação já está disponível na memória (síncrono) na maior parte do tempo?
Esse é um cenário específico onde você está gerando pressão desnecessária no Garbage Collector (GC) e deveria considerar o ValueTask<T>.
O ValueTask<T> foi introduzido no .NET Core 2.0 (e aprimorado no 2.1) para resolver exatamente isso. Não é melhor, nem mais moderno e sim mais específico. Se você não entender quando usar, pode criar bugs silenciosos em produção.
O problema: Task<T> sempre aloca
Task<T> é uma classe (Reference Type). Isso significa que toda vez que você cria ou retorna uma Task, há uma alocação no Heap, que eventualmente precisará ser limpa pelo GC.
public async Task<Cliente> ObterClienteAsync(int id)
{
if (_cache.TryGetValue(id, out var cached))
return cached; // Aloca Task<Cliente> no heap
return await _database.QueryAsync(id);
}
Em um cenário de cache hit (digamos, 90% das vezes), você está alocando objetos desnecessariamente. Em uma API de alta performance recebendo 10 mil requests/segundo, isso significa milhares de objetos “inúteis” sendo criados e descartados por segundo. Isso gera o que chamamos de GC Pressure.
A solução: ValueTask<T> como struct
ValueTask<T> é uma struct (Value Type). Quando usado como variável local, geralmente evita alocações no heap e pode representar dois estados:
Um resultado direto: Quando o dado já está pronto (síncrono), gera zero alocações.
Uma Task encapsulada: Quando a operação precisa ser realmente assíncrona, ele envolve uma Task (ou um
IValueTaskSource), comportando-se como antes.
OBS: Lembrando que Structs podem ir para o heap depedendo da forma como está seu código (como por exemplo em casos de boxing).
A mágica está no compilador, que decide o caminho:
public async ValueTask<Cliente> ObterClienteAsync(int id)
{
if (_cache.TryGetValue(id, out var cached))
return cached; // ZERO alocações - retorna direto na struct
return await _database.QueryAsync(id); // Aloca Task internamente
}
Quando retornamos do cache, não há alocação. Quando retornamos da base de dados, comporta-se como Task<T> normal.
Benchmark: Provando com números
Vamos medir o impacto. Cenário típico de banking: 90% cache hits, 10% database calls via BenchmarkDotNet.
OBS: Foi usado o
Random.Sharedpara evitar problemas de thread-safety no benchmark eTask.Yield()apenas para forçar um caminho assíncrono no benchmark.
[MemoryDiagnoser]
[SimpleJob(warmupCount: 3, iterationCount: 10)]
public class TaskVsValueTaskBenchmark
{
private readonly Dictionary<int, Cliente> _cache = new();
private const int Operacoes = 1000;
[GlobalSetup]
public void Setup()
{
for (int i = 0; i < 10000; i++)
_cache[i] = new Cliente(i, $"Cliente {i}", i * 100m);
}
[Benchmark(Baseline = true)]
public async Task<long> Task_TodosEmCache()
{
long soma = 0;
for (int i = 0; i < Operacoes; i++)
{
var cliente = await ObterClienteTask(i);
soma += cliente.Id;
}
return soma;
}
[Benchmark]
public async Task<long> ValueTask_TodosEmCache()
{
long soma = 0;
for (int i = 0; i < Operacoes; i++)
{
var cliente = await ObterClienteValueTask(i);
soma += cliente.Id;
}
return soma;
}
[Benchmark]
public async Task<int> Task_ValidacaoRapida()
{
int contadorValidos = 0;
for (int i = 0; i < Operacoes; i++)
{
if (await ValidarSaldoTask(i))
contadorValidos++;
}
return contadorValidos;
}
[Benchmark]
public async Task<int> ValueTask_ValidacaoRapida()
{
int contadorValidos = 0;
for (int i = 0; i < Operacoes; i++)
{
if (await ValidarSaldoValueTask(i))
contadorValidos++;
}
return contadorValidos;
}
[Benchmark]
public async Task<decimal> Task_ChamadasEncadeadas()
{
decimal total = 0;
for (int i = 0; i < Operacoes; i++)
{
total += await CalcularTotalTask(i);
}
return total;
}
[Benchmark]
public async Task<decimal> ValueTask_ChamadasEncadeadas()
{
decimal total = 0;
for (int i = 0; i < Operacoes; i++)
{
total += await CalcularTotalValueTask(i);
}
return total;
}
// Implementações Task
private async Task<Cliente> ObterClienteTask(int id)
{
if (_cache.TryGetValue(id, out var cliente))
return cliente;
await Task.Yield();
return new Cliente(id, "Não encontrado", 0);
}
private async Task<bool> ValidarSaldoTask(int id)
{
if (!_cache.TryGetValue(id, out var cliente))
return false;
return cliente.Saldo > 1000m;
}
private async Task<decimal> CalcularTotalTask(int id)
{
var cliente = await ObterClienteTask(id);
if (cliente.Saldo < 100)
return 0;
return cliente.Saldo * 1.05m;
}
// Implementações ValueTask
private async ValueTask<Cliente> ObterClienteValueTask(int id)
{
if (_cache.TryGetValue(id, out var cliente))
return cliente;
await Task.Yield();
return new Cliente(id, "Não encontrado", 0);
}
private async ValueTask<bool> ValidarSaldoValueTask(int id)
{
if (!_cache.TryGetValue(id, out var cliente))
return false;
return cliente.Saldo > 1000m;
}
private async ValueTask<decimal> CalcularTotalValueTask(int id)
{
var cliente = await ObterClienteValueTask(id);
if (cliente.Saldo < 100)
return 0;
return cliente.Saldo * 1.05m;
}
}
public sealed record Cliente(int Id, string Nome, decimal Saldo);
public class Program
{
public static void Main() => BenchmarkRunner.Run<TaskVsValueTaskBenchmark>();
}
Resultados Obtidos

O que esses números realmente significam?
~99.9% menos alocações:
Cache hits: 99.90% de redução (72.072 B → 72 B)
Chamadas encadeadas: 99.95% de redução (152.000 B → 80 B)
25–35% mais rápido:
Cache hits: 32.9% mais rápido (19.56 μs → 13.13 μs)
Chamadas encadeadas: 25.1% mais rápido (58.81 μs → 44.02 μs)
Redução drástica de pausas de GC:
- ~99.9% menos alocações = menos trabalho para o GC
Quando NÃO usar ValueTask (A “Zona de Perigo”)
Aqui entra a parte crítica. Diferente da Task, a ValueTask tem restrições severas de uso devido a otimizações de pooling/reciclagem do .NET.
1. Não pode dar await múltiplas vezes
var task = ObterClientePorIdAsync(10);
var resultado1 = await task; // OK
var resultado2 = await task; // PERIGO: Pode estar corrompido ou lançar exceção
O .NET recicla o objeto que gerencia a ValueTask para economizar memória (implementação de IValueTaskSource). Se você der await duas vezes, pode estar lendo um valor que já foi sobrescrito por outra operação do sistema.
2. Não pode dar await concorrentemente
var task = ObterClientePorIdAsync(10);
// RACE CONDITION: Nunca faça isso
await Task.WhenAll(
Task.Run(async () => await task),
Task.Run(async () => await task));
Pelo mesmo motivo de reciclagem, o estado interno não é thread-safe para múltiplos consumidores.
3. Não guardar em campos ou coleções
private ValueTask<Cliente> _cachedTask; // NUNCA faça isso
public async Task ProcessarAsync()
{
_cachedTask = ObterClientePorIdAsync(1);
await _cachedTask;
}
Como o ValueTask deve ser consumido imediatamente ("use uma vez e descarte"), armazená-lo em uma variável de classe também é pedir para ter problemas.
Dica: Se você realmente precisar fazer qualquer uma das coisas acima, use o método
.AsTask()para converter oValueTaskem umaTasksegura:
var task = ObterClientePorIdAsync(1).AsTask();
Quando usar ValueTask
Alta Frequência: Método chamado com frequência altíssima (por request, em loops, por frame).
Caminho Síncrono Comum: O dado quase sempre está em cache, buffer ou memória, permitindo retornar a
structsem alocar.Consumo Imediato: Você só precisa dar um
awaitsimples e seguir a vida.Evidência: Você mediu e as alocações da Gen0 são um gargalo visível.
Quando NÃO usar ValueTask
Lógica de negócio genérica.
Necessidade de passar a tarefa para múltiplos métodos.
Micro-otimização sem evidência (não vale os riscos citados).
Um Uso Correto no Mundo Real
public sealed class ClienteRepository
{
private readonly IMemoryCache _cache;
private readonly IDatabase _database;
// BOM USO: cache hit é comum, método chamado milhares de vezes
public async ValueTask<Cliente?> ObterPorIdAsync(int id)
{
if (_cache.TryGetValue(id, out Cliente cached))
return cached;
var cliente= await _database.QueryAsync(id);
_cache.Set(id, cliente);
return cliente;
}
// NÃO USE: operação sempre async, sem path síncrono
public async Task<List<Cliente>> BuscarAsync(string query)
{
return await _database.SearchAsync(query);
}
}
O Runtime Também Tenta Te Ajudar
Muitas vezes achamos que Task sempre aloca, mas o runtime do .NET possui algumas otimizações internas para Task<T> comuns, fazendo cache de instâncias únicas (singletons) para evitar alocações:
Task.CompletedTask: Uma tarefa já concluída compartilhada.Task.FromResult(true)eTask.FromResult(false): O .NET mantém cache dessas duas tasks booleanas.Task.FromResult(n): Para inteiros pequenos (atualmente entre -1 e 9), o runtime reusa objetos de Task já alocados.
OBS: isso não cobre seus tipos customizados. Como no exemplo, para
Task<Cliente>, sempre haverá alocação ao usarTask.FromResult()se você não usarValueTask.
Se ficou curioso, veja os trechos das propriedades e métodos da classe Task direto do GitHub.

Lendo o fonte do .NET só pra ter o prazer sádico de confirmar que o artigo não é gerado por IA.
Conclusão
Se está em dúvida, continue usando o Task<T>. Quando o GC começar a reclamar e você identificar no profiler que um método específico de alto tráfego está alocando demais apenas para retornar dados de cache, aí sim considere ValueTask<T>.
Se quiser acompanhar mais conteúdos e projetos:
Repositório: https://github.com/Marcus-V-Freitas





