//JorgenHoc
← Todos os artigos
Async C#Por Jorge CalderónAtualizado 14 min read

ValueTask vs Task em C# — Análise de Desempenho em Profundidade

Uma comparação completa de ValueTask e Task em C#: diferenças de alocação no heap, quando ValueTask genuinamente melhora o desempenho, benchmarks e quando manter o Task.

#csharp#async#dotnet

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 |
Resultados do BenchmarkDotNet para Task versus ValueTask no .NET 10: o acerto de cache aloca 72 bytes com Task e nada com ValueTask, a falha de cache aloca 195 bytes com Task e 229 bytes com ValueTask, com o cabeçalho do ambiente e um aviso de distribuição bimodal.
A execução em si, com o cabeçalho do ambiente — incluindo o aviso de bimodalidade discutido abaixo.

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:

CaminhoTask<User?>ValueTask<User?>
Acerto de cache160 B/chamada0 B/chamada
Falha de cache18.748 B/chamada18.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.

Saída de console medindo bytes alocados por chamada contra um repositório real do EF Core: 160 bytes com Task e 0 com ValueTask em um acerto de cache, cerca de 18.750 bytes com ambos em uma falha, seguido de quatro resultados de duplo await em que apenas o builder com pooling lança exceção.
Alocação medida contra um repositório real, mais os resultados do duplo await — o builder com pooling é o único que reclama.

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;  // Seguro

Executar esse segundo await contra um método async ValueTask<T> normal dá:

CaminhoSegundo await
Acerto de cache (concluído de forma síncrona)Funciona
Falha de cache (concluída de forma assíncrona)Funciona
Método com PoolingAsyncValueTaskMethodBuilderLanç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> é apropriado

Resumo

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.

Leituras adicionais

Sobre o autor

Jorge Calderón

Engenheiro de software com mais de uma década construindo e operando aplicações .NET em produção — camadas de dados com EF Core, serviços intensivos em async e implantações em Azure e contêineres. Todos os benchmarks e projetos de exemplo destes guias estão publicados em um repositório público no GitHub para que você possa reproduzi-los.

Perfil no GitHubLinkedIn ↗Benchmarks e código de exemplo

Artigos relacionados