1. Introducción
Antes de lanzarnos al código, aclaremos las cosas de forma sencilla. ¿Recuerdas que crear un objeto en .NET (y mucho más aún — ¡un task!) cuesta memoria y un poco de CPU? Ahora imagina una API asíncrona que en la mitad de los casos devuelve el resultado al instante (por ejemplo, lo toma de la caché), y en la otra mitad consulta la base de datos, por lo que la operación se vuelve realmente asíncrona y requiere un Task. Esto ocurre a menudo — por ejemplo, en cachés de archivos, APIs de red, pools de objetos y otros escenarios "asíncronos, pero a veces instantáneos".
Si siempre devolviéramos Task, incluso para resultados instantáneos tendríamos que crear objetos extra. ¿Y si pudiéramos devolver el resultado sin task cuando ya está listo? Para eso nació ValueTask.
Dato: los estándar Task.CompletedTask y Task.FromResult(…) realmente ahorran tiempo de ejecución, pero crean objetos compartidos que pueden no ser ideales en escenarios de alta carga.
¿Qué es ValueTask?
ValueTask es una estructura-wrapper que puede representar o bien un resultado ya listo, o (si la operación es realmente asíncrona) el propio task. Dicho de otro modo, es un "paquete" que puede contener o bien simplemente el valor, o bien una referencia a un Task.
Hay dos variantes principales:
- ValueTask — "sin sentido" (no devuelve valor, como Task)
- ValueTask<TResult> — wrapper para un valor (análogo a Task<TResult>)
Comparación entre Task y ValueTask
| Tipo | Número de allocations | Puede completarse síncronamente | Suele usarse |
|---|---|---|---|
|
Una (heap) | Sí/No | Casi siempre |
|
Cero/una | Sí/No | Optimización |
|
Cero/una | Sí/No | Optimización |
Cuándo aplicar ValueTask
Hay una regla de oro: si escribes un método asíncrono normal que siempre devuelve el resultado solo después de una operación async, usa Task. Es simple, seguro y comprensible para todos.
Usa ValueTask cuando:
- El resultado puede obtenerse síncronamente (por ejemplo, desde caché, pool, memoria), y quieres ahorrar allocations innecesarias.
- La operación asíncrona ocurre no demasiado a menudo (si no, las ventajas se pierden frente a la complejidad del código y la copia de estructuras).
¡Atención! Si siempre devuelves un resultado asíncrono — usa Task. Si quieres que tu API sea "súper-óptima" para resultados instantáneos frecuentes — entonces ValueTask.
2. Resultado síncrono o asíncrono
Veamos una función que busca un usuario por nombre. Si el usuario está en caché — lo devolvemos al instante; si no — lo cargamos de forma asíncrona desde la "base":
// Modelamos a nuestro usuario
public class User
{
public string Name { get; set; }
}
// Nuestro cache (muy simple)
private readonly Dictionary<string, User> _localCache = new();
public async ValueTask<User> FindUserAsync(string name)
{
// Comprobamos el caché local
if (_localCache.TryGetValue(name, out var user))
{
// Resultado instantáneo — ¡no hay allocation de task!
return user;
}
// Aquí hay supuestamente una operación asíncrona larga (por ejemplo, desde la base de datos)
user = await LoadUserFromDbAsync(name);
// Lo ponemos en caché para el futuro
_localCache[name] = user;
return user;
}
private async Task<User> LoadUserFromDbAsync(string name)
{
// Simulamos una demora
await Task.Delay(500);
return new User { Name = name };
}
Importante: En caso de acierto en caché devolvemos el resultado normal — ¡no hay allocation de task! Solo cuando hay que "obtener" el resultado realmente creamos la tarea asíncrona.
Cómo está hecho ValueTask por dentro
Dentro de ValueTask puede haber o bien un valor listo, o una referencia a un Task:
ValueTask result = ValueTask.CompletedTask;
ValueTask<int> valueResult = new ValueTask<int>(42);
ValueTask<int> valueResult2 = new ValueTask<int>(Task.Run(() => 42));
Al usar await el compilador lo resolverá: si el resultado es instantáneo, no se crearán objetos extra.
Importante sobre await y ValueTask
Los métodos async funcionan bien con await para ValueTask:
public async ValueTask PingAsync()
{
// ...
await Task.Delay(10);
}
Pero si tú guardas una instancia de ValueTask y decides esperarla más tarde, recuerda: no puedes hacer await de la misma instancia más de una vez. Task puede esperarse múltiples veces, ValueTask no.
Error: await repetido en ValueTask
ValueTask<int> task = ComputeAsync();
// Esto está BIEN
int a = await task;
// ¡Esto es un error! Un segundo await sobre la misma instancia de ValueTask no está permitido:
// int b = await task; // ¡NO SE PUEDE!
3. Reescribimos parte de nuestra aplicación de consola
Supongamos que ahora desarrollamos un mini-lector de libros: parte de los textos ya están cargados en caché, el resto se descarga de forma asíncrona. Implementemos un método optimizado para obtener la primera línea de un libro usando ValueTask:
private readonly Dictionary<string, string> _bookCache = new();
public async ValueTask<string> GetFirstLineOfBookAsync(string title)
{
if (_bookCache.TryGetValue(title, out var bookText))
{
var firstLine = bookText.Split('\n')[0];
return firstLine;
}
// Supongamos descarga asíncrona del libro
var downloadedBook = await DownloadBookTextAsync(title);
_bookCache[title] = downloadedBook;
return downloadedBook.Split('\n')[0];
}
private async Task<string> DownloadBookTextAsync(string title)
{
// Simulamos una demora (por ejemplo, descarga desde internet)
await Task.Delay(1000);
return $"Book: {title}\nThis is the first line.\nSecond line...";
}
Así ValueTask ahorra la creación del task cuando el libro ya está en caché.
4. Matices útiles
Señal: cuándo no usar ValueTask
Si tu API es siempre asíncrona (por ejemplo, siempre comunicas con la red) — usa Task, es más sencillo y seguro.
Cómo convertir ValueTask en Task y viceversa
Sucede que ValueTask "no encaja" en una API que requiere Task. Usa .AsTask():
ValueTask<int> valueTask = ComputeAsync();
Task<int> task = valueTask.AsTask();
Si necesitas convertir un Task a ValueTask — simplemente crea un ValueTask desde el task:
Task<int> task = ComputeAsyncTask();
ValueTask<int> valueTask = new ValueTask<int>(task);
Comparación: ValueTask vs Task
| Característica | |
|
|---|---|---|
| Await repetido | Permitido | No permitido |
| Allocations en respuestas rápidas | Hay | No (si el resultado es instantáneo) |
| Compatibilidad de interfaz | Ampliamente soportado | Requiere envoltorios adicionales |
| Aplicabilidad | En todas partes | Sólo para optimización |
| Pooled (Pool) | Usa pool común | No, es struct |
| Simplicidad | Fácil | Más complejo |
Uso de ValueTask en la vida real
public async ValueTask<string> GetMessageAsync(int id)
{
if (_messageCache.TryGetValue(id, out var value))
return value;
var result = await LoadMessageFromDbAsync(id);
_messageCache[id] = result;
return result;
}
Aplicación en una entrevista: Si te preguntan cómo exprimir aún más el rendimiento de una API asíncrona con caché, menciona ValueTask.
Ejemplos de compatibilidad con LINQ y IAsyncEnumerable<T>
Si quieres usar ValueTask con LINQ asíncrono (IAsyncEnumerable<T>), esto está soportado en .NET (por ejemplo, el método ToListAsync):
public async ValueTask<List<int>> GetPrimeNumbersAsync()
{
// Simulamos que parte de los números ya están calculados, y otra parte es operación asíncrona
// ... (ejemplo omitido por brevedad)
return new List<int> { 2, 3, 5, 7, 11 };
}
5. Resumen y peculiaridades de implementación
- Usa ValueTask para optimizar cuando una parte significativa del tiempo tu resultado está listo al instante (por ejemplo, desde caché).
- No uses ValueTask "porque sí" — complicará el código y provocará errores.
- No hagas await de un ValueTask varias veces. Si necesitas hacerlo — conviértelo a Task.
- Toda la interoperabilidad con Task es mediante .AsTask().
- Recuerda que ValueTask es un struct, y puede comportarse distinto al copiarlo.
6. Errores típicos al trabajar con ValueTask
Error nº1: await repetido en la misma instancia de ValueTask. ValueTask es una estructura, no se le puede await-iar dos veces. El segundo await dará lugar a una excepción o a un comportamiento incorrecto.
Error nº2: usar ValueTask sin comprobar compatibilidad. Algunas APIs externas requieren Task, y pasar ValueTask directamente puede dar problemas.
Error nº3: usar ValueTask sin necesidad. Si el resultado es siempre asíncrono, usar ValueTask complica el código sin beneficio real.
Error nº4: copiar ValueTask como struct. Una estructura copiada puede comportarse inesperadamente al await; es importante trabajar con el original.
GO TO FULL VERSION