CodeGym /Cursos /C# SELF /Interacción entre código asíncrono y síncrono

Interacción entre código asíncrono y síncrono

C# SELF
Nivel 62 , Lección 4
Disponible

1. Introducción

La asincronía en C# es una herramienta potente. Pero a veces te encuentras con la situación de que el código asíncrono tiene que llamarse desde código síncrono (o viceversa). Parece que todo debería "funcionar mágicamente", pero en la práctica pueden ocurrir bloqueos extraños (deadlock), pérdida de rendimiento e incluso, inesperadamente... interfaz de usuario rota. Y a menudo el bug aparece solo con datos reales: en producción o en el equipo del usuario. La causa suele estar en la interacción incorrecta entre código síncrono y asíncrono y en detalles no evidentes del planificador de tareas de .NET.

Además, las librerías y frameworks modernos promueven el uso activo de métodos asíncronos, y necesitas entender cómo "pegar" correctamente código asíncrono en cadenas síncronas existentes o, al revés, cómo invocar correctamente código síncrono desde un método asíncrono.

Breve repaso: qué pasa con await

Cuando escribes:

await SomeAsyncMethod();

El código se "rompe" en dos partes: antes del await y después. La primera parte se ejecuta hasta la primera operación asíncrona que se espera (por ejemplo, una llamada de red), y la continuación —después de que esa operación termine—. Pregunta: ¿dónde se ejecutará la "continuación"? ¿En el mismo hilo? ¿En otro? ¿Y si escribimos una aplicación de escritorio (por ejemplo, WPF o WinForms), o una consola? La respuesta depende. Y aquí entra en juego, por ejemplo, ConfigureAwait.

¿Qué es SynchronizationContext?

SynchronizationContext es un mecanismo especial de .NET que permite al código "recordar" dónde y cómo debe llamarse la continuación de una operación asíncrona.

  • En aplicaciones clásicas WinForms/WPF, SynchronizationContext garantiza que después de await la parte restante del método continuará ejecutándose en el mismo hilo que el UI, para evitar errores de "acceso a control desde un hilo incorrecto".
  • En ASP.NET (el antiguo, no Core) SynchronizationContext permite restaurar el HttpContext y continuar trabajando con la petición HTTP.
  • En aplicaciones consola y ASP.NET Core, por lo general SynchronizationContext está ausente (es null), y las continuaciones se ejecutan en el thread pool.

¿Qué es TaskScheduler?

TaskScheduler es un mecanismo de más bajo nivel. En la mayoría de los casos trabajas con TaskScheduler.Default, que usa el thread pool de .NET. Se encarga de qué tareas se ejecutan cuándo y dónde.

2. Deadlock al mezclar await y Result/Wait()

Una de las trampas más famosas en C#:

// En algún código de UI
var result = SomeAsyncMethod().Result;

o

SomeAsyncMethod().Wait();

Todo: la aplicación "se colgó". ¿Por qué?

¿Cómo sucede?

  1. Llamas a un método asíncrono y de inmediato pides .Result o .Wait() — es decir, "bloqueas" el hilo actual y esperas que termine la tarea asíncrona.
  2. SomeAsyncMethod internamente hace await, y programa la continuación en el mismo hilo vía SynchronizationContext, pero ese hilo ya está bloqueado esperando Result/Wait().
  3. Puesto que el hilo está esperando la finalización de Result/Wait(), no puede ejecutar la continuation (continuación).
  4. Listo, deadlock: el hilo espera a sí mismo.

Esto es especialmente fácil de reproducir en aplicaciones UI, donde todo el código corre en el hilo de interfaz y todas las continuaciones esperan ese hilo. En aplicaciones consola y ASP.NET Core (sin synchronization context) esos deadlocks son raros.

Broma de la vida: Si lograste un deadlock con .Result — felicidades, estás un par de pasos más cerca del título Senior :D

3. ¿Cómo insertar correctamente código asíncrono en síncrono?

Recomendación número uno: asincronía de arriba hacia abajo

