Menu

async await en C#: Task, Task<T>, WhenAll y trampas habituales

async y await permiten que el código C# espere operaciones lentas sin bloquear un hilo. Aprende cómo se ejecuta un método async, Task y Task<T>, ejecutar a la vez operaciones independientes con Task.WhenAll, las excepciones en código async, y por qué async void y .Result provocan bugs e interbloqueos.

Esta página incluye editores ejecutables: edita, ejecuta y ve el resultado al instante.

La mayoría de las operaciones lentas de un programa son esperas: a que responda un servidor web, a que una base de datos devuelva filas, a que se lea un archivo. async y await permiten que un método se pause durante esa espera sin tener un hilo secuestrado, y que después siga donde lo dejó. El código se sigue leyendo de arriba abajo como código normal.

Un primer método async

Un método async devuelve Task (sin resultado) o Task<T> (un resultado de tipo T). Dentro de él, await espera a otra tarea y te da su resultado.

Salida:

Tea 2.50, cake 4.00, total 6.50

GetPriceAsync dice return 2.50m, y el compilador lo envuelve en el Task<decimal> que devuelve el método. await lo vuelve a desenvolver. Task.Delay es la versión async de Thread.Sleep: termina cuando ha pasado el tiempo, sin bloquear un hilo mientras tanto.

Desde C# 7.1, el propio Main puede ser async, y así es como escribirías este programa en el .NET moderno:

static async Task Main()
{
    decimal tea = await GetPriceAsync("tea");
    Console.WriteLine(tea);
}

Los ejemplos ejecutables de esta página llaman a un método async RunAsync desde un Main normal con .GetAwaiter().GetResult(), que es lo que genera el compilador para un Main async. En una aplicación de consola eso es seguro; la sección sobre interbloqueos explica por qué la misma llamada es peligrosa en código de interfaz.

Qué ocurre en un await

Un método async se ejecuta de forma síncrona hasta su primer await sobre una tarea sin terminar. En ese punto devuelve un Task a quien lo llamó, y el resto del método se convierte en una continuación que se ejecuta cuando termina la tarea esperada.

Salida:

Calling DownloadAsync
  Download: starting
DownloadAsync returned a task; doing other work
  Download: finished
Download awaited

"Download: starting" se imprime antes de que DownloadAsync vuelva, porque todo hasta el primer await se ejecuta en el hilo de quien llama. Después el método devuelve una tarea sin terminar, quien llama sigue adelante, y "Download: finished" solo aparece cuando acaba la espera. Llamar a un método async lo inicia; esperarlo con await es como aguardas el resultado.

async no crea un hilo. Mientras el método está en pausa no hay ningún hilo esperándolo, y por eso un servidor puede tener miles de peticiones esperando a una base de datos con solo un puñado de hilos. Para trabajo que consume mucha CPU y debe ejecutarse en otro hilo, usa Task.Run, que se explica en tareas.

Ejecutar operaciones a la vez con Task.WhenAll

Esperar una llamada detrás de otra es secuencial: cada una empieza solo cuando ha terminado la anterior. Cuando las operaciones no dependen unas de otras, inícialas todas y después espéralas juntas con Task.WhenAll:

Salida de ejemplo:

Sequential: 160 units in ~900 ms
Concurrent: 160 units in ~300 ms

La versión secuencial tarda la suma de las tres esperas; la concurrente, más o menos lo mismo que la más lenta. Task.WhenAll devuelve los resultados en el mismo orden que las tareas que le pasaste, sin importar cuál terminó primero. También funciona con una lista, que es la forma habitual de "obtener cada elemento": await Task.WhenAll(ids.Select(id => FetchAsync(id))).

Task.WhenAny es su contrapartida: termina en cuanto termina la primera tarea, lo que resulta útil para tiempos límite y para "gana la primera respuesta".

Excepciones en código async

Una excepción lanzada dentro de un método async se guarda en la tarea devuelta y se vuelve a lanzar cuando se espera la tarea, así que un try/catch normal alrededor del await funciona:

Salida:

Task created, completed yet: False
Caught ArgumentOutOfRangeException for userId
WhenAll rethrew the first failure
All failures: 1
The other task still succeeded: profile 7

Llamar a LoadProfileAsync(-1) no lanzó nada: la excepción vive en la tarea hasta que algo la espera. Una tarea que nadie espera nunca oculta su excepción por completo, lo que es un motivo más para esperar siempre lo que inicias. Con WhenAll, await vuelve a lanzar la primera excepción, mientras que la propiedad Exception de la tarea combinada (una AggregateException) las contiene todas. Leer ok.Result no da problemas ahí porque ok ya ha terminado.

async void: solo para manejadores de eventos

Un método async también puede devolver void. Evítalo en todas partes salvo en los manejadores de eventos:

// Bad: the caller cannot await it or catch its exceptions.
static async void SaveAsync(Order order)
{
    await db.InsertAsync(order);   // if this throws, the process may crash
}

// Good: return Task, so callers can await and handle errors.
static async Task SaveAsync(Order order)
{
    await db.InsertAsync(order);
}

// Acceptable: an event handler must return void.
private async void SaveButton_Click(object sender, EventArgs e)
{
    try { await SaveAsync(currentOrder); }
    catch (Exception ex) { ShowError(ex); }
}

Con async void no hay ninguna tarea que esperar, así que quien llama sigue adelante antes de que el trabajo termine, y una excepción lanzada dentro se lanza directamente en el contexto de sincronización (o en el pool de hilos), donde ningún try/catch de quien llama puede alcanzarla. En una aplicación de consola o de servidor, eso normalmente termina el proceso. Dentro de un manejador de eventos async void, captúralo todo tú mismo.

Un error relacionado es llamar a un método async sin await. El compilador avisa (CS4014), y el método se ejecuta en segundo plano sin que nada observe su resultado ni sus errores.

Interbloqueos con .Result y .Wait()

Bloquear sobre una tarea con .Result o .Wait() es donde el código async falla más a menudo. En una aplicación con contexto de sincronización (WinForms, WPF, MAUI, ASP.NET clásico), la secuencia es esta:

  1. El hilo de la interfaz llama a GetDataAsync().Result y se bloquea esperando la tarea.
  2. Dentro de GetDataAsync, un await termina. Por defecto, su continuación debe ejecutarse en el contexto donde empezó: el hilo de la interfaz.
  3. El hilo de la interfaz está bloqueado en el paso 1, así que la continuación nunca se ejecuta, así que la tarea nunca termina, así que el paso 1 nunca acaba.

La aplicación se congela sin ninguna excepción. Las aplicaciones de consola y ASP.NET Core no tienen ese contexto, y por eso el mismo código funciona en un programa de prueba y se cuelga en una aplicación de escritorio. Las soluciones:

  • Usa await en toda la cadena de llamadas en lugar de bloquear ("async de principio a fin"). Los manejadores de eventos pueden ser async void para este fin.
  • En código de biblioteca que no necesita volver al contexto de quien llama, escribe await SomethingAsync().ConfigureAwait(false);. Así la continuación se ejecuta en el pool de hilos en lugar del contexto capturado, y la biblioteca se ahorra un cambio de hilo innecesario. Solo protege a quien llama bloqueando si todos los await de la cadena hacen lo mismo, así que tómalo como buena higiene de bibliotecas, no como solución para .Result.

El código de aplicación en ASP.NET Core no necesita ConfigureAwait(false), porque no hay contexto al que volver.

Errores comunes

  • Esperar llamadas independientes una a una. Inícialas y después haz await Task.WhenAll(...).
  • Métodos async void. Devuelve Task; reserva async void para los manejadores de eventos.
  • .Result y .Wait() en código de interfaz o de ASP.NET clásico. Provocan interbloqueos; usa await.
  • Olvidar await. El trabajo se ejecuta sin que nadie lo observe y sus excepciones desaparecen.
  • Envolver E/S en Task.Run. Una API async como File.ReadAllTextAsync o HttpClient.GetStringAsync ya libera el hilo; Task.Run solo añade un salto al pool de hilos.
  • Suponer que async significa "se ejecuta en otro hilo". Significa "puede pausarse sin bloquear". Usa Task.Run para el trabajo que depende de la CPU.

Preguntas frecuentes

¿Cómo funcionan async y await en C#?

Marcar un método como async le permite usar await. Cuando el método llega a un await sobre una tarea que no ha terminado, vuelve de inmediato a quien lo llamó y le entrega un Task que representa el resto del trabajo. Cuando la operación esperada termina, el método se reanuda después del await. Ningún hilo se queda bloqueado mientras espera.

¿Qué diferencia hay entre Task y Task<T>?

Task representa una operación que no produce ningún valor, la versión async de un método void. Task<T> representa una que produce un T: await sobre un Task<int> te da el int. Un método async declarado async Task<int> simplemente escribe return 42;, y el compilador lo envuelve en la tarea.

¿Cómo ejecuto varias operaciones async a la vez?

Inícialas todas primero y después espéralas juntas: var a = GetUserAsync(); var b = GetOrdersAsync(); await Task.WhenAll(a, b);. Escribir await GetUserAsync(); await GetOrdersAsync(); las ejecuta una detrás de otra, así que el tiempo total es la suma en lugar del de la más larga.

¿Por qué async void es malo en C#?

Un método async void no se puede esperar, así que quien llama no sabe cuándo termina, y una excepción lanzada dentro no puede capturarla quien llama: se lanza en el contexto de sincronización y normalmente hace caer el proceso. Devuelve Task en su lugar. async void existe solo para los manejadores de eventos, cuya firma exige void.

¿Por qué .Result o .Wait() provocan un interbloqueo?

En una aplicación con contexto de sincronización (WinForms, WPF, ASP.NET clásico), .Result bloquea el hilo de la interfaz o de la petición mientras la continuación del método esperado aguarda para ejecutarse en ese mismo hilo. Cada uno espera al otro para siempre. Usa await en toda la cadena en lugar de bloquear, y usa ConfigureAwait(false) dentro del código de bibliotecas.

¿Puede Main ser async en C#?

Sí, desde C# 7.1: static async Task Main() o static async Task<int> Main(). Las instrucciones de nivel superior (C# 9) pueden usar await directamente. En código antiguo, el equivalente es llamar a MainAsync().GetAwaiter().GetResult() desde un Main normal, algo seguro ahí porque una aplicación de consola no tiene contexto de sincronización.

Coddy programming languages illustration

Aprende a programar con Coddy

COMENZAR