//JorgenHoc
← Todos los artículos
Async C#Por Jorge CalderónActualizado 14 min read

ValueTask vs Task en C# — Análisis de Rendimiento en Profundidad

Una comparación exhaustiva de ValueTask y Task en C#: diferencias de asignación en el heap, cuándo ValueTask mejora genuinamente el rendimiento, benchmarks y cuándo quedarse con Task.

#csharp#async#dotnet

ValueTask<T> se añadió a .NET específicamente para eliminar las asignaciones en el heap en métodos async que frecuentemente se completan de forma sincrónica. En la práctica, la mayoría de los desarrolladores debería usar Task<T> casi siempre. Pero en hot paths críticos para el rendimiento, entender la diferencia vale la pena.

El Problema de Asignación con Task

Cada vez que se crea un Task<T>, .NET asigna un objeto en el heap administrado. Para la mayoría del código de aplicación, esto está completamente bien — el GC lo gestiona. Pero considera una capa de caché invocada miles de veces por segundo:

// Versión con Task<T> — siempre asigna, incluso en acierto de caché
public async Task<User?> GetUserAsync(int id)
{
    if (_cache.TryGetValue(id, out var cached))
        return cached;  // Asigna un Task<User?> completado en el heap
 
    var user = await _db.Users.FindAsync(id);  // Ruta async — Task asignado de todos modos
    _cache.Set(id, user);
    return user;
}

En cada acierto de caché (que puede ser el 95% de las llamadas), se asigna y descarta inmediatamente un objeto Task<User?>. Con alto throughput, esto se acumula:

  • Cada asignación de Task<T>: 72 bytes en el heap (medido más abajo en .NET 10 x64)
  • 10 000 aciertos de caché/segundo × 72 bytes = ~720 KB/segundo de asignaciones de corta duración
  • Mayor presión sobre el GC, más pausas del GC

ValueTask: Cero Asignaciones para Rutas Sincrónicas

ValueTask<T> es una struct. Devolver un valor completado sincrónicamente no tiene ningún costo de asignación en el heap:

// Versión con ValueTask<T> — cero asignaciones en acierto de caché
public ValueTask<User?> GetUserAsync(int id)
{
    if (_cache.TryGetValue(id, out var cached))
        return ValueTask.FromResult(cached);  // Struct — ¡sin asignación en el heap!
 
    return FetchAndCacheAsync(id);  // La ruta async sigue usando Task internamente
}
 
private async ValueTask<User?> FetchAndCacheAsync(int id)
{
    var user = await _db.Users.FindAsync(id);
    _cache.Set(id, user);
    return user;
}

En aciertos de caché: la struct se crea en el stack, se devuelve por valor y queda fuera de ámbito — el GC nunca interviene.

Benchmarks

Usando BenchmarkDotNet para comparar el overhead de asignación:

[MemoryDiagnoser]
public class TaskVsValueTaskBenchmark
{
    private readonly Dictionary<int, string> _cache = new() { [1] = "cached" };
 
    // ---- Ruta sincronica: el caso para el que existe ValueTask ----
 
    [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();
    }
 
    // ---- Ruta asincronica: el caso donde ValueTask no aporta nada ----
 
    [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 en lugar de Task.Delay: Delay mediria el temporizador del SO en vez del
    // comportamiento de asignacion, y llevaria todas las columnas a milisegundos.
    private static async Task<string?> SlowTaskAsync()
    {
        await Task.Yield();
        return null;
    }
 
    private static async ValueTask<string?> SlowValueTaskAsync()
    {
        await Task.Yield();
        return null;
    }
}

Ejecutándolo con 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 de BenchmarkDotNet para Task frente a ValueTask en .NET 10: el acierto de caché asigna 72 bytes con Task y nada con ValueTask, el fallo de caché asigna 195 bytes con Task y 229 bytes con ValueTask, con el encabezado del entorno y una advertencia de distribución bimodal.
La ejecución en sí, con encabezado de entorno incluido — y la advertencia de bimodalidad que se comenta más abajo.

Lee la columna Allocated, no Mean. En la ruta sincrónica el resultado es el esperado: Task<T> asigna 72 bytes por llamada y ValueTask<T> no asigna nada en absoluto. Ese es el caso para el que se diseñó ValueTask, y cumple.

⚠️

Fíjate en las filas de fallo de caché. En la ruta genuinamente async, ValueTask<T> asignó más que Task<T> — 229 B frente a 195 B. Una vez que un método async ValueTask<T> realmente se suspende, su builder aún tiene que asignar algo en el heap para contener la máquina de estados, y el wrapper struct se apoya encima de eso en lugar de reemplazarlo. El ahorro solo existe cuando el método retorna sin suspenderse nunca.

Ese último punto es el que importa al decidir si conviertes una API. ValueTask no es una mejora gratuita — es un intercambio que gana en la ruta sincrónica y pierde en la asincrónica. Si tu tasa de aciertos de caché es del 95%, claramente vale la pena. Si es del 30%, has empeorado ligeramente las cosas y además has asumido la restricción de un solo await que se describe más abajo.

Dos advertencias sobre los tiempos. BenchmarkDotNet marcó la fila Task<T>, cache hit como bimodal (mValue 3,7) y descartó 17 valores atípicos de ella, así que de las cuatro filas esa es la menos fiable — con nanosegundos de un solo dígito estás midiendo tanto ruido de planificación como trabajo real. La columna Ratio dice que ValueTask es 0,59 de la línea base en un acierto, pero no te lleves de ahí un titular de "1,7x más rápido": son unos pocos nanosegundos sobre una distribución bimodal.

La columna de asignación no tiene ese problema. En tres ejecuciones distintas en esta máquina los tiempos variaron hasta un 4% mientras que Allocated devolvió exactamente los mismos bytes cada vez — 72 B, 0, 195 B, 229 B. Esa es la diferencia entre una medición determinista y una estadística, y por eso son las cifras de asignación las que sostienen el argumento aquí.

Estas cifras siguen viniendo de un solo portátil. La forma debería reproducirse en cualquier parte; los nanosegundos absolutos no, y por eso lo que conviene medir es la proporción de aciertos y fallos en tu propia carga de trabajo.

Contra un Repositorio Real

El benchmark de arriba es sintético — usa Task.Yield() para la ruta async para que el ruido de medición no se coma la diferencia de asignación. Pon esas mismas dos formas delante de una consulta real de EF Core y mide bytes asignados en lugar de tiempo:

RutaTask<User?>ValueTask<User?>
Acierto de caché160 B/llamada0 B/llamada
Fallo de caché18.748 B/llamada18.775 B/llamada

La fila de aciertos es la promesa cumplida: cero asignaciones. La fila de fallos es la que decide si vale la pena convertir una API — las dos quedan a un 0,15% una de otra, porque el pipeline de consultas domina cualquier cosa que haga el wrapper. Cuál de las dos sale marginalmente por delante cambia entre ejecuciones, lo que te dice que la diferencia es ruido.

Mira la proporción entre filas: un fallo cuesta unas 117 veces un acierto. Así que la pregunta nunca es "¿es ValueTask más rápido?", es "¿qué fracción de mis llamadas se completa de forma sincrónica?". Con un 95% de aciertos el ahorro es real y se acumula. Con un 30% has adoptado el peligro del await único que se describe más abajo a cambio de un error de redondeo.

💡

Medido con GC.GetTotalAllocatedBytes(precise: true), no con GC.GetAllocatedBytesForCurrentThread(). El contador por hilo es la opción obvia y la equivocada: un fallo de caché se suspende en await y se reanuda en otro hilo del thread pool, así que esas asignaciones nunca se cuentan. Usándolo aquí se produjo una diferencia de más de 10x entre los dos tipos de retorno que era puro artefacto de medición — y, peor aún, parecía plausible.

Los números también difieren de la columna Allocated de arriba (160 B frente a 72 B) porque miden ámbitos distintos: BenchmarkDotNet aísla el método medido, mientras que GetTotalAllocatedBytes cuenta todo lo que hace el bucle que llama. La dirección y la proporción se reproducen; la cifra absoluta depende de dónde traces el límite.

Salida de consola midiendo bytes asignados por llamada contra un repositorio real de EF Core: 160 bytes con Task y 0 con ValueTask en un acierto de caché, unos 18.750 bytes con ambos en un fallo, seguido de cuatro resultados de doble await donde solo el builder con pooling lanza excepción.
Asignación medida contra un repositorio real, más los resultados del doble await — el builder con pooling es el único que protesta.

Ambos son ejecutables: samples/valuetask-vs-task-csharp para las cifras de asignación y la demostración del await único, y benchmarks/JorgenHoc.Benchmarks para la ejecución con BenchmarkDotNet.

Cuándo ValueTask Realmente Ayuda

1. Métodos de Alta Frecuencia con Ruta Sincrónica Común

// Buen caso de uso de ValueTask — buffer en memoria que se llena de forma asincrónica
public class DataBuffer
{
    private readonly Queue<byte[]> _buffer = new();
 