Si tienes un método asíncrono, propaga async/await hacia arriba en la pila de llamadas hasta el propio UI o el punto de entrada. No mantengas la asincronía a medias.

Mal (bloquea el hilo):

// Método síncrono llama a asíncrono vía .Result
public void DoStuff()
{
    var data = GetDataAsync().Result;
    // ...
}

Bien (asincronía end-to-end):

public async Task DoStuffAsync()
{
    var data = await GetDataAsync();
    // ...
}

Si puedes — usa siempre await, no .Result / .Wait().

4. Pero hay situaciones: hay que llamar async desde sync

Reescribir todo el árbol superior a async (la mejor opción si puedes).

Usar patrones especiales: por ejemplo, ejecutar la tarea en un hilo separado vía Task.Run, y dentro de ese hilo llamar al método async.

public void DoStuff()
{
    var result = Task.Run(() => SomeAsyncMethod()).Result;
}

Pero: también hay matices con sincronización en aplicaciones UI, así que mejor no hacerlo sin una buena razón.

5. ¿Qué hace ConfigureAwait(false)?

A veces no necesitas que después de await el código continúe en el mismo hilo (por ejemplo, en aplicaciones servidoras o en librerías donde no es crítico el SynchronizationContext). Al contrario, es mejor que .NET no se ate a un hilo — ¡aumenta el rendimiento!

Sintaxis y principio

await SomeAsyncMethod().ConfigureAwait(false);
  • ConfigureAwait(false) dice: "no necesito el SynchronizationContext original, continúa donde sea, incluso en otro hilo".
  • ConfigureAwait(true) (por defecto) — "continúa donde se llamó el await, preferiblemente en el mismo SynchronizationContext".

Esquema visual


        ┌─────────────────────────────────────────────────────┐
        │                     SynchronizationContext          │
        └─────────────────────────────────────────────────────┘
                      ↑                             ↑
       (UI-thread)   await SomeAsyncMethod()        Continuation (después del await)
                  ──────────────────────────────>  (mismo hilo — si ConfigureAwait(true))
                      ↓
                    (cualquier hilo — si ConfigureAwait(false))

Ejemplo de uso de ConfigureAwait(false) — código de librería

Imagina que escribes una librería que puede usar cualquiera: WinForms, WPF, ASP.NET, consola…

No deberías depender de su modelo de hilos. Por eso usa ConfigureAwait(false) en los métodos asíncronos de tu librería:

public async Task<string> LoadDataFromUrlAsync(string url)
{
    using var client = new HttpClient();
    string content = await client.GetStringAsync(url).ConfigureAwait(false);
    return content;
}

Ahora tu método no "requiere" ejecutarse en un SynchronizationContext concreto. Es más seguro y eficiente (menos cambios de hilo).

Ejemplo: qué pasa con await sin ConfigureAwait

Considera una aplicación WPF:

private async void Button_Click(object sender, RoutedEventArgs e)
{
    Button1.Content = "Cargando...";
    await Task.Delay(2000);  // Simula una operación larga
    Button1.Content = "¡Listo!";
}

Task.Delay internamente hace "await". Por defecto después del await el control vuelve al hilo UI para poder seguir actualizando controles.

Si dentro de tu operación larga usas ConfigureAwait(false):

await Task.Delay(2000).ConfigureAwait(false);
Button1.Content = "¡Listo!";  // ¡Error!

Se lanzará la excepción: InvalidOperationException: "The calling thread cannot access this object because a different thread owns it."
Porque ahora la "continuación" se ejecuta en un hilo distinto, y no puedes acceder al UI.

Conclusión: usa ConfigureAwait(false) solo donde no necesites acceso al UI/contexto.

6. Matices útiles

¿Dónde y cuándo usar ConfigureAwait?

Escenario ¿Usar .ConfigureAwait(false)? ¿Por qué?
Código de librería Se invoca desde cualquier contexto, no necesita UI
Dentro de código ASP.NET Core Sí (el contexto casi no existe) Aumenta el rendimiento
En WinForms/WPF, al acceder al UI No Necesitas volver al hilo UI
Métodos síncronos No tiene sentido No hay synchronization context
Aplicaciones consola Puedes, pero no notarás efecto No hay contexto

¿Cómo saber si hay SynchronizationContext?

Puedes comprobarlo directamente en código:

Console.WriteLine(SynchronizationContext.Current == null
    ? "No hay contexto"
    : "El contexto existe");
  • En aplicaciones consola y ASP.NET Core verás "No hay contexto".
  • En WinForms/WPF — "El contexto existe".

Visualización del salto de hilos

sequenceDiagram
    participant MainThread as Hilo principal (UI/Consola)
    participant ThreadPool as Hilo del pool

    MainThread->>SomeAsyncMethod: Llamada al método
    SomeAsyncMethod->>MainThread: await sin ConfigureAwait
    Note right of MainThread: Después del await volvemos al mismo hilo
    SomeAsyncMethod->>ThreadPool: await con ConfigureAwait(false)
    Note right of ThreadPool: Después del await se puede continuar en cualquier hilo

Reglas rápidas de uso

  • No uses .Result ni .Wait() en código UI con métodos asíncronos.
  • En código de librería usa estrictamente ConfigureAwait(false) en todos los await.
  • En aplicaciones UI usa ConfigureAwait(false) solo dentro de métodos que no acceden a elementos de la interfaz.
  • Mejor no mezcles código síncrono y asíncrono sin necesidad.
  • Si necesitas llamar async desde sync — piénsalo dos veces: ¿no puedes hacer toda la cadena async?

7. Errores típicos de principiantes con asincronía

Error nº1: uso masivo de .Result o .Wait().
Muy a menudo desarrolladores, especialmente tras leer un par de artículos sobre asincronía, empiezan a añadir .Result o .Wait() por todas partes para "sincronizar" llamadas asíncronas. A primera vista parece cómodo, pero en la práctica es camino asegurado al deadlock en aplicaciones UI. Es especialmente peligroso si dentro de métodos asíncronos no se usa ConfigureAwait(false) — el hilo UI se bloquea y no puede ejecutar la continuación de la tarea.

Error nº2: uso incorrecto o excesivo de ConfigureAwait(false).
Algunos principiantes aplican ConfigureAwait(false) por todas partes, incluso en código que interactúa con la UI. En aplicaciones UI eso provoca errores de acceso a controles (InvalidOperationException), porque la continuación ahora se ejecuta en un hilo distinto del hilo UI.

Error nº3: olvidar que ConfigureAwait funciona solo con objetos awaitable.
Muchos creen que pueden usar ConfigureAwait(false) en cualquier método, pero en realidad funciona solo con objetos que retornan Task, Task<T>, ValueTask y otros tipos awaitable. Para métodos síncronos ConfigureAwait no tiene efecto, y esperar por dichos métodos no los hace asíncronos.

Error nº4: mezclar código síncrono y asíncrono sin necesidad.
Intentos de llamar a métodos asíncronos desde código síncrono sin una estrategia pensada (por ejemplo, vía .Result, .Wait() o Task.Run) suelen llevar a bugs sutiles, pérdida de rendimiento y deadlocks difíciles de diagnosticar. Aunque el método parezca corto y seguro, estas llamadas pueden romper la cadena y afectar a toda la aplicación.

Error nº5: subestimar la influencia de SynchronizationContext.
Los principiantes a menudo olvidan que la continuación después de await puede ejecutarse en el mismo hilo (UI) si no usan ConfigureAwait(false). Esto lleva a comportamiento impredecible, especialmente al combinar código UI y librerías, donde esperas y continuaciones se entremezclan.

2
Tarea
C# SELF, nivel 62, lección 4
Bloqueada
Llamar a un método asíncrono desde uno síncrono
Llamar a un método asíncrono desde uno síncrono
1
Cuestionario/control
Flujos de datos asíncronos, nivel 62, lección 4
No disponible
Flujos de datos asíncronos
Un viaje profundo en la asincronía
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION