ValueTask<T> foi adicionado ao .NET especificamente para eliminar alocações no heap em métodos async que frequentemente são concluídos de forma síncrona. Na prática, a maioria dos desenvolvedores deve usar Task<T> quase sempre. Mas em hot paths críticos para desempenho, entender a diferença vale a pena.
O Problema de Alocação com Task
Toda vez que você cria um Task<T>, o .NET aloca um objeto no heap gerenciado. Para a maioria do código de aplicação, isso é completamente normal — o GC cuida disso. Mas considere uma camada de cache chamada milhares de vezes por segundo:
// Versão com Task<T> — sempre aloca, mesmo no acerto de cache
public async Task<User?> GetUserAsync(int id)
{
if (_cache.TryGetValue(id, out var cached))
return cached; // Aloca um Task<User?> concluído no heap
var user = await _db.Users.FindAsync(id); // Caminho async — Task alocado de qualquer forma
_cache.Set(id, user);
return user;
}Em cada acerto de cache (que pode ser 95% das chamadas), você aloca e descarta imediatamente um objeto Task<User?>. Com alto throughput, isso se acumula:
- Cada alocação de
Task<T>: 72 bytes no heap (medido abaixo em .NET 10 x64) - 10.000 acertos de cache/segundo × 72 bytes = ~720 KB/segundo de alocações de curta duração
- Mais pressão sobre o GC, mais pausas do GC
ValueTask: Zero Alocações para Caminhos Síncronos
ValueTask<T> é uma struct. Retornar um valor concluído de forma síncrona não tem custo de alocação no heap:
// Versão com ValueTask<T> — zero alocações no acerto de cache
public ValueTask<User?> GetUserAsync(int id)
{
if (_cache.TryGetValue(id, out var cached))
return ValueTask.FromResult(cached); // Struct — sem alocação no heap!
return FetchAndCacheAsync(id); // O caminho async ainda usa Task internamente
}
private async ValueTask<User?> FetchAndCacheAsync(int id)
{
var user = await _db.Users.FindAsync(id);
_cache.Set(id, user);
return user;
}Nos acertos de cache: a struct é criada na stack, retornada por valor e sai do escopo — o GC nunca é envolvido.
Benchmarks
Usando BenchmarkDotNet para comparar o overhead de alocação:
[MemoryDiagnoser]
public class TaskVsValueTaskBenchmark
{
private readonly Dictionary<int, string> _cache = new() { [1] = "cached" };
// ---- Caminho sincrono: o caso para o qual o ValueTask existe ----
[Benchmark(Baseline = true, Description = "Task<T>, cache hit")]
public async Task<string?> TaskCacheHit()
{
if (_cache.TryGetValue(1, out var val)) return val;
return await SlowTaskAsync();
}
[Benchmark(Description = "ValueTask<T>, cache hit")]
public async ValueTask<string?> ValueTaskCacheHit()
{
if (_cache.TryGetValue(1, out var val)) return val;
return await SlowValueTaskAsync();
}
// ---- Caminho assincrono: o caso em que o ValueTask nao traz ganho ----
[Benchmark(Description = "Task<T>, cache miss")]
public async Task<string?> TaskCacheMiss()
{
if (_cache.TryGetValue(999, out var val)) return val;
return await SlowTaskAsync();
}
[Benchmark(Description = "ValueTask<T>, cache miss")]
public async ValueTask<string?> ValueTaskCacheMiss()
{
if (_cache.TryGetValue(999, out var val)) return val;
return await SlowValueTaskAsync();
}
// Task.Yield em vez de Task.Delay: Delay mediria o temporizador do SO em vez do
// comportamento de alocacao, e levaria todas as colunas a milissegundos.
private static async Task<string?> SlowTaskAsync()
{
await Task.Yield();
return null;
}
private static async ValueTask<string?> SlowValueTaskAsync()
{
await Task.Yield();
return null;
}
}Executando com dotnet run -c Release:
BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.9106)
11th Gen Intel Core i7-1165G7 2.80GHz, 1 CPU, 8 logical and 4 physical cores
.NET SDK 10.0.303
[Host] : .NET 10.0.11 (10.0.1126.37416), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
DefaultJob : .NET 10.0.11 (10.0.1126.37416), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|--------------------------- |-------------:|-----------:|-----------:|-------:|--------:|-------:|----------:|------------:|
| 'Task<T>, cache hit' | 11.491 ns | 0.3985 ns | 1.0638 ns | 1.01 | 0.13 | 0.0115 | 72 B | 1.00 |
| 'ValueTask<T>, cache hit' | 6.676 ns | 0.2023 ns | 0.2630 ns | 0.59 | 0.06 | - | - | 0.00 |
| 'Task<T>, cache miss' | 1,172.718 ns | 23.3628 ns | 23.9919 ns | 102.90 | 9.45 | 0.0305 | 195 B | 2.71 |
| 'ValueTask<T>, cache miss' | 1,238.435 ns | 11.6302 ns | 10.8789 ns | 108.66 | 9.79 | 0.0362 | 229 B | 3.18 |
Leia a coluna Allocated, não Mean. No caminho síncrono o resultado é o esperado:
Task<T> aloca 72 bytes por chamada e ValueTask<T> não aloca nada. Esse é o caso para o
qual o ValueTask foi projetado, e ele entrega.
Observe as linhas de falha de cache. No caminho genuinamente async, o ValueTask<T> alocou
mais que o Task<T> — 229 B contra 195 B. Assim que um método
async ValueTask<T> realmente suspende, seu builder ainda precisa alocar algo no heap para
guardar a máquina de estados, e o wrapper struct fica em cima disso em vez de substituí-lo.
A economia só existe quando o método retorna sem nunca suspender.
Esse último ponto é o que importa ao decidir se vale converter uma API. ValueTask não é
uma melhoria gratuita — é uma troca que ganha no caminho síncrono e perde no assíncrono. Se
a sua taxa de acerto de cache é de 95%, claramente compensa. Se é de 30%, você piorou
ligeiramente as coisas e ainda assumiu a restrição de um único await descrita abaixo.
Duas ressalvas sobre os tempos. O BenchmarkDotNet sinalizou a linha Task<T>, cache hit
como bimodal (mValue 3,7) e descartou 17 outliers dela, então das quatro linhas essa é a
menos confiável — em nanossegundos de um dígito você mede tanto ruído de escalonamento
quanto trabalho real. A coluna Ratio diz que o ValueTask é 0,59 da linha de base em um
acerto, mas não leve daí uma manchete de "1,7x mais rápido": são poucos nanossegundos sobre
uma distribuição bimodal.
A coluna de alocação não tem esse problema. Em três execuções distintas nesta máquina os
tempos variaram até 4% enquanto o Allocated retornou exatamente os mesmos bytes todas as
vezes — 72 B, 0, 195 B, 229 B. Essa é a diferença entre uma medição determinística e uma
estatística, e é por isso que os números de alocação sustentam o argumento aqui.
Esses números ainda vêm de um único notebook. A forma deve se reproduzir em qualquer lugar; os nanossegundos absolutos não — por isso o que vale medir é a proporção de acertos e falhas na sua própria carga de trabalho.
Contra um Repositório Real
O benchmark acima é sintético — ele usa Task.Yield() no caminho async para que o ruído de
medição não engula a diferença de alocação. Coloque essas mesmas duas formas na frente de uma
consulta real do EF Core e meça bytes alocados em vez de tempo:
| Caminho | Task<User?> | ValueTask<User?> |
|---|---|---|
| Acerto de cache | 160 B/chamada | 0 B/chamada |
| Falha de cache | 18.748 B/chamada | 18.775 B/chamada |
A linha de acertos é a promessa cumprida: zero alocações. A linha de falhas é a que decide se vale converter uma API — as duas ficam a 0,15% uma da outra, porque o pipeline de consultas supera qualquer coisa que o wrapper faça. Qual das duas sai marginalmente à frente muda entre execuções, o que indica que a diferença é ruído.
Veja a proporção entre as linhas: uma falha custa cerca de 117 vezes um acerto. Então a
pergunta nunca é "o ValueTask é mais rápido?", é "que fração das minhas chamadas é concluída
de forma síncrona?". Com 95% de acertos a economia é real e acumulativa. Com 30% você adotou o
risco do await único descrito abaixo em troca de um erro de arredondamento.
Medido com GC.GetTotalAllocatedBytes(precise: true), não com
GC.GetAllocatedBytesForCurrentThread(). O contador por thread é a escolha óbvia e a errada:
uma falha de cache suspende no await e retoma em outra thread do pool, então essas alocações
nunca são contadas. Usá-lo aqui produziu uma diferença de mais de 10x entre os dois tipos de
retorno que era puramente artefato de medição — e, pior, parecia plausível.
Os números também diferem da coluna Allocated acima (160 B contra 72 B) porque medem escopos
distintos: o BenchmarkDotNet isola o método medido, enquanto o GetTotalAllocatedBytes conta
tudo o que o laço chamador faz. A direção e a proporção se reproduzem; o valor absoluto depende
de onde você traça o limite.

Ambos são executáveis:
samples/valuetask-vs-task-csharp para os números de alocação e a demonstração do await
único, e benchmarks/JorgenHoc.Benchmarks para a execução com BenchmarkDotNet.
Quando ValueTask Realmente Ajuda
1. Métodos de Alta Frequência com Caminho Síncrono Comum
// Bom caso de uso de ValueTask — buffer em memória que é preenchido de forma assíncrona
public class DataBuffer
{
private readonly Queue<byte[]> _buffer = new();
// Chamado em um loop apertado — frequentemente tem dados, raramente precisa aguardar
public ValueTask<byte[]> ReadChunkAsync(CancellationToken ct = default)
{
if (_buffer.TryDequeue(out var chunk))
return ValueTask.FromResult(chunk); // Zero alocações — caminho comum
return WaitForChunkAsync(ct);
}
private async ValueTask<byte[]> WaitForChunkAsync(CancellationToken ct)
{
await _dataAvailableSignal.WaitAsync(ct);
return _buffer.Dequeue();
}
}2. Métodos de Interface em Hot Paths
Ao definir interfaces para componentes async críticos para desempenho:
// Interface para um serializador usado milhões de vezes
public interface IFastSerializer<T>
{
// ValueTask faz sentido aqui — implementações podem ser concluídas de forma síncrona
ValueTask<byte[]> SerializeAsync(T value, CancellationToken ct = default);
ValueTask<T> DeserializeAsync(byte[] data, CancellationToken ct = default);
}
// Implementação que frequentemente é concluída de forma síncrona (objetos pequenos)
public class SmallObjectSerializer<T> : IFastSerializer<T>
{
public ValueTask<byte[]> SerializeAsync(T value, CancellationToken ct = default)
{
var data = JsonSerializer.SerializeToUtf8Bytes(value);
if (data.Length < 1024)
return ValueTask.FromResult(data); // Pequeno — síncrono
return WriteLargeAsync(data, ct);
}
private async ValueTask<byte[]> WriteLargeAsync(byte[] data, CancellationToken ct)
{
await Task.Yield(); // Ceder para permitir outro trabalho durante a serialização grande
return data;
}
}3. Operações Já Concluídas
// Um leitor que faz pré-buffering — frequentemente já tem dados no buffer
public ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default)
{
if (TryCopyFromBuffer(buffer, out var bytesRead))
return ValueTask.FromResult(bytesRead); // Acerto de buffer — sem alocação
return ReadFromStreamAsync(buffer, ct); // I/O real — task necessário
}Quando NÃO Usar ValueTask
1. A Maioria do Código de Aplicação
Para código típico de controlador/serviço do ASP.NET Core, Task<T> é a escolha correta:
// Não use ValueTask aqui — não é um hot path, uma alocação por request é aceitável
public async Task<List<Product>> GetProductsAsync(int categoryId)
{
return await _db.Products
.Where(p => p.CategoryId == categoryId)
.ToListAsync();
}O custo de alocação de um Task por request HTTP é completamente negligenciável.
2. Métodos Que Sempre Aguardam
// ValueTask não traz nenhum benefício aqui — sempre segue o caminho async
public async ValueTask<UserDto> GetUserDtoAsync(int id)
{
var user = await _db.Users.FindAsync(id); // Sempre async
return new UserDto(user!.Id, user.Name, user.Email);
}
// Use Task<T> em vez disso — mais simples, mesmo desempenho
public async Task<UserDto> GetUserDtoAsync(int id)
{
var user = await _db.Users.FindAsync(id);
return new UserDto(user!.Id, user.Name, user.Email);
}3. Métodos Aguardados Várias Vezes
Este é um problema de corretude, não apenas de desempenho — e é pior que uma falha direta, porque na maioria das vezes parece funcionar.
// COMPORTAMENTO INDEFINIDO — um ValueTask só pode ser consumido uma vez
var vt = cache.GetUserAsync(42);
var a = await vt; // OK
var b = await vt; // ERRADO — mas é bem provável que funcione mesmo assim
// Seguro — converta para Task se precisar aguardar mais de uma vez
var task = cache.GetUserAsync(42).AsTask();
var a = await task;
var b = await task; // SeguroExecutar esse segundo await contra um método async ValueTask<T> normal dá:
| Caminho | Segundo await |
|---|---|
| Acerto de cache (concluído de forma síncrona) | Funciona |
| Falha de cache (concluída de forma assíncrona) | Funciona |
Método com PoolingAsyncValueTaskMethodBuilder | Lança InvalidOperationException |
.AsTask() | Funciona — sempre seguro |
Observe as duas primeiras linhas. Com o AsyncValueTaskMethodBuilder padrão, a caixa da
máquina de estados continua viva depois da conclusão, então nada detecta a violação e um
segundo await devolve tranquilamente o resultado correto. "Funcionou quando eu testei" não
é evidência aqui.
Ele quebra quando a caixa é reciclada: um método anotado com
PoolingAsyncValueTaskMethodBuilder, ou qualquer coisa apoiada por um IValueTaskSource que
reutiliza tokens — Socket, System.IO.Pipelines, SemaphoreSlim.WaitAsync. Qual builder o
método chamado usa é um detalhe de implementação que você não controla e que pode mudar sob
seus pés em uma atualização de biblioteca.
Trate o consumo único como uma invariante que você impõe, não como uma regra que o runtime
impõe para você. Chame AsTask() no momento em que precisar do resultado duas vezes.
4. Armazenar em Campos ou Usar com WhenAll
// INCORRETO — ValueTask não pode ser armazenado e aguardado posteriormente
ValueTask<string> _pendingTask;
public async Task StartAsync()
{
_pendingTask = DoWorkAsync(); // Não é possível armazenar e aguardar mais tarde de forma confiável
}
// INCORRETO — Task.WhenAll não aceita ValueTask
await Task.WhenAll(task1, task2); // task1/task2 devem ser Task, não ValueTask
// Converta se necessário
await Task.WhenAll(vt1.AsTask(), vt2.AsTask());Um ValueTask deve ser aguardado exatamente uma vez, imediatamente. Se você precisar aguardá-lo várias vezes, verificá-lo de múltiplos lugares ou passá-lo para Task.WhenAll, converta-o primeiro com .AsTask().
A Interface IValueTaskSource
Para controle máximo (usado na BCL do .NET), implemente IValueTaskSource<T> para reutilizar o objeto task em múltiplas chamadas — eliminando até mesmo o overhead mínimo das máquinas de estado async:
// Padrão avançado — objeto de operação async reutilizável
// Usado internamente em Socket, PipeReader, etc.
// O código da maioria das aplicações nunca precisa desse nível de otimização
public class ReusableAsyncOperation : IValueTaskSource<int>
{
private ManualResetValueTaskSourceCore<int> _core;
public ValueTask<int> GetValueTask() => new ValueTask<int>(this, _core.Version);
public int GetResult(short token) => _core.GetResult(token);
public ValueTaskSourceStatus GetStatus(short token) => _core.GetStatus(token);
public void OnCompleted(Action<object?> continuation, object? state,
short token, ValueTaskSourceOnCompletedFlags flags)
=> _core.OnCompleted(continuation, state, token, flags);
public void SetResult(int result) => _core.SetResult(result);
public void Reset() => _core.Reset();
}Este é código de infraestrutura de baixo nível — é improvável que você precise escrevê-lo no desenvolvimento de aplicações.
Guia Prático de Decisão
É um hot path? (1000+ chamadas/segundo por instância)
├── Não → Use Task<T>
└── Sim → É concluído com frequência de forma síncrona?
├── Não → Use Task<T>
└── Sim → Use ValueTask<T>
└── O caller precisa aguardá-lo várias vezes?
├── Sim → Use Task<T> ou converta com .AsTask()
└── Não → ValueTask<T> é apropriadoResumo
ValueTask<T> é uma ferramenta de otimização especializada, não um substituto geral para Task<T>. Use-o quando:
- O método está em um hot path
- O caso de conclusão síncrona é comum (acertos de cache, leituras com buffer)
- O caller aguardará exatamente uma vez e imediatamente
Para tudo o mais — que é a maioria do código de aplicação — Task<T> é mais simples, mais seguro e tem overhead negligenciável.
Se você ainda está construindo o modelo mental de base, o
guia de async/await em C# cobre como a máquina de estados
e o padrão awaiter funcionam — o que faz a restrição de um único await do ValueTask<T>
fazer sentido em vez de parecer arbitrária.
Para cenários de streaming, note que
IAsyncEnumerable<T> já usa ValueTask internamente em
MoveNextAsync, o que ilustra bem o caso de uso pretendido: um hot path onde a conclusão
síncrona é comum.