async/await en C# en 2026: Task, ValueTask y los errores que te cuestan caro

La programación asíncrona en C# lleva años siendo la forma estándar de escribir código que no bloquee hilos mientras espera: llamadas a BBDD, peticiones HTTP, lectura de ficheros. Pero async/await tiene más matices de los que parece a primera vista, y hay errores concretos que siguen apareciendo en código de producción. Esta guía va al grano.

Cómo funciona async/await

El compilador transforma un método marcado con async en una máquina de estados. Cuando el código llega a un await, el método suspende su ejecución y devuelve el control al llamador. Cuando la tarea esperada termina, la ejecución continúa desde donde se quedó, sin bloquear el hilo durante la espera.

El tipo de retorno importa. Un método async devuelve Task si no hay valor de retorno, Task<T> si lo hay, o ValueTask/ValueTask<T> cuando optimizar asignaciones importa de verdad. Hay un cuarto caso: async void, que existe solo para event handlers. Si lo usas en cualquier otro sitio, las excepciones que lance serán invisibles para el llamador.

// Correcto: devuelve Task<string>
public async Task<string> ObtenerDatosAsync(string url, CancellationToken ct = default)
{
    var respuesta = await _httpClient.GetStringAsync(url, ct);
    return respuesta;
}

// Evitar fuera de event handlers
public async void ManejarEvento(object sender, EventArgs e)
{
    await HacerAlgoAsync(); // Si lanza, nadie lo captura
}

El hilo no se bloquea mientras espera la tarea: queda libre para atender otras peticiones. Eso es lo que hace que los servidores web con async/await escalen mucho mejor que su equivalente síncrono.

Task vs ValueTask: cuándo usar cada uno

Task<T> siempre asigna un objeto en el heap. Para la gran mayoría de métodos asíncronos eso no es un problema, y Task<T> es la elección correcta. Pero si tienes un método que en la mayoría de llamadas ya tiene el resultado disponible de forma síncrona (un buffer ya cargado, un valor en caché), esa asignación se puede evitar con ValueTask<T>.

// ValueTask tiene sentido cuando el camino síncrono es el más frecuente
public ValueTask<int> LeerDelBufferAsync()
{
    if (_buffer.TieneDatos())
        return new ValueTask<int>(_buffer.Leer()); // Sin asignación en heap

    return new ValueTask<int>(LeerAsync()); // Asíncrono real
}

Ojo: ValueTask no se puede await-ar más de una vez ni guardar para usar después. Si necesitas hacer eso, conviértelo a Task con .AsTask(). No uses ValueTask por defecto pensando que siempre es más eficiente: su complejidad de uso solo compensa en hotpaths con perfilado que lo justifique.

ConfigureAwait(false): cuándo y por qué

Por defecto, await captura el SynchronizationContext actual y reanuda la ejecución en él. En una aplicación de escritorio con WPF o WinForms eso significa volver al hilo de UI. En ASP.NET clásico, al contexto de la petición HTTP. Eso tiene sentido cuando necesitas acceder a la UI o al HttpContext después del await.

En código de librería, capturar ese contexto es un coste innecesario y una fuente de deadlocks. La regla es clara: si escribes una librería NuGet, usa ConfigureAwait(false) en todos tus awaits.

public async Task<string> ObtenerAsync(string url)
{
    var respuesta = await _httpClient.GetAsync(url).ConfigureAwait(false);
    var contenido = await respuesta.Content.ReadAsStringAsync().ConfigureAwait(false);
    return contenido;
}

En ASP.NET Core la historia cambia: ya no hay SynchronizationContext que capturar, así que ConfigureAwait(false) da un beneficio marginal. Usarlo o no es opcional, aunque algunos equipos lo ponen por consistencia con el código de librería.

Los errores más caros de async/await

Hay cuatro patrones que aparecen una y otra vez en código con problemas.

.Result y .Wait(): la receta del deadlock

Bloquear un hilo para esperar una tarea asíncrona rompe el modelo de concurrencia. En aplicaciones con SynchronizationContext (WPF, ASP.NET clásico), esto puede causar deadlock: el hilo principal espera a que la tarea termine, y la tarea espera a volver al hilo principal que está bloqueado.

// Mal: puede causar deadlock
var resultado = ObtenerDatosAsync().Result;

// Bien: async de verdad
var resultado = await ObtenerDatosAsync();

async void: excepciones que desaparecen

Un método async void que lanza una excepción no deja ningún sitio donde atraparla con try/catch. La excepción va directa al SynchronizationContext y, según el contexto, puede tumbar el proceso entero.

No await-ar una Task

