Skip to main content

Command Palette

Search for a command to run...

ValueTask: Porque Nem Toda Task Precisa Existir

Este artigo ilustra uma decisão que eu mesmo vou ignorar enquanto digito Task<T> para tudo.

Published
7 min read
ValueTask: Porque Nem Toda Task Precisa Existir
M
6+ years of experience focused on Backend Development (.NET) for mission-critical systems, banking, and judicial data. Specialist in the Microsoft ecosystem with MCSA and MCSD certifications, plus additional certifications in Azure, AWS, and GCP. Focused on C# API and microservices development (.NET Core to .NET 10), emphasizing Background Services (Workers), messaging, and Batch processing.

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:

  1. Um resultado direto: Quando o dado já está pronto (síncrono), gera zero alocações.

  2. 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.Shared para evitar problemas de thread-safety no benchmark e Task.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 o ValueTask em uma Task segura:

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 struct sem alocar.

  • Consumo Imediato: Você só precisa dar um await simples 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) e Task.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 usar Task.FromResult() se você não usar ValueTask.

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: