async y await en profundidad

Avanzado
C#
C#
Actualizado: 21/04/2026

El modelo async y await es la base de las aplicaciones modernas en .NET. Permite mantener la capacidad de respuesta sin invertir esfuerzo en gestionar hilos manualmente. Una vez asimilados los conceptos, el código asíncrono se lee casi como código síncrono, con la gran diferencia de que un await no bloquea el hilo actual mientras espera un resultado externo.

Task y ValueTask como tipos de retorno

Un método async devuelve habitualmente Task o Task<T>. Task representa una operación que se completará en el futuro sin valor de retorno, y Task<T> una que completará con un valor del tipo indicado. Es lo que permite al llamador aplicar await y reanudar cuando el resultado está disponible.

public async Task GuardarAsync(Pedido pedido)
{
    await _repositorio.InsertarAsync(pedido);
    await _bus.PublicarAsync(new PedidoCreado(pedido.Id));
}

public async Task<Pedido> LeerAsync(int id)
{
    var entidad = await _repositorio.BuscarAsync(id);
    return Mapper.Map(entidad);
}

El tipo ValueTask<T> es una alternativa más ligera, útil cuando una operación puede completar de forma síncrona en la mayoría de los casos. Evita la asignación de un Task y reduce el coste de rutas calientes.

public ValueTask<string?> LeerDeCacheAsync(string clave)
{
    if (_cache.TryGetValue(clave, out var valor))
    {
        return ValueTask.FromResult<string?>(valor);
    }

    return LeerAsincronoAsync(clave);
}

private async ValueTask<string?> LeerAsincronoAsync(string clave) =>
    await _db.LeerAsync(clave);

El camino rápido no genera ningún Task, mientras que el camino lento sigue el flujo asíncrono habitual. Esta técnica es valiosa en librerías que exponen métodos llamados miles de veces por segundo. Para aplicaciones cotidianas, Task y Task<T> son la elección por defecto.

Un async void solo se justifica en manejadores de eventos. Las excepciones lanzadas en un método async void pueden terminar el proceso, ya que nadie las recibe a través de un Task.

Ejecutar tareas en paralelo

Cuando varias operaciones asíncronas son independientes, esperarlas secuencialmente con un await tras otro desaprovecha tiempo. La respuesta es iniciar todas y esperarlas en bloque con Task.WhenAll, que devuelve un Task que completa cuando todas han finalizado.

public async Task<DashboardViewModel> CargarDashboardAsync(int usuarioId)
{
    Task<Usuario> usuarioTask = _usuarios.BuscarAsync(usuarioId);
    Task<IReadOnlyList<Pedido>> pedidosTask = _pedidos.UltimosAsync(usuarioId);
    Task<IReadOnlyList<Mensaje>> mensajesTask = _mensajes.BandejaAsync(usuarioId);

    await Task.WhenAll(usuarioTask, pedidosTask, mensajesTask);

    return new DashboardViewModel(
        usuarioTask.Result,
        pedidosTask.Result,
        mensajesTask.Result);
}

Las tres peticiones se lanzan al mismo tiempo. El tiempo total de espera se aproxima al de la operación más lenta, no a la suma de las tres. Cuando Task.WhenAll detecta una excepción, todas las excepciones se agregan en una AggregateException, pero al hacer await solo se propaga la primera para facilitar la lectura.

Para procesar una colección de elementos en paralelo con un grado de concurrencia controlado, Parallel.ForEachAsync es la opción idiomática.

public async Task ActualizarTodosAsync(IReadOnlyList<int> ids, CancellationToken ct)
{
    await Parallel.ForEachAsync(
        ids,
        new ParallelOptions
        {
            MaxDegreeOfParallelism = 8,
            CancellationToken = ct
        },
        async (id, token) =>
        {
            await _repositorio.ActualizarAsync(id, token);
        });
}

El parámetro MaxDegreeOfParallelism acota el número de operaciones simultáneas, protegiendo recursos externos. Sin límite, un bucle sobre miles de elementos abriría miles de conexiones a la vez, algo que casi ningún backend toleraría.

flowchart LR
  A[Colección] --> B[Parallel.ForEachAsync]
  B --> C{Concurrencia N}
  C --> D[Worker 1]
  C --> E[Worker 2]
  C --> F[Worker N]
  D --> G[Operación remota]
  E --> G
  F --> G

Cancelación con CancellationToken

La cancelación en .NET es cooperativa. Ningún código termina a otro de forma forzosa. La operación que puede ser cancelada debe aceptar un CancellationToken y respetarlo en sus puntos de suspensión.

public async Task ProcesarArchivoAsync(string ruta, CancellationToken ct)
{
    using var fs = File.OpenRead(ruta);
    var buffer = new byte[4096];

    while (true)
    {
        ct.ThrowIfCancellationRequested();

        int leidos = await fs.ReadAsync(buffer, ct);
        if (leidos == 0) break;

        await ProcesarChunkAsync(buffer.AsMemory(0, leidos), ct);
    }
}

El token se pasa a cada operación asíncrona. Si el consumidor invoca Cancel, las operaciones lanzan OperationCanceledException y el método finaliza de forma controlada. La llamada explícita a ThrowIfCancellationRequested garantiza respuesta en los puntos donde no hay await.

La fuente de tokens es CancellationTokenSource, que tambien permite combinar tokens y cancelar por tiempo.

using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(30));

try
{
    await ProcesarArchivoAsync("datos.csv", cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Operacion cancelada por tiempo.");
}

La variante CreateLinkedTokenSource une varios tokens en uno solo, muy util cuando se quiere combinar un token externo con un timeout interno.

public async Task<int> DescargarAsync(Uri uri, CancellationToken ct)
{
    using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
    timeout.CancelAfter(TimeSpan.FromSeconds(5));

    return await _http.GetIntAsync(uri, timeout.Token);
}

Documentar en la firma que un método acepta un CancellationToken es parte del contrato público. Quien lo invoca entiende que la operación es cancelable y planifica el flujo en consecuencia.

Flujos asíncronos con IAsyncEnumerable

Cuando el resultado de una operación es una secuencia que se produce poco a poco, como páginas de una API o registros de un streaming, la interfaz ideal es IAsyncEnumerable<T>. Permite iterar con await foreach y libera cada elemento en cuanto está disponible.

public async IAsyncEnumerable<Factura> LeerFacturasAsync(
    int desdeId,
    [EnumeratorCancellation] CancellationToken ct = default)
{
    int ultimo = desdeId;

    while (true)
    {
        ct.ThrowIfCancellationRequested();

        var lote = await _api.ObtenerLoteAsync(ultimo, ct);
        if (lote.Count == 0) yield break;

        foreach (var f in lote)
        {
            yield return f;
            ultimo = f.Id;
        }
    }
}

El consumidor itera con await foreach, recibiendo cada Factura cuando está lista sin esperar a que la secuencia completa termine.

await foreach (var f in LeerFacturasAsync(0, cts.Token))
{
    await _procesador.RegistrarAsync(f);
}

El atributo EnumeratorCancellation asegura que el token pasado con WithCancellation llega correctamente al cuerpo del generador. IAsyncEnumerable reemplaza patrones antiguos basados en Task<List<T>> cuando el tamaño total de la secuencia es grande o desconocido.

Buenas prácticas de async

Hay cuatro reglas que evitan la mayoría de problemas. La primera es propagar async hasta el punto de entrada de la operación. Mezclar Result o Wait en medio de una cadena asíncrona bloquea hilos y anula la ventaja del modelo.

public int ObtenerCantidad() => _api.ObtenerCantidadAsync().Result;

Esta línea parece inocua pero puede causar un deadlock en contextos de sincronización antiguos y siempre bloquea un hilo del pool. La versión correcta es hacer el método asíncrono y propagar el await hasta donde sea necesario.

La segunda es pasar siempre un CancellationToken en métodos públicos. Esto permite al consumidor combinar cancelaciones y aplicar timeouts propios sin alterar la lógica.

La tercera es evitar async void salvo en manejadores de eventos. Cuando se quiere ejecutar una operación asíncrona desde un punto síncrono, se puede envolver en Task.Run si de verdad hace falta, aunque es una solución de emergencia.

La cuarta es configurar ConfigureAwait(false) en librerías que no necesiten retomar el contexto original de sincronización. En código de aplicación moderno, como ASP.NET Core, no suele hacer falta, ya que no hay contexto que capturar.

public async Task<string> LeerRecursoAsync(string ruta)
{
    using var http = new HttpClient();
    return await http.GetStringAsync(ruta).ConfigureAwait(false);
}

Diagnóstico de código asíncrono

Los problemas típicos se detectan con analizadores estáticos y con herramientas de diagnóstico integradas en el IDE. Los avisos más útiles a configurar son los siguientes.

El aviso por async sin await detecta métodos marcados como asíncronos que no esperan ninguna tarea, síntoma de haber olvidado un await o de no necesitar el modificador.

El aviso por Task sin await alerta cuando un método devuelve un Task que nadie observa, lo que provoca excepciones silenciosas y orden de ejecución incorrecto.

El aviso por CancellationToken no propagado avisa cuando una operación interna podría cancelarse pero se le pasa default en lugar del token recibido.

public async Task ProcesarAsync(CancellationToken ct)
{
    await _repositorio.GuardarAsync(ct);
    await _notificador.AvisarAsync();
}

El analizador señalaría la llamada a AvisarAsync porque no recibe el token. Cuando una operación dura minutos, perder la cancelación es un defecto tangible en producción.

Siguiendo estas pautas, el código asíncrono en C# se mantiene predecible incluso cuando la lógica involucra cientos de operaciones concurrentes contra servicios externos.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, C# es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de C#

Explora más contenido relacionado con C# y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Escribir código asíncrono correcto con async await, evitar bloqueos con Task y ValueTask, coordinar tareas concurrentes y cancelar operaciones largas con CancellationToken.