    // Llamado en un bucle cerrado — a menudo tiene datos, raramente necesita esperar
    public ValueTask<byte[]> ReadChunkAsync(CancellationToken ct = default)
    {
        if (_buffer.TryDequeue(out var chunk))
            return ValueTask.FromResult(chunk);  // Cero asignaciones — ruta común
 
        return WaitForChunkAsync(ct);
    }
 
    private async ValueTask<byte[]> WaitForChunkAsync(CancellationToken ct)
    {
        await _dataAvailableSignal.WaitAsync(ct);
        return _buffer.Dequeue();
    }
}

2. Métodos de Interfaz en Hot Paths

Al definir interfaces para componentes async críticos para el rendimiento:

// Interfaz para un serializador usado millones de veces
public interface IFastSerializer<T>
{
    // ValueTask tiene sentido aquí — las implementaciones pueden completarse sincrónicamente
    ValueTask<byte[]> SerializeAsync(T value, CancellationToken ct = default);
    ValueTask<T> DeserializeAsync(byte[] data, CancellationToken ct = default);
}
 
// Implementación que frecuentemente se completa sincrónicamente (objetos pequeños)
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);  // Pequeño — sincrónico
 
        return WriteLargeAsync(data, ct);
    }
 
    private async ValueTask<byte[]> WriteLargeAsync(byte[] data, CancellationToken ct)
    {
        await Task.Yield();  // Ceder para permitir otro trabajo durante la serialización grande
        return data;
    }
}

3. Operaciones Ya Completadas

// Un lector que prebuferea — frecuentemente ya tiene datos en el buffer
public ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default)
{
    if (TryCopyFromBuffer(buffer, out var bytesRead))
        return ValueTask.FromResult(bytesRead);  // Acierto de buffer — sin asignación
 
    return ReadFromStreamAsync(buffer, ct);  // I/O real — se necesita task
}

Cuándo NO Usar ValueTask

1. La Mayoría del Código de Aplicación

Para el código típico de controlador/servicio de ASP.NET Core, Task<T> es la elección correcta:

// No uses ValueTask aquí — no es un hot path, una asignación por request está bien
public async Task<List<Product>> GetProductsAsync(int categoryId)
{
    return await _db.Products
        .Where(p => p.CategoryId == categoryId)
        .ToListAsync();
}

El costo de asignación de un Task por request HTTP es completamente insignificante.

2. Métodos Que Siempre Esperan

// ValueTask no aporta ningún beneficio aquí — siempre va a la ruta async
public async ValueTask<UserDto> GetUserDtoAsync(int id)
{
    var user = await _db.Users.FindAsync(id);  // Siempre async
    return new UserDto(user!.Id, user.Name, user.Email);
}
 
// Usa Task<T> en su lugar — más simple, mismo rendimiento
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 Esperados Varias Veces

Este es un problema de corrección, no solo de rendimiento — y es peor que un fallo directo, porque la mayoría de las veces parece funcionar.

// COMPORTAMIENTO INDEFINIDO — un ValueTask solo puede consumirse una vez
var vt = cache.GetUserAsync(42);
var a = await vt;  // OK
var b = await vt;  // MAL — pero es muy probable que funcione igualmente
 
// Seguro — conviértelo a Task si necesitas esperarlo más de una vez
var task = cache.GetUserAsync(42).AsTask();
var a = await task;
var b = await task;  // Seguro

Ejecutar ese segundo await contra un método async ValueTask<T> normal da:

RutaSegundo await
Acierto de caché (completado sincrónicamente)Funciona
Fallo de caché (completado asincrónicamente)Funciona
Método con PoolingAsyncValueTaskMethodBuilderLanza InvalidOperationException
.AsTask()Funciona — siempre seguro
🚨

Fíjate en las dos primeras filas. Con el AsyncValueTaskMethodBuilder por defecto, la caja de la máquina de estados sigue viva tras completarse, así que nada detecta la violación y un segundo await devuelve tranquilamente el resultado correcto. "Funcionó cuando lo probé" no es evidencia aquí.

Se rompe cuando la caja se recicla: un método anotado con PoolingAsyncValueTaskMethodBuilder, o cualquier cosa respaldada por un IValueTaskSource que reutiliza tokens — Socket, System.IO.Pipelines, SemaphoreSlim.WaitAsync. Qué builder usa el método llamado es un detalle de implementación que no controlas y que puede cambiar bajo tus pies en una actualización de librería.

Trata el consumo único como una invariante que tú impones, no como una regla que el runtime impone por ti. Llama a AsTask() en el momento en que necesites el resultado dos veces.

4. Almacenar en Campos o Usar con WhenAll

// INCORRECTO — ValueTask no puede almacenarse y esperarse más tarde
ValueTask<string> _pendingTask;
 
public async Task StartAsync()
{
    _pendingTask = DoWorkAsync();  // No se puede almacenar y esperar más tarde de forma fiable
}
 
// INCORRECTO — Task.WhenAll no acepta ValueTask
await Task.WhenAll(task1, task2);  // task1/task2 deben ser Task, no ValueTask
 
// Convierte si es necesario
await Task.WhenAll(vt1.AsTask(), vt2.AsTask());
⚠️

Un ValueTask debe ser esperado exactamente una vez, de forma inmediata. Si necesitas esperarlo varias veces, comprobarlo desde múltiples lugares o pasarlo a Task.WhenAll, conviértelo primero con .AsTask().

La Interfaz IValueTaskSource

Para el control definitivo (utilizado en la BCL de .NET), implementa IValueTaskSource<T> para reutilizar el objeto task en múltiples llamadas — eliminando incluso el overhead mínimo de las máquinas de estado async:

// Patrón avanzado — objeto de operación async reutilizable
// Usado internamente en Socket, PipeReader, etc.
// El código de la mayoría de las aplicaciones nunca necesita este nivel de optimización
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 es código de infraestructura profunda — es probable que nunca lo escribas en el desarrollo de aplicaciones.

Guía de Decisión Práctica

¿Es este un hot path? (1000+ llamadas/segundo por instancia)
├── No → Usa Task<T>
└── Sí → ¿Se completa frecuentemente de forma sincrónica?
           ├── No → Usa Task<T>
           └── Sí → Usa ValueTask<T>
                      └── ¿El caller necesita esperarlo varias veces?
                               ├── Sí → Usa Task<T> o convierte con .AsTask()
                               └── No → ValueTask<T> es apropiado

Resumen

ValueTask<T> es una herramienta de optimización especializada, no un reemplazo general de Task<T>. Úsalo cuando:

  • El método está en un hot path
  • El caso de finalización sincrónica es común (aciertos de caché, lecturas con buffer)
  • El caller esperará exactamente una vez e inmediatamente

Para todo lo demás — que es la mayoría del código de aplicación — Task<T> es más simple, más seguro y tiene un overhead insignificante.

Si todavía estás construyendo el modelo mental de base, la guía de async/await en C# cubre cómo funcionan la máquina de estados y el patrón awaiter — que es lo que hace que la restricción de un solo await de ValueTask<T> tenga sentido en lugar de parecer arbitraria.

Para escenarios de streaming, nota que IAsyncEnumerable<T> ya usa ValueTask internamente en MoveNextAsync, lo que ilustra bien el caso de uso previsto: un hot path donde la finalización sincrónica es común.

Lecturas adicionales

Sobre el autor

Jorge Calderón

Ingeniero de software con más de una década construyendo y operando aplicaciones .NET en producción — capas de datos con EF Core, servicios intensivos en async y despliegues en Azure y contenedores. Cada benchmark y proyecto de ejemplo de estas guías está publicado en un repositorio público de GitHub para que puedas reproducirlo.

Perfil de GitHubLinkedIn ↗Benchmarks y código de ejemplo

Artículos relacionados