Usa await. Suspende el método y devuelve el hilo (thread) al pool mientras se ejecuta la operación, así que la aplicación sigue atendiendo otro trabajo. Cuando la tarea falla, la excepción original aparece donde podemos capturarla.

Task.Wait() bloquea el hilo que hace la llamada hasta que la tarea termina. Envuelve cualquier error en una AggregateException y, en un entorno con contexto de sincronización, puede provocar directamente un interbloqueo (deadlock). El único lugar donde se puede justificar es un punto de entrada síncrono que no se puede hacer asíncrono.

Para descargar el código fuente de este artículo, puedes visitar nuestro repositorio de GitHub.

await vs Wait: ¿qué diferencia hay en C#?

Los dos esperan a que termine una tarea. La diferencia está en lo que le pasa al hilo mientras esperan.

await suspende el método y devuelve el hilo al pool. El resto del método se registra como una continuación y se ejecuta cuando la tarea se completa, posiblemente en otro hilo. No se bloquea nada, así que el mismo pool pequeño de hilos sigue atendiendo otras solicitudes.

Task.Wait() bloquea. El hilo que hace la llamada se detiene y sigue detenido hasta que la tarea se completa, sin hacer ningún trabajo útil mientras tanto. Bajo carga, eso es lo que lleva a un servidor a tener una latencia cada vez mayor con la CPU inactiva.

Con las excepciones también hay diferencias. await vuelve a lanzar la excepción original, así que un catch (HttpRequestException) normal funciona. Task.Wait() lanza una AggregateException que la envuelve, así que ese mismo bloque catch nunca se ejecuta.

La regla es breve: si el método puede ser async, usa await; si no puede, la solución suele ser convertirlo en async, no bloquear.

Por qué es malo el código bloqueante

Escribimos código para crear aplicaciones, y esas aplicaciones se ejecutan como instrucciones dentro de procesos del sistema operativo. Cada proceso recurre a un número limitado de hilos, gestionados por un thread pool, para ejecutarlas.

Cada instrucción de nuestro software se ejecuta en un hilo:

El sistema operativo usa procesos para ejecutar nuestras aplicaciones. Cada proceso crea varios hilos a partir de un thread pool

Como ese número tiene un límite, nuestro código debería ocupar un hilo el menor tiempo posible y devolverlo al pool en cuanto deje de necesitarlo.

Sin embargo, hay instrucciones que, por su naturaleza, requieren mucho tiempo para completarse.

Hay 2 categorías de estas operaciones largas:

  • Intensivas en CPU: operaciones pesadas que exigen mucho a la CPU y requieren bastante tiempo para completarse. Algunos ejemplos de este tipo de operaciones son los cálculos matemáticos complejos y la compresión de video o de imágenes.
  • Intensivas en E/S: operaciones que dependen mucho de recursos externos y remotos e implican tiempos de espera considerables. Algunos ejemplos son la escritura y la lectura en la base de datos y las llamadas a API web.

El código bloqueante es código que reserva un hilo durante todo el tiempo que tarda en completarse una operación intensiva en CPU o en E/S. Suele ser un periodo largo, y este tipo de código a menudo provoca ineficiencias, falta de capacidad de respuesta y una mala experiencia de usuario.

Como desarrolladores, debemos evitar escribir código bloqueante. Y tenemos las herramientas para conseguirlo.

Fundamentos de programación asíncrona para entender await y Task.Wait

La programación asíncrona es un paradigma de programación que podemos usar para evitar bloquear hilos en nuestra aplicación. Las operaciones de larga duración intensivas en CPU y en E/S pasan a ejecutarse en un flujo separado del flujo principal de la aplicación. Los hilos que esperan esas operaciones de larga duración no se bloquean, sino que se liberan y se usan en otro trabajo hasta que la operación se completa. En ese momento, la ejecución del código se reanuda con la siguiente operación. Así, el sistema sigue respondiendo y la percepción general es que la aplicación se ejecuta más rápido.

En las versiones más recientes de .NET, la programación asíncrona se consigue mediante el patrón asíncrono basado en tareas y las palabras clave await y async.

Puedes leer más al respecto en nuestro artículo sobre programación asíncrona con async y await en ASP.NET Core, que incluye un análisis más amplio de la programación asíncrona.

Ahora que conocemos los fundamentos del código bloqueante y del código asíncrono, continuemos con los efectos que Task.Wait() y await tienen en nuestras aplicaciones y veamos cómo se relacionan con los conceptos anteriores.

Programación asíncrona y Task.Wait

Task es una clase del espacio de nombres System.Threading.Tasks que representa una operación asíncrona. Cuando queremos ejecutar una operación de forma asíncrona, podemos envolverla en un objeto Task e iniciarlo. Task expone mucha funcionalidad útil a través de su interfaz pública, que nos permite interactuar con el objeto y con la operación en curso. Uno de los métodos que podemos usar en los objetos Task es Task.Wait().

Cuando llamamos a Task.Wait(), le indicamos al runtime que se detenga y espere a que la tarea termine de ejecutarse, lo que bloquea la ejecución. Eso significa que, al detenerse, el hilo en ejecución no se libera. Al contrario, sigue ocupado esperando a que se complete la operación. Esto también significa que el mismo hilo que ejecuta nuestro código antes de llamar a Wait() lo ejecutará después de la llamada. Vemos, por tanto, que llamar a Task.Wait() tiene todos los inconvenientes del código bloqueante que mencionamos antes en el artículo.

Ponemos cada método de ejemplo en una clase estática CodeExamples, en su propio archivo CodeExamples.cs, y lo llamamos desde Program.cs en una aplicación de consola, con await en el caso de los asíncronos. Veamos cómo llamar al método Task.Wait() en un Task en ejecución:

public static void BlockingCodeExample()
{
    Console.WriteLine("** Blocking code Example **");

    Console.WriteLine($"Current ManagedThreadId: {Environment.CurrentManagedThreadId}");

    Task<string> taskToBlock = Task.Run(() =>
    {
        Thread.Sleep(5000);

        return "Hello, from a blocking task";
    });

    taskToBlock.Wait();

    string blockingResult = taskToBlock.Result;
    Console.WriteLine($"Current ManagedThreadId: {Environment.CurrentManagedThreadId}");
}

Aquí creamos el método BlockingCodeExample(), que primero imprime en la consola el CurrentManagedThreadId del hilo en ejecución. Después, definimos un objeto de tipo Task que envuelve un método delegado Func. El método delegado llama a Thread.Sleep() para simular una operación de larga duración que tarda 5 segundos en completarse y devuelve un string.

The Web API Production Checklist, ebook gratuito en inglés

Ebook gratis

¿Tu Web API está lista para producción?

33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.

El ebook está en inglés.

Descarga la checklist gratis

PDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.

A continuación, llamamos al método Wait() en el Task, lo que hace que el hilo en ejecución se bloquee mientras espera a que se complete la operación de larga duración. Después, accedemos a la propiedad Result de taskToBlock para obtener el resultado de la tarea.

Por último, volvemos a imprimir el CurrentManagedThreadId y vemos que el hilo es el mismo.

Veamos la salida de la consola:

** Blocking code Example **
Current ManagedThreadId: 2
Current ManagedThreadId: 2

Sigamos con el operador await para ver en qué se diferencia de lo que hemos visto hasta ahora.

Programación asíncrona y await

El operador await se usa para esperar de forma asíncrona a que se complete una operación. En cuanto el runtime detecta que se espera un Task con await, la ejecución se detiene y el hilo en ejecución se libera hasta que se completa la operación asíncrona. Mientras tanto, el runtime puede usar el hilo en otras operaciones y el sistema sigue respondiendo.

Además, la palabra clave await “desenvuelve” el valor devuelto por la operación asíncrona cuando esta se completa y nos permite asignarlo a una variable.

El operador await solo se puede usar en métodos asíncronos marcados con el modificador async. También es una buena práctica que el método tenga un tipo de valor devuelto Task o Task<T>. Así, quien llame al método también podrá usar await. En el caso de los manejadores de eventos, es correcto marcarlos con async y que devuelvan void.

Veamos ahora cómo usar la palabra clave await en un método asíncrono:

public async static Task AsynchronousCodeExampleAsync()
{
    Console.WriteLine("** Asynchronous code Example **");

    Console.WriteLine($"Current ManagedThreadId: {Environment.CurrentManagedThreadId}");

    Task<string> taskToAwait = Task.Run(() =>
    {
        Thread.Sleep(5000);

        return "Hello, from an asyncrhonous task";
    });

    string awaitResult = await taskToAwait;

    Console.WriteLine($"Current ManagedThreadId: {Environment.CurrentManagedThreadId}");
}

Aquí declaramos el método AsynchronousCodeExampleAsync() y lo marcamos con async para poder usar el operador await dentro de él. Imprimimos el CurrentManagedThreadId del hilo en ejecución y definimos el Task que vamos a esperar con await; simulamos la operación de larga duración con una llamada a Thread.Sleep().

Después usamos la palabra clave await para esperar de forma asíncrona a que se complete la tarea y recibir su resultado. Fíjate en que, en este caso, no necesitamos usar la propiedad Result como hicimos antes. Por último, volvemos a imprimir el CurrentManagedThreadId.

Veamos la salida del método:

** Asynchronous code Example **
Current ManagedThreadId: 2
Current ManagedThreadId: 10

Observa que, en este caso, CurrentManagedThreadId ha cambiado de valor después de usar la palabra clave await. Esto se debe a que el runtime decide cambiar el hilo en ejecución. Como mencionamos antes, await no bloquea el hilo inicial mientras espera a que la operación se complete. Según el tipo de aplicación y nuestro código, el runtime podría esperar a que el hilo inicial vuelva a estar libre para continuar la ejecución una vez completada la operación de larga duración.

Los dos ejemplos anteriores usan Thread.Sleep() solo para simular trabajo real; consulta Thread.Sleep vs Task.Delay para ver por qué la elección entre ambos importa cuando la espera es real. Esa distinción también es independiente de devolver una tarea o esperarla con await desde dentro de un método, que tiene que ver con lo que el propio método devuelve, no con lo que espera.

¿Cómo esperar una tarea en C# sin bloquear?

Esperar sin bloquear significa usar await, y la forma de la espera depende de lo que estemos esperando.

Para una sola tarea, await task. Para varias que pueden ejecutarse a la vez, await Task.WhenAll(t1, t2) las espera juntas y continúa cuando termina la última, mucho más rápido que ejecutarlas una tras otra.

Para obtener el primer resultado de varias, await Task.WhenAny(tasks) devuelve la tarea que se completó primero, que es la forma de expresar un tiempo de espera sin bloquear nada.

Para esperar un periodo fijo, await Task.Delay(ms), nunca Thread.Sleep(ms): el primero libera el hilo y el segundo lo retiene.

Hay dos formas que parecen esperas, pero no lo son. task.Result y task.GetAwaiter().GetResult() bloquean ambas, y la segunda solo se diferencia en que desenvuelve la excepción; ninguna de las dos es una espera no bloqueante.

Si el método no se puede marcar con async porque lo impide la firma de una interfaz o del framework, el problema que vale la pena corregir es esa firma.

Task.WhenAll y Task.WhenAny son el punto de partida para ejecutar varias tareas de forma asíncrona. Cuando de verdad no hay forma de hacer asíncrona la firma, el artículo sobre cómo ejecutar un método asíncrono de forma síncrona trata los pocos casos en los que esa es la única opción.

The Web API Production Checklist, ebook gratuito en inglés

Ebook gratis

¿Tu Web API está lista para producción?

33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.

El ebook está en inglés.

Descarga la checklist gratis

PDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.

Manejo de excepciones con await y Task.Wait

Ahora que hemos visto las diferencias entre await y Task.Wait() en el flujo de ejecución, veamos también en qué se diferencian a la hora de manejar excepciones.

Como hemos visto, la palabra clave await devuelve el resultado de una operación asíncrona cuando esta se completa. Además, comprueba si se ha lanzado alguna Exception dentro de la operación asíncrona y vuelve a lanzar la Exception al método que espera. Por tanto, la experiencia que obtenemos es muy parecida a la de la ejecución síncrona.

Veámoslo en acción:

public async static Task AsynchronousExceptionHandlingAsync()
{
   Console.WriteLine("** Asynchronous Exception Handling **");

    try
    {
        Task taskToFail = Task.Run(() =>
        {
            throw new ApplicationException("Error in the asynchronous long-running operation");
        });

        await taskToFail;
    }
    catch (ApplicationException ex)
    {
        Console.WriteLine(ex.Message);
    }
}

Aquí definimos el método AsynchronousExceptionHandlingAsync(), que ejecuta un Task encargado de lanzar una ApplicationException. Usamos un bloque try-catch para envolver el código y manejar cualquier excepción.

Ahora ejecutemos este código; deberíamos ver que capturamos correctamente la ApplicationException:

** Asynchronous Exception Handling **
Error in the asynchronous long-running operation

Veamos ahora cómo se comporta el mismo código cuando usamos Task.Wait():

public static void BlockingExceptionHandling()
{
    Console.WriteLine("** Blocking Exception Handling **");

    try
    {
        Task taskToFail = Task.Run(() =>
        {
            throw new ApplicationException("Error in the blocking long-running operation");
        });

        taskToFail.Wait();
    }
    catch (ApplicationException ex)
    {
        Console.WriteLine(ex.Message);
    }
}

Aquí definimos el método BlockingExceptionHandling(), que ejecuta un Task encargado de lanzar una ApplicationException. La única diferencia esta vez es que no usamos await con la tarea, sino que llamamos al método Wait().

Ejecutamos el código, ¡pero vemos que falla!

Veamos la salida:

Muestra la excepción que lanza el ejemplo de manejo de excepciones bloqueante.

Parece que nuestro código lanza una System.AggregateException en lugar de la ApplicationException que esperábamos. La programación basada en tareas usa AggregateException para agrupar las distintas excepciones que pueden lanzar las tareas en paralelo. Es un concepto potente cuando trabajamos con tareas concurrentes y queremos comprobar su estado e informar de sus errores. Sin embargo, al manejar errores en una sola tarea asíncrona, AggregateException agrega complejidad al código sin ningún beneficio.

¿Por qué Task.Wait() provoca interbloqueos?

El interbloqueo se produce cuando el hilo bloqueado es el único que tiene permitido terminar el trabajo que está esperando.

Algunos entornos instalan un contexto de sincronización que solo permite que un hilo a la vez ejecute continuaciones: el hilo de la interfaz de usuario en WinForms y WPF, o el hilo que tiene el contexto de la solicitud en el ASP.NET clásico. Llamar a Task.Wait() en ese hilo lo bloquea.

La operación esperada termina, su continuación se pone en cola en ese mismo contexto y el hilo no puede ejecutarla porque sigue bloqueado esperando. Las dos partes esperan para siempre.

ASP.NET Core eliminó el contexto de sincronización de las solicitudes, así que el clásico cuelgue es menos frecuente que antes. No ha desaparecido: WPF, WinForms y cualquier contexto personalizado de un solo hilo lo siguen reproduciendo.

La solución alternativa a la que se recurre es usar ConfigureAwait(false) en todas las llamadas de la cadena, lo que solo funciona si no se ha olvidado en ninguna.

Stephen Toub, del equipo de .NET, explica la diferencia sin rodeos en el artículo ConfigureAwait FAQ: “mientras que el ASP.NET clásico en .NET Framework tiene su propio SynchronizationContext, en cambio ASP.NET Core no lo tiene”.

Criterioawait tasktask.Wait() / task.Result
Retiene el hilo que hace la llamadaNo: vuelve al poolSí: bloqueado hasta que termina
Se puede usar en un método síncronoNo: el método debe ser asyncSí
Excepción que se recibeLa excepción originalUna AggregateException que la envuelve
try/catch captura el tipo realSíSolo a través de ex.InnerException
Riesgo de interbloqueo con un contexto de sincronizaciónNingunoReal: el ASP.NET clásico y WinForms/WPF se cuelgan
CancelaciónCancellationToken en la llamada esperadaWait(timeout) deja de esperar; el trabajo sigue en ejecución
Costo para el thread pool bajo cargaUn hilo atiende muchas esperasUn hilo bloqueado por cada espera
Dónde usarloEn todas partesMain antes de C# 7.1 y los constructores

Esta tabla refleja también la postura de Microsoft: su guía de programación asíncrona incluye una tabla “Código actual → Sustituir por ‘await'” que asocia Task.Wait o Task.Result con await, Task.WaitAny con await Task.WhenAny, Task.WaitAll con await Task.WhenAll y Thread.Sleep con await Task.Delay (Asynchronous programming scenarios, Microsoft Learn, consultado el 9 de agosto de 2026).

Conclusión

En este artículo, hemos analizado las diferencias entre el operador await y el método Task.Wait(). Hemos empezado explicando qué es el código bloqueante, por qué es una mala práctica y cómo podemos usar código asíncrono en su lugar. Hemos visto cómo usar await y Task.Wait() en nuestro código y cómo afectan al flujo de ejecución de nuestros programas. También hemos visto cómo await facilita el manejo de excepciones y por qué deberíamos usarlo para crear sistemas eficientes y con buena capacidad de respuesta.

Probado con .NET 10.0.10.