Result y Wait son deadlocks en espera
Este hábito de C# congela apps web. Un solo .Result en medio de una cadena async funciona bien en tu máquina, funciona bien en la demo, y luego congela un hilo de request en producción. Bajo carga es peor: muchos hilos bloqueados a la vez agotan el thread pool y el servicio entero deja de responder. Trata cada .Result y .Wait como un deadlock a punto de ocurrir.
Por qué una línea puede congelar un request
Cuando haces await sobre una task incompleta, por defecto se captura el contexto actual y se usa para reanudar el método. En ASP.NET clásico y en apps con GUI, ese contexto solo permite ejecutar un fragmento de código a la vez. Ahora bloquea ese mismo hilo de contexto con .Result: la continuación del await queda encolada en un contexto que el hilo bloqueado posee, y ese hilo no lo va a soltar hasta que la task termine. Cada lado espera al otro — un deadlock clásico. Stephen Cleary dice que es la pregunta más frecuente de quienes empiezan con async: "¿Por qué mi código parcialmente async hace deadlock?"
El mismo código corre bien en una app de consola (contexto de thread pool, sin deadlock), y justo por eso sobrevive las pruebas locales y muere en la app web.
ASP.NET Core eliminó ese contexto por request, así que el deadlock de libro es más raro — pero bloquear sigue siendo veneno ahí. La guía de Microsoft es directa: "Muchas llamadas bloqueantes síncronas llevan a la inanición del Thread Pool y a tiempos de respuesta degradados." Un pool dimensionado para miles de requests async concurrentes colapsa cuando cada request retiene un hilo esperando .Result.
La solución: async de entrada, async de salida
La asincronía es viral. Cuando una llamada es async, todos los callers por encima también deberían serlo — "los esfuerzos por ser async no sirven de nada a menos que toda la pila de llamadas sea async" (guía de David Fowler para ASP.NET Core). La tabla de traducción es corta:
| En lugar de… | Usa |
|---|---|
task.Result / task.Wait() | await task |
Task.WaitAny(...) | await Task.WhenAny(...) |
Task.WaitAll(...) | await Task.WhenAll(...) |
Thread.Sleep(1000) | await Task.Delay(1000) |
En concreto:
// Deadlock (or starved thread) waiting to happen:
public IActionResult Get()
{
var data = _service.LoadAsync().Result; // blocks the request thread
return Ok(data);
}
// Async all the way down:
public async Task<IActionResult> Get(CancellationToken ct)
{
var data = await _service.LoadAsync(ct);
return Ok(data);
}
Fíjate en el CancellationToken: el modelo de cancelación cooperativa de .NET espera que aceptes un token y lo pases por toda la cadena de llamadas, para que un cliente que se desconecta (o un timeout) realmente detenga el trabajo en vez de quemar un hilo en una respuesta que nadie va a leer.
Notas para usuarios avanzados
.Resultes peor que una API realmente síncrona. La guía de Fowler califica el sync-over-async como mucho peor que llamar a un método genuinamente síncrono — pagas la maquinaria async y además bloqueas un hilo.- No lo "arregles" con
Task.Run(...).Result. En ASP.NET Core, envolver el trabajo enTask.Runsolo agrega scheduling extra en el thread pool; tu código ya corre en hilos del pool. - Las excepciones cambian de forma al bloquear.
awaitrelanza la excepción original;.Result/.Wait()envuelven todo en unAggregateException, así que tucatch (InvalidOperationException)deja de coincidir en silencio. ConfigureAwait(false)en librerías. Evita capturar el contexto, lo que esquiva el deadlock clásico y ahorra saltos de contexto innecesarios — úsalo en código de librería que no toca UI niHttpContext.- Existen excepciones legítimas. Un
Mainde consola (antes deasync Main) puede bloquear; casi nada más debería. - Diagnostica la inanición, no adivines. .NET incluye una guía de depuración de thread-pool starvation con
dotnet-counters; un conteo de hilos que sube con throughput plano es la firma.
Recursos
- Async/Await — Best Practices in Asynchronous Programming (Stephen Cleary, MSDN Magazine)
- ASP.NET Core Best Practices — Avoid blocking calls
- Async Guidance — David Fowler, AspNetCoreDiagnosticScenarios
- Cancellation in managed threads (documentación de .NET)
- Debug thread pool starvation (diagnóstico de .NET)
¿Construyendo una funcionalidad con AI? Yeda AI diseña, audita y entrega sistemas LLM de producción.