Si llamas a un método asíncrono sin await y sin guardar la Task, cualquier excepción que lance se pierde de forma silenciosa, o en el mejor caso activa TaskScheduler.UnobservedTaskException. El compilador avisa con un warning: no lo ignores.

Fire-and-forget sin control

Lanzar tasks en segundo plano sin supervisión es arriesgado. Si necesitas fire-and-forget, al menos captura las excepciones dentro de la task o usa un canal/cola con control de errores.

// Fire-and-forget con captura básica
_ = Task.Run(async () =>
{
    try { await ProcesarEnSegundoPlanoAsync(); }
    catch (Exception ex) { _logger.LogError(ex, "Error en background task"); }
});

CancellationToken: propagación de cancelación

Todo método asíncrono que haga I/O debería aceptar un CancellationToken. El patrón estándar es ponerlo como último parámetro con valor por defecto default para no romper la firma cuando el llamador no quiere cancelación.

public async Task ProcesarAsync(string datos, CancellationToken ct = default)
{
    await _repositorio.GuardarAsync(datos, ct);
    await _notificaciones.EnviarAsync(datos, ct);
}

Pasa el token a todas las llamadas internas: await httpClient.GetAsync(url, ct), await dbCommand.ExecuteReaderAsync(ct). Si tienes un bucle con trabajo pesado, comprueba manualmente la cancelación:

foreach (var elemento in elementos)
{
    ct.ThrowIfCancellationRequested();
    await ProcesarElementoAsync(elemento, ct);
}

En ASP.NET Core, HttpContext.RequestAborted es el token que se cancela automáticamente cuando el cliente cierra la conexión. Propágalo hasta tus consultas a BBDD y evitarás trabajo innecesario cuando el usuario ya no espera la respuesta.

IAsyncEnumerable<T>: streams asíncronos

Cuando tienes una secuencia de valores que llegan de forma asíncrona (páginas de resultados de BBDD, líneas de un fichero grande, eventos de un stream), IAsyncEnumerable<T> es la herramienta adecuada. Devuelves valores a medida que están disponibles sin cargar todo en memoria.

public async IAsyncEnumerable<Registro> ObtenerRegistrosAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await foreach (var pagina in _repositorio.PaginarAsync(ct))
    {
        foreach (var registro in pagina)
            yield return registro;
    }
}

// Consumo
await foreach (var registro in ObtenerRegistrosAsync(ct))
{
    await ProcesarAsync(registro, ct);
}

El atributo [EnumeratorCancellation] en el parámetro del token permite que la cancelación se propague correctamente cuando se usa WithCancellation() en el await foreach.

Paralelismo con Task.WhenAll y Task.WhenAny

Task.WhenAll espera a que terminen todas las tasks. Task.WhenAny espera a que termine la primera. Útil cuando tienes llamadas independientes que puedes lanzar en paralelo.

// Lanzar en paralelo y esperar todas
var tareas = ids.Select(id => _repositorio.ObtenerAsync(id, ct));
var resultados = await Task.WhenAll(tareas);

// Esperar solo la primera
var tarea = await Task.WhenAny(task1, task2);
var resultado = await tarea; // Propagar posibles excepciones

Para paralelismo con control de concurrencia, Parallel.ForEachAsync (disponible desde .NET 6) es más cómodo que gestionar semáforos a mano:

await Parallel.ForEachAsync(items, new ParallelOptions
{
    MaxDegreeOfParallelism = 10,
    CancellationToken = ct
}, async (item, token) =>
{
    await ProcesarAsync(item, token);
});

Si necesitas más control manual, SemaphoreSlim sigue siendo una opción válida para limitar el número de tasks simultáneas.

TaskCompletionSource: puente entre callbacks y async

Cuando integras una librería antigua basada en callbacks o eventos con código async/await moderno, TaskCompletionSource<T> es el puente. Creas una Task que puedes completar, fallar o cancelar desde fuera.

public Task<string> EsperarRespuestaAsync()
{
    var tcs = new TaskCompletionSource<string>();

    _clienteAntiguo.OnRespuesta += (datos) => tcs.SetResult(datos);
    _clienteAntiguo.OnError += (ex) => tcs.SetException(ex);
    _clienteAntiguo.OnCancelado += () => tcs.SetCanceled();

    _clienteAntiguo.IniciarPeticion();
    return tcs.Task;
}

// Uso
var respuesta = await EsperarRespuestaAsync();

Con esto puedes usar await sobre cualquier API que no haya sido diseñada para ello. Más info sobre el modelo asíncrono en C# moderno: del lenguaje síncrono al async-first y sobre cómo ASP.NET Core aprovecha todo esto en ASP.NET Core y el modelo asíncrono por defecto.

Imagen: Pexels / Patricio Nahuelhual

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP