WPF/WinForms: async y el hilo de UI, resumidos en una hoja

· Actualizado el: · · C#, async/await, .NET, WPF, WinForms, UI, Hilos

Lo que más confunde al usar async / await en WPF / WinForms es a qué hilo se vuelve después de await y cuándo está permitido tocar la UI. Cuando se mezclan Dispatcher, BeginInvoke, ConfigureAwait(false) y .Result / .Wait(), resulta especialmente difícil ver la causa de que la pantalla se congele o aparezca una excepción de acceso entre hilos.

En este artículo tratamos únicamente la relación entre el hilo de UI y async / await en WPF / WinForms. Los criterios generales de decisión sobre async / await están relacionados con Tabla práctica de decisiones sobre async/await en C# - Task.Run y ConfigureAwait.

En la práctica, los puntos donde de verdad se huele la sangre suelen ser estos.

  • No se sabe dónde se ejecuta la continuación después de await
  • No queda claro si se puede tocar la UI después de usar Task.Run
  • Surgen dudas sobre dónde colocar ConfigureAwait(false)
  • La pantalla se congela por .Result / .Wait() / .GetAwaiter().GetResult()
  • Se mezclan mentalmente el Dispatcher de WPF y los Invoke / BeginInvoke / InvokeAsync de WinForms

Tanto WPF como WinForms son modelos centrados en el hilo de UI. Por eso, para ordenar async / await, lo que más ayuda no es una discusión casi filosófica sobre «qué es lo asíncrono», sino dejar claro qué se le está haciendo al hilo de UI y al bucle de mensajes.

Este artículo asume principalmente aplicaciones WPF / WinForms en .NET 6 o posterior, y recorre, en un orden útil para el trabajo diario, a dónde vuelve la ejecución después de await, el papel de Dispatcher, y por qué se bloquea con ConfigureAwait(false) y con .Result / .Wait().

Cabe aclarar que Control.InvokeAsync de WinForms está disponible desde .NET 9. En versiones anteriores de WinForms, lo habitual es usar BeginInvoke / Invoke.

Además, el código que aparece en este artículo está publicado en GitHub como un conjunto de ejemplos que se puede compilar y ejecutar (una biblioteca independiente de la UI, ejemplos de WPF / WinForms y pruebas unitarias que reproducen el destino de await y los interbloqueos).

wpf-winforms-ui-thread-async-await-one-sheet - komurasoft-blog-samples (GitHub)

Índice

  1. Primero, la conclusión (en pocas palabras)
  2. Primero, un resumen en una imagen
    • 2.1. Panorama general
    • 2.2. Tabla de decisión inicial
  3. Términos usados en este artículo
    • 3.1. El hilo de UI y el bucle de mensajes
    • 3.2. SynchronizationContext / Dispatcher / Invoke
  4. Patrones típicos
    • 4.1. await simple en un controlador de eventos de UI
    • 4.2. Task.Run solo para cálculos pesados de CPU
    • 4.3. ConfigureAwait(false) no es «no volver», sino «no obligar a volver»
    • 4.4. Por qué se bloquea con .Result / .Wait() / .GetAwaiter().GetResult()
  5. Cuándo usar Dispatcher / Invoke
  6. Antipatrones habituales
  7. Lista de verificación para la revisión de código
  8. Resumen práctico de cuándo usar cada cosa
  9. Resumen
  10. Referencias

1. Primero, la conclusión (en pocas palabras)

  • Si se hace un await simple en un controlador de eventos de UI de WPF / WinForms, puede asumirse que la continuación después de await vuelve básicamente al hilo de UI
  • Task.Run sirve para sacar cálculos de CPU del hilo de UI, no es una herramienta para envolver esperas de E/S
  • Aunque se haga await Task.Run(...) dentro de un controlador de UI, si ese await es simple, la continuación normalmente vuelve al hilo de UI
  • ConfigureAwait(false) significa que no obliga a volver al contexto de UI capturado por ese await. Tocar la UI directamente en la continuación posterior es peligroso
  • .Result / .Wait() / .GetAwaiter().GetResult() bloquean el hilo de UI. Si la continuación de un await necesita volver a la UI, es bastante habitual que la aplicación se atasque
  • Para volver explícitamente a la UI en WPF, use Dispatcher.InvokeAsync
  • Para volver explícitamente a la UI en WinForms, tradicionalmente se usa BeginInvoke; desde .NET 9, InvokeAsync combina bien con el flujo async
  • La política de partida es: mantener await simple en la capa más externa de la UI, considerar ConfigureAwait(false) en las bibliotecas de propósito general, y hacer explícito el regreso a la UI solo donde sea necesario

En resumen, en WPF / WinForms, si se tienen claros estos tres puntos, la visión de conjunto mejora enormemente:

  1. En qué hilo se está ejecutando ahora mismo
  2. A dónde vuelve la continuación de await
  3. Quién tiene la responsabilidad de devolver el control a la UI

2. Primero, un resumen en una imagen

2.1. Panorama general

Lo más rápido es captar el panorama general con este diagrama.

Cuatro patrones de await en WPF/WinForms y a dónde vuelvenDiagrama que muestra cuatro caminos desde un controlador de eventos de UI. un await simple que vuelve al hilo de UI. un Task.Run cuyo cálculo corre en el ThreadPool pero cuya continuación también vuelve a la UI. un ConfigureAwait con el valor false cuya continuación no vuelve forzosamente a la UI y requiere Dispatcher o Invoke. y un uso de Result, Wait o GetAwaiter/GetResult que bloquea el hilo de UI y produce un cuelgue o interbloqueoControlador de eventos de UI(WPF / WinForms)await simpleAPI de E/SCaptura el SynchronizationContext de la UITras el await, se reanuda en el hilo de UISe puede actualizar la UI directamenteawait Task.Run(...)procesamiento pesado de CPUEl cálculo corre en el ThreadPoolTras el await, se reanuda en el hilo de UIawait AlgoAsync().ConfigureAwait(false)No obliga a volver a la UILa continuación puede ser en cualquier hiloActualizar la UI directamente es peligrosohace falta Dispatcher / InvokeAlgoAsync().Result / Wait()GetAwaiter().GetResult()Bloquea el hilo de UILa continuación no puede volver a la UICuelgue / interbloqueo / al menos congelamiento

En la práctica, lo que se ve son sobre todo estos 4 patrones.

  1. await simple en un controlador de eventos de UI
  2. Usar Task.Run en un controlador de eventos de UI para descargar la CPU
  3. Quitar el destino de retorno con ConfigureAwait(false)
  4. Bloquear el hilo de UI con .Result / .Wait()

2.2. Tabla de decisión inicial

Situación Qué se ejecuta durante la espera Continuación tras await ¿Se puede tocar la UI directamente? Elección inicial
await AlgoIoAsync() en un controlador de UI Se espera a que termine la E/S. El propio hilo de UI puede volver al bucle de mensajes Básicamente, el hilo de UI await simple
await Task.Run(...) en un controlador de UI La CPU pesada va al ThreadPool Básicamente, el hilo de UI Task.Run solo para la CPU
await x.ConfigureAwait(false) en un controlador de UI No fija el destino de retorno en la UI Cualquier hilo No Evitarlo en general en código de UI
x.Result / x.Wait() en el hilo de UI El hilo de UI queda bloqueado por la espera La continuación, de entrada, apenas puede ejecutarse No No usarlos
Se quiere actualizar la UI desde un hilo en segundo plano o después de ConfigureAwait(false) Se ejecuta en un hilo distinto al de la UI Tal cual, no es el hilo de UI No Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync
Se escribe una biblioteca de propósito general independiente de la UI No depende de las circunstancias del llamador No obliga a volver a la UI Diseñarla para que no toque la UI Considerar ConfigureAwait(false)
Se quiere llamar a código async desde un constructor o una propiedad síncrona El hilo de UI tiende a entrar en espera La ruta de arranque tiende a atascarse No Trasladarlo a Loaded / Shown / InitializeAsync

Lo importante de esta tabla es que el await simple es, en realidad, un aliado en el código de UI. El enemigo no es await en sí, sino bloquear el hilo de UI de forma síncrona.

La elección en sí queda resumida en esta tabla. Los capítulos siguientes se reparten así: por qué la tabla es así (capítulos 3 y 4), cómo elegir la herramienta para volver a la UI (capítulo 5) y cómo detectarlo en una revisión (capítulos 6 y 7).

3. Términos usados en este artículo

3.1. El hilo de UI y el bucle de mensajes

La UI de WPF / WinForms tiene, básicamente, esta forma: hay un único hilo de UI, y es él quien procesa la entrada, el dibujo y los eventos.

El papel de este hilo de UI es, a grandes rasgos, el siguiente.

  • Procesa mensajes como pulsaciones de botón, entrada de teclado o solicitudes de redibujado
  • Es el único hilo desde el que se pueden tocar de forma segura los controles y los objetos de UI
  • Si se le carga con demasiado procesamiento, se detienen la actualización de pantalla y la respuesta a la entrada

Lo esencial aquí es que el trabajo del hilo de UI consiste en «girar rápido». Si se bloquea durante mucho tiempo, el ratón, el teclado y el redibujado se atascan, y desde el punto de vista del usuario la aplicación parece «congelada».

Tener esta imagen presente como un diagrama ayuda a no confundirse.

El bucle de mensajes del hilo de UI y el efecto de un procesamiento síncrono largoDiagrama que muestra cómo la entrada del usuario o una solicitud de redibujado entra en el bucle de mensajes del hilo de UI, ejecuta el controlador de eventos y actualiza la pantalla en un ciclo continuo, y cómo un procesamiento síncrono largo dentro del controlador impide que el bucle de mensajes siga girando, lo que hace que la pantalla parezca congeladaEntrada del usuario / solicitud de redibujadoBucle de mensajes del hilo de UIEjecución del controlador de eventosActualización de pantallaProcesamiento síncrono largoEl bucle de mensajes deja de girarLa pantalla parece congelada

3.2. SynchronizationContext / Dispatcher / Invoke

Los términos que aparecen con frecuencia aquí, organizados de forma práctica, son estos.

Término Significado en este contexto
Hilo de UI El hilo que creó los objetos de UI. En principio, es el único que puede tocar la UI de forma segura
Bucle de mensajes El mecanismo por el que el hilo de UI procesa los mensajes en orden
SynchronizationContext Una abstracción para «devolver el procesamiento al lugar de ejecución original»
Dispatcher La cola para el hilo de UI en WPF
Invoke / BeginInvoke / InvokeAsync Las API para enviar procesamiento al hilo de UI

Para describir con más precisión cómo se decide el destino de la continuación: lo primero que captura await (equivalente al ConfigureAwait(true) por defecto) es SynchronizationContext.Current. Solo cuando ese valor es null se consulta TaskScheduler.Current, y si no es TaskScheduler.Default, la continuación vuelve a ese TaskScheduler. Si no se da ninguno de los dos casos —es decir, SynchronizationContext.Current es null y TaskScheduler.Current es el predeterminado—, la continuación se ejecuta en el ThreadPool. En el hilo de UI de WPF / WinForms se da el primer caso: hay un SynchronizationContext de UI activo, así que en la práctica no hay problema en asumir que el SynchronizationContext de la UI está en efecto.

Poner en una tabla la correspondencia por framework lo hace más claro.

Framework Contexto del lado de la UI API representativa para volver explícitamente a la UI
WPF DispatcherSynchronizationContext Dispatcher.InvokeAsync / Dispatcher.BeginInvoke / Dispatcher.Invoke
WinForms WindowsFormsSynchronizationContext Control.BeginInvoke / Control.Invoke / .NET 9+ Control.InvokeAsync

En WPF, el centro es el Dispatcher. En WinForms, el centro son el identificador (handle) del control y el bucle de mensajes, y ahí es donde aparecen BeginInvoke / Invoke.

En la práctica, recordar la relación entre la abstracción y su implementación concreta a este nivel evita confusiones.

Relación entre el código actual, SynchronizationContext y las API concretas de WPF/WinFormsDiagrama que muestra cómo el código actual usa un SynchronizationContext, que en WPF es un DispatcherSynchronizationContext ligado a Dispatcher.InvokeAsync/BeginInvoke/Invoke, y en WinForms es un WindowsFormsSynchronizationContext ligado a Control.BeginInvoke/Invoke/InvokeAsync desde .NET 9Código actualSynchronizationContextWPF: DispatcherSynchronizationContextWinForms: WindowsFormsSynchronizationContextDispatcher.InvokeAsync / BeginInvoke / InvokeControl.BeginInvoke / Invoke / InvokeAsync (.NET 9+)

4. Patrones típicos

4.1. await simple en un controlador de eventos de UI

Es la forma más directa.

private async void LoadButton_Click(object sender, RoutedEventArgs e)
{
    LoadButton.IsEnabled = false;
    StatusText.Text = "Cargando...";

    try
    {
        string text = await File.ReadAllTextAsync(FilePathTextBox.Text);
        PreviewTextBox.Text = text;
        StatusText.Text = "Completado";
    }
    catch (Exception ex)
    {
        StatusText.Text = ex.Message;
    }
    finally
    {
        LoadButton.IsEnabled = true;
    }
}

En este código, LoadButton_Click comienza en el hilo de UI. Y como await File.ReadAllTextAsync(...) es un await simple, normalmente captura el contexto de UI vigente en ese momento.

Por eso se da esta situación:

  • Mientras se espera la E/S del archivo, no se ocupa el hilo de UI
  • La continuación tras completarse la carga vuelve, básicamente, al hilo de UI
  • Se puede escribir PreviewTextBox.Text = text; directamente

Aquí no hace falta ningún Dispatcher adicional. Si dentro de un controlador de UI solo se hizo un await simple, normalmente se puede tocar la UI tal cual.

El motivo por el que este controlador es async void es que la firma de un controlador de eventos de UI exige void; este es uno de los casos excepcionales donde async void está permitido. Precisamente por eso hay una razón clara para colocar try / catch dentro. Con async Task, la excepción viaja en el Task que se devuelve y el llamador puede recibirla al hacer await. async void no tiene ese Task, así que una excepción que escapa se vuelve a lanzar en el SynchronizationContext que existía cuando arrancó el controlador, es decir, en el hilo de UI. Una excepción no controlada en el hilo de UI llega, en WPF, a Application.DispatcherUnhandledException, y en WinForms, a Application.ThreadException; si no se maneja ahí, la aplicación se cierra.

Es decir, en un controlador async void, lo básico es capturar la excepción dentro del propio controlador; como en el ejemplo anterior, convertir el fallo en un mensaje de estado y devolver el botón a su estado en el finally cierra el flujo de UI de forma natural. El receptor global de la aplicación (DispatcherUnhandledException, etc.) debe colocarse solo como red de seguridad final.

En WinForms, el razonamiento es el mismo. Mientras se haga un await simple dentro del controlador Click, la continuación vuelve básicamente al lado de la UI.

En forma de diagrama, el flujo es este.

Secuencia de un await simple en un controlador de UIDiagrama de secuencia que muestra el hilo de UI iniciando el controlador Click, haciendo await sobre ReadAllTextAsync, reservando el regreso de la continuación al SynchronizationContext de la UI, volviendo mientras tanto al bucle de mensajes, y tras completarse la E/S, reanudando la continuación en el hilo de UI para actualizar TextBox o LabelSynchronizationContext de la UIE/S asíncronaHilo de UISynchronizationContext de la UIE/S asíncronaHilo de UIMientras espera, vuelve al bucle de mensajesInicia el controlador Clickawait sobre ReadAllTextAsyncReserva el regreso de la continuación a la UISe completa la E/SReanuda la continuación en el hilo de UIActualiza TextBox / Label

4.2. Task.Run solo para cálculos pesados de CPU

Task.Run resulta útil cuando se quiere sacar del hilo de UI un cálculo pesado de CPU.

private async void HashButton_Click(object sender, RoutedEventArgs e)
{
    HashButton.IsEnabled = false;
    ResultText.Text = "Calculando...";

    try
    {
        byte[] data = await File.ReadAllBytesAsync(InputPathTextBox.Text);

        string hash = await Task.Run(() =>
        {
            using SHA256 sha256 = SHA256.Create();
            byte[] digest = sha256.ComputeHash(data);
            return Convert.ToHexString(digest);
        });

        ResultText.Text = hash;
    }
    catch (Exception ex)
    {
        ResultText.Text = ex.Message;
    }
    finally
    {
        HashButton.IsEnabled = true;
    }
}

Lo que ocurre en este código es, a grandes rasgos, lo siguiente.

  1. El controlador de eventos comienza en el hilo de UI
  2. La espera de E/S de File.ReadAllBytesAsync se deja fluir de forma asíncrona
  3. Solo el cálculo pesado del hash se envía al ThreadPool con Task.Run
  4. La continuación de await Task.Run(...) vuelve al hilo de UI, porque es un await simple
  5. Se puede escribir ResultText.Text = hash; directamente

En otras palabras, solo lo que está dentro de Task.Run corre en otro hilo. No es que, tras el await, se pase de forma permanente a «un lugar que ya no es la UI».

Verlo en un solo diagrama ayuda a no malinterpretarlo.

Secuencia de Task.Run para un cálculo pesado de CPUDiagrama de secuencia que muestra el hilo de UI haciendo await sobre ReadAllBytesAsync y reanudando en la UI por ser un await simple, luego enviando el cálculo pesado de CPU al ThreadPool con Task.Run, recibiendo el resultado del cálculo y reanudando de nuevo en el hilo de UI para reflejar el resultado en pantallaThreadPoolE/S asíncronaHilo de UIThreadPoolE/S asíncronaHilo de UILa continuación de await Task.Run(...) se reanuda en la UIawait sobre ReadAllBytesAsyncAl ser await simple, se reanuda en la UIEnvía el cálculo pesado de CPU con Task.RunDevuelve el resultado del cálculoRefleja el resultado en pantalla

Aquí hay dos precauciones a tener en cuenta:

  • No envolver una espera de E/S con Task.Run
  • Pensar en Task.Run no como «hacer algo asíncrono», sino como «crear un destino donde descargar la CPU»

Una forma de escribir como Task.Run(async () => await File.ReadAllTextAsync(...)) solo reenvía innecesariamente la espera de E/S al ThreadPool, y apenas aporta ninguna ventaja.

4.3. ConfigureAwait(false) no es «no volver», sino «no obligar a volver»

Este es el punto que más se malinterpreta.

Antes que nada, ConfigureAwait(false) es adecuado para código de biblioteca de propósito general que no depende de la UI ni de un modelo de aplicación específico.

public sealed class DocumentRepository
{
    public async Task<string> LoadNormalizedTextAsync(string path, CancellationToken cancellationToken)
    {
        string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
        return text.Replace("\r\n", "\n", StringComparison.Ordinal);
    }
}

Este método no toca la UI. Tiene una forma que puede usarse tanto en WPF como en WinForms, ASP.NET Core o un worker. En código de este tipo, es natural agregar ConfigureAwait(false).

Y del lado de la UI, basta con llamarlo con un await simple.

private readonly DocumentRepository _repository = new();

private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
    OpenButton.IsEnabled = false;
    StatusText.Text = "Cargando...";

    try
    {
        string text = await _repository.LoadNormalizedTextAsync(
            PathTextBox.Text,
            CancellationToken.None);

        PreviewTextBox.Text = text;
        StatusText.Text = "Completado";
    }
    catch (Exception ex)
    {
        StatusText.Text = ex.Message;
    }
    finally
    {
        OpenButton.IsEnabled = true;
    }
}

Lo importante aquí es que el ConfigureAwait(false) dentro de la biblioteca no obliga a que el await del llamador también sea false.

Es decir, se logra esta separación:

  • Dentro de la biblioteca no se vuelve a la UI
  • Cuando un controlador de UI hace un await simple sobre esa llamada, la continuación del llamador sí vuelve a la UI

Por el contrario, es peligroso escribir esto directamente en el propio controlador de UI.

private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
    string text = await _repository.LoadNormalizedTextAsync(
        PathTextBox.Text,
        CancellationToken.None).ConfigureAwait(false);

    PreviewTextBox.Text = text;
}

En este caso, la continuación de ese await en OpenButton_Click no está obligada a volver a la UI. Por eso, PreviewTextBox.Text = text; puede convertirse en un acceso entre hilos.

Hay otro punto importante, aunque discreto. Agregar ConfigureAwait(false) no garantiza que siempre se pase al ThreadPool: si ese await se completa de inmediato sin esperar, la continuación puede seguir corriendo tal cual en el hilo actual. Interpretar esto como «siempre va a otro hilo» o «de aquí en adelante ya nunca es la UI» es fuente de errores; el significado es únicamente que no obliga a que la continuación de ese await vuelva al contexto de UI original, nada más.

En forma de diagrama, es así.

Efecto de agregar ConfigureAwait(false) en un controlador de UIDiagrama que muestra que, al hacer await en un controlador de UI, si no se agrega ConfigureAwait(false) la continuación vuelve básicamente al hilo de UI y permite actualizar la UI directamente, y si se agrega, la continuación no queda fija en la UI y puede reanudarse en cualquier hilo, por lo que actualizar la UI requiere Dispatcher o InvokeNoawait en un controlador de UI¿Se agrega ConfigureAwait(false)?La continuación es básicamente el hilo de UIEs fácil actualizar la UI directamenteLa continuación no queda fija en la UIPuede reanudarse en cualquier hiloActualizar la UI requiere Dispatcher / Invoke

4.4. Por qué se bloquea con .Result / .Wait() / .GetAwaiter().GetResult()

Este es el error que más se ve.

private void LoadButton_Click(object sender, RoutedEventArgs e)
{
    string text = LoadTextAsync().Result;
    PreviewTextBox.Text = text;
}

private async Task<string> LoadTextAsync()
{
    string text = await File.ReadAllTextAsync(FilePathTextBox.Text);
    return text.ToUpperInvariant();
}

A primera vista parece que solo se está obteniendo el resultado de forma síncrona, pero hacerlo en el hilo de UI es peligroso.

El flujo, en forma de diagrama, es así.

Secuencia del interbloqueo al usar .Result en el hilo de UIDiagrama de secuencia que muestra el hilo de UI llamando a LoadTextAsync, recibiendo una Task sin completar, bloqueándose al esperar con .Result, la E/S completándose y queriendo devolver la continuación a la UI, pero como el hilo de UI está ocupado con .Result la continuación no puede ejecutarse y la operación nunca se completaSynchronizationContext de la UIE/S asíncronaHilo de UISynchronizationContext de la UIE/S asíncronaHilo de UIPero la UI está ocupada con .ResultComo la continuación no puede correr, no se completaInicia LoadButton_ClickLlama a LoadTextAsync()Devuelve una Task sin completarSe bloquea esperando con .ResultSe completa la E/S, quiere devolver la continuación a la UIQuiere ejecutar la continuación

Puesto en palabras, lo que ocurre es esto.

  1. El hilo de UI llama a LoadTextAsync()
  2. El await dentro de LoadTextAsync() captura el contexto de UI
  3. El hilo de UI se queda esperando con .Result
  4. Termina la E/S
  5. La continuación de LoadTextAsync() quiere volver al hilo de UI
  6. Pero el hilo de UI está ocupado con .Result
  7. Como la continuación no puede ejecutarse, LoadTextAsync() no se completa
  8. .Result nunca termina

En otras palabras, la UI dice «espero hasta que termines» y el lado asíncrono dice «puedo terminar en cuanto vuelva a la UI»: ambos se quedan esperándose mutuamente. Es una sensación realmente desagradable.

Un malentendido habitual aquí es pensar que usar GetAwaiter().GetResult() es seguro. Pero la esencia de bloquear el hilo de UI es la misma. Lo que cambia es sobre todo cómo se envuelve la excepción.

Por eso, en la UI es más seguro tratar estos tres como si tuvieran el mismo olor:

  • .Result
  • .Wait()
  • .GetAwaiter().GetResult()

Cabe señalar que, por la misma razón, también es peligroso hacer Task.Wait() desde el hilo de UI sobre el Task del DispatcherOperation que devuelve Dispatcher.InvokeAsync(...) en WPF. InvokeAsync solo apila el delegado recibido en la cola del Dispatcher; en realidad se ejecuta cuando el hilo de UI hace girar esa cola. Si el hilo de UI está detenido en Wait(), la cola no gira, así que ese Task nunca se completa. Lo mismo se aplica al propio DispatcherOperation: DispatcherOperation.Wait() está documentado explícitamente para lanzar InvalidOperationException cuando se espera una operación que se está ejecutando en el mismo hilo. Es decir, la propia ruta de bloquear y esperar no está contemplada. En el contexto de la UI, lo que tiende a atascarse es, precisamente, la dirección misma de «esperar de forma síncrona algo que se acaba de enviar». Para una explicación detallada de cómo se produce el atasco, Await, and UI, and deadlocks! Oh my! se lee bien.

En cuanto a si esto siempre produce un interbloqueo, no necesariamente es así. Si por casualidad el código tiene una continuación que no vuelve a la UI, a veces no hay interbloqueo, solo se congela la UI. Pero eso también es suficientemente molesto, así que en la UI, por norma general, es mejor no hacerlo.

5. Cuándo usar Dispatcher / Invoke

Con esto en mente, en un controlador de UI con await simple, normalmente no hace falta un Dispatcher / Invoke explícito.

Se vuelve necesario, por ejemplo, en estos casos:

  • Se quiere tocar la UI en la continuación de un ConfigureAwait(false)
  • Se ha organizado el código de modo que ni dentro de Task.Run ni fuera de él se vuelve a la UI
  • La notificación llega desde un lugar que, de entrada, no es el hilo de UI, como una recepción de socket, un temporizador o una devolución de llamada de un evento
  • En una capa donde se separó deliberadamente la UI de lo que no es UI, y solo se quiere hacer explícita la actualización final de la UI

En WPF, el representante es Dispatcher.InvokeAsync.

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);

    await Dispatcher.InvokeAsync(() =>
    {
        PreviewTextBox.Text = text;
        StatusText.Text = "Completado";
    });
}

En WinForms con .NET 9 o posterior, InvokeAsync encaja de forma natural con el flujo async.

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);

    await previewTextBox.InvokeAsync(() =>
    {
        previewTextBox.Text = text;
        statusLabel.Text = "Completado";
    });
}

En el patrón tradicional de WinForms se usa BeginInvoke. Invoke es un envío síncrono que hace esperar al llamador. BeginInvoke publica el delegado y devuelve el control de inmediato. En un flujo async, en general encaja mejor el lado que no bloquea.

Sin embargo, lo que devuelve Control.BeginInvoke es un IAsyncResult, así que no se puede hacer await directamente sobre él. Si se quiere incorporar al flujo async en un entorno sin Control.InvokeAsync (.NET Framework 4.8, .NET 6 / 8, etc.), lo más directo es envolverlo con TaskCompletionSource para convertirlo en un Task.

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

public static class ControlUiExtensions
{
    // Usamos la versión genérica de TaskCompletionSource para que también
    // funcione tal cual en .NET Framework 4.8. Desde .NET 5 también podría
    // escribirse con la versión no genérica.
    //
    // No hacemos que cancellationToken sea opcional. Si el control se destruye
    // después de que BeginInvoke lo aceptó, el delegado publicado se descarta
    // sin ejecutarse, y TaskCompletionSource no recibe ni resultado ni excepción.
    // Sin una vía para cancelar, el lado que hace await se queda esperando para
    // siempre
    public static Task InvokeOnUiAsync(
        this Control control, Action action, CancellationToken cancellationToken)
    {
        if (control is null)
        {
            throw new ArgumentNullException(nameof(control));
        }

        if (action is null)
        {
            throw new ArgumentNullException(nameof(action));
        }

        if (!control.IsHandleCreated)
        {
            throw new InvalidOperationException("El identificador de ventana aún no se ha creado.");
        }

        if (!control.InvokeRequired)
        {
            action();
            return Task.CompletedTask;
        }

        // Para que la continuación del lado que hizo await no se ejecute
        // directamente sobre el hilo de UI, dejamos explícito que la
        // continuación fluye de forma asíncrona.
        var tcs = new TaskCompletionSource<bool>(
            TaskCreationOptions.RunContinuationsAsynchronously);

        // Hacemos que la cancelación y la ejecución compitan por un mismo
        // "derecho de una sola vez". Con Interlocked.Exchange, solo avanza
        // el lado que consiga escribir 1 primero. Si en cambio se comprobara
        // una bandera y luego se llamara a action(), quedaría abierta la vía
        // en la que, si la cancelación llega justo después de comprobarla,
        // "el llamador recibe la cancelación y empieza la siguiente operación,
        // pero el delegado antiguo reescribe la pantalla más tarde"
        int claimed = 0;   // 0 = sin decidir / 1 = uno de los dos ya lo tomó

        // Si se cancela, el Task se cierra aunque el delegado no llegue a
        // ejecutarse. El registro se retira siempre al completarse el Task
        // (si no se retira, seguiría reteniendo tcs mientras el token esté
        // vivo). CancellationTokenRegistration.Dispose es seguro entre hilos,
        // así que puede llamarse desde cualquier hilo
        CancellationTokenRegistration registration = cancellationToken.Register(() =>
        {
            if (Interlocked.Exchange(ref claimed, 1) == 0)
            {
                tcs.TrySetCanceled(cancellationToken);
            }
        });

        tcs.Task.ContinueWith(
            _ => registration.Dispose(),
            CancellationToken.None,
            TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default);

        try
        {
            control.BeginInvoke(new Action(() =>
            {
                // Puede ocurrir una cancelación entre el momento en que se
                // publica y el momento en que el hilo de UI lo ejecuta. Si aquí
                // no se puede tomar el derecho, significa que la cancelación lo
                // tomó primero, así que se vuelve sin tocar la pantalla en
                // absoluto
                if (Interlocked.Exchange(ref claimed, 1) != 0)
                {
                    return;
                }

                try
                {
                    action();
                    tcs.TrySetResult(true);
                }
                catch (Exception ex)
                {
                    tcs.TrySetException(ex);
                }
            }));
        }
        catch (Exception ex)
        {
            // BeginInvoke en sí también puede lanzar una excepción (por
            // ejemplo, si el handle ya no existe). Si no se cierra aquí,
            // igualmente se quedaría esperando para siempre. Como el delegado
            // no llega a ejecutarse, aquí también se toma el derecho antes de
            // cerrar
            if (Interlocked.Exchange(ref claimed, 1) == 0)
            {
                tcs.TrySetException(ex);
            }
        }

        return tcs.Task;
    }
}

El lado que llama queda con una forma casi idéntica al ejemplo de InvokeAsync. Vincule el token al ciclo de vida del formulario.

// Campo del formulario. Se cancela al cerrar
private readonly CancellationTokenSource _formClosing = new();

protected override void OnFormClosed(FormClosedEventArgs e)
{
    // Para poder cerrar el lado que hace await incluso si el delegado
    // publicado se descarta sin ejecutarse
    _formClosing.Cancel();
    base.OnFormClosed(e);
}

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    using var linked = CancellationTokenSource.CreateLinkedTokenSource(
        cancellationToken, _formClosing.Token);

    string text = await File.ReadAllTextAsync(path, linked.Token).ConfigureAwait(false);

    await previewTextBox.InvokeOnUiAsync(() =>
    {
        previewTextBox.Text = text;
        statusLabel.Text = "Completado";
    }, linked.Token);
}

Con esta forma, incluso las excepciones que ocurren del lado de la UI se pueden recibir en el try / catch del lugar donde se hizo await. Hay cuatro puntos que conviene tener presentes.

  • No basta con que la cancelación y la ejecución «comprueben una bandera y luego actúen». Justo después de comprobar «¿ya está cancelado?», antes de llamar a action(), puede producirse la cancelación. En ese instante, tcs queda cancelado y el llamador que hacía await avanza y empieza la siguiente operación. Después de eso, el delegado antiguo que todavía quedaba en la cola se ejecuta y reescribe la pantalla ── una forma de fallo difícil de reproducir en la que la pantalla nueva queda sobrescrita por la pantalla antigua. Por eso el código anterior hace que la cancelación y la ejecución compitan por «un derecho de una sola vez» con Interlocked.Exchange: el lado que no consigue el derecho se vuelve sin hacer nada
  • Llamar a BeginInvoke antes de que se cree el handle (antes de Load) o después de cerrar el formulario provoca una excepción. Tenga presente el ciclo de vida del lado que llama
  • Si el control se destruye después de haberse publicado el delegado, este puede descartarse sin llegar a ejecutarse. En ese caso, TaskCompletionSource no recibe ni resultado ni excepción, por lo que el lado que hace await espera para siempre. Pase siempre, como en el ejemplo anterior, un token vinculado al cierre del formulario. El resultado del cierre se propaga como OperationCanceledException
  • File.ReadAllTextAsync es una API disponible desde .NET Core 2.0. Si se necesita la misma forma en .NET Framework 4.8, reemplácela por algo como StreamReader.ReadToEndAsync

Como criterio de distinción, basta con este nivel de detalle.

Qué se quiere hacer WPF WinForms
Entrar en la UI de forma síncrona Dispatcher.Invoke Control.Invoke
Enviar a la UI de forma asíncrona Dispatcher.InvokeAsync / Dispatcher.BeginInvoke Control.BeginInvoke / .NET 9+ Control.InvokeAsync
Encajar de forma natural con async / await Dispatcher.InvokeAsync .NET 9+ Control.InvokeAsync; antes de eso, BeginInvoke

Como criterio práctico,

  • No hace falta si en el controlador de UI solo se hace await simple
  • Se usa cuando se quiere tocar la UI desde un lugar que no es la UI
  • No abusar de Invoke síncrono dentro de un flujo async

Con esto se reducen bastante los errores.

Si hay dudas, basta con un diagrama de decisión de este nivel.

Diagrama de decisión para volver o no a la UIDiagrama de decisión que pregunta si el lugar donde se escribe la continuación es el hilo de UI, y si es así permite actualizar la UI con await simple, y si no lo es, pregunta si se quiere tocar la UI, continuando el procesamiento tal cual si no y usando Dispatcher.InvokeAsync en WPF o BeginInvoke/InvokeAsync en WinForms si síNoNo¿El lugar donde se escribe esta continuación es el hilo de UI?¿Sí?Se puede actualizar la UI con await simple¿Se quiere tocar la UI?Continuar el procesamiento tal cualWPF: Dispatcher.InvokeAsyncWinForms: BeginInvoke / InvokeAsync

6. Antipatrones habituales

Antipatrón Qué lo hace problemático Sustituto recomendado
LoadAsync().Result en un controlador de UI Bloquea el hilo de UI. Es propenso a interbloqueos await LoadAsync()
LoadAsync().Wait() en un controlador de UI Lo mismo. Detiene el bucle de mensajes await LoadAsync()
LoadAsync().GetAwaiter().GetResult() en un controlador de UI Solo cambia cómo se ve la excepción; el bloqueo es el mismo await LoadAsync()
Agregar ConfigureAwait(false) mecánicamente al código de UI La actualización de la UI tras el await se rompe con facilidad await simple en la capa más externa de la UI
Task.Run(async () => await IoAsync()) Reenvía la E/S innecesariamente await IoAsync()
El código de biblioteca sostiene directamente Dispatcher o Control La dependencia de la UI se profundiza. Es difícil de reutilizar La biblioteca solo devuelve datos, y el marshal se hace del lado de la UI
Usar Dispatcher.Invoke / Control.Invoke en exceso dentro de un flujo async Es fácil que se forme una cadena de bloqueos Considerar Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync
Sincronizar código async en un constructor o en un getter de propiedad Es un caldo de cultivo para cuelgues en el arranque Trasladarlo a Loaded / Shown / InitializeAsync

De todos estos, hay tres con una frecuencia de aparición especialmente alta:

  1. .Result / .Wait() en el hilo de UI
  2. Agregar ConfigureAwait(false) de forma mecánica al código de UI
  3. Que se mezclen las responsabilidades de biblioteca y UI, y Dispatcher termine invadiendo capas profundas

Con solo eliminar estos tres, el código se vuelve mucho más estable.

7. Lista de verificación para la revisión de código

El contenido es el mismo que la tabla de decisión de 2.2 y los antipatrones del capítulo 6, pero aquí está en forma de preguntas para revisar en orden al abrir el código.

  • ¿Queda algún .Result / .Wait() / .GetAwaiter().GetResult() en los controladores de eventos de UI o en las rutas de inicialización de la UI?
  • ¿Se usa Task.Run solo para cálculos de CPU? ¿No está envolviendo E/S?
  • ¿ConfigureAwait(false) no se ha introducido de forma mecánica en código de UI?
  • A la inversa, ¿una biblioteca de propósito general no arrastra una dependencia del contexto de UI?
  • En los lugares donde se toca la UI directamente después de un await, ¿se puede afirmar con certeza que ese punto está sobre el contexto de UI?
  • En los puntos donde hace falta volver explícitamente a la UI, ¿se están usando Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync?
  • ¿No se ha incrementado innecesariamente el marshal síncrono como Dispatcher.Invoke / Control.Invoke?
  • ¿No se está forzando la sincronización de código async desde un constructor, una propiedad síncrona o un evento síncrono?
  • ¿La capa de biblioteca no hace referencia directa a Window / Control / Dispatcher?

Esta lista de verificación también es útil para que el equipo se alinee sobre «qué es responsabilidad de la UI».

8. Resumen práctico de cuándo usar cada cosa

La elección según cada situación ya está resumida en la tabla de decisión de 2.2, así que aquí solo dejamos las reglas mnemotécnicas para llevarse.

  • La capa más externa de la UI usa await simple. Poder tocar la UI directamente después de await se debe a mantener esto
  • Task.Run es un destino para descargar la CPU. No es una herramienta para envolver esperas de E/S
  • ConfigureAwait(false) es una herramienta de las bibliotecas de propósito general. No se agrega de forma mecánica al código de UI
  • Dispatcher / BeginInvoke / InvokeAsync se usan solo cuando se toca la UI desde un lugar que no es la UI
  • No se usan los tres que esperan en el hilo de UI (.Result / .Wait() / .GetAwaiter().GetResult()). Si surge la tentación de sincronizar, extienda async hasta el propio llamador

La parte de los motivos está en el capítulo 4, cómo elegir entre Dispatcher / Invoke está en el capítulo 5, y los criterios para detectarlo en código real están en los capítulos 6 y 7.

9. Resumen

Lo verdaderamente importante en async / await de WPF / WinForms no es la sensación de que «lo asíncrono es difícil», sino pensar por separado en:

  • Dónde empezó la ejecución ahora mismo
  • A dónde vuelve la continuación de await
  • Quién tiene la responsabilidad de devolver el control a la UI

Como reglas iniciales, basta con respetar estas para desenvolverse bien.

  1. await simple en la capa más externa de la UI
  2. Task.Run solo para la CPU pesada
  3. Considerar ConfigureAwait(false) en las bibliotecas de propósito general
  4. Dispatcher / BeginInvoke / InvokeAsync únicamente cuando haga falta volver a la UI
  5. No usar .Result / .Wait() / .GetAwaiter().GetResult() en el hilo de UI

async / await en sí mismo no es un mecanismo tan complicado. Sin embargo, si se usa sin poner al hilo de UI en el centro de la mirada, de repente se convierte en un lodazal.

Dicho de otro modo,

  • Separar el exterior y el interior de la UI
  • Tener presente el destino de retorno
  • No introducir bloqueos

Con solo respetar estos tres puntos, el código asíncrono de WPF / WinForms se vuelve bastante más tranquilo. El código que congela la pantalla, en general, no es que «lo asíncrono sea malo»: simplemente la forma de pedir prestado tiempo al hilo de UI es descuidada.

10. Referencias

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿A qué hilo se vuelve después de await?
Cuando se hace un await simple (sin ConfigureAwait) en un controlador de eventos de UI de WPF o WinForms, la continuación después de await vuelve básicamente al hilo de UI. Esto se debe a que await captura el SynchronizationContext de la UI vigente en ese momento y devuelve la continuación a él, por lo que después de await se puede actualizar un TextBox o un Label directamente. Con await Task.Run(...) ocurre lo mismo: el cálculo en sí se ejecuta en el ThreadPool, pero si el await es simple, la continuación se reanuda en el hilo de UI.
¿Por qué se bloquea la aplicación al usar .Result o .Wait() en el hilo de UI?
Porque mientras el hilo de UI espera con .Result, la continuación del procesamiento asíncrono intenta volver al contexto de UI que capturó, pero como el hilo de UI está bloqueado por .Result, esa continuación no puede ejecutarse: ambos se quedan esperándose mutuamente y se produce un interbloqueo (deadlock). GetAwaiter().GetResult() tiene la misma esencia de bloquear el hilo de UI; lo único que cambia es cómo se envuelve la excepción. En la UI conviene evitar los tres —.Result, .Wait() y GetAwaiter().GetResult()— y usar await.
¿Debe agregarse ConfigureAwait(false) al código de UI?
Es preferible no agregarlo. ConfigureAwait(false) significa que no se obliga a volver al contexto de UI capturado, por lo que la continuación puede reanudarse en cualquier hilo, y una actualización de UI justo después podría convertirse en un acceso entre hilos (cross-thread). Este método es adecuado para código de biblioteca de propósito general que no depende de la UI; la política es mantener la capa más externa de la UI con await simple.
¿Cuándo debe usarse Task.Run?
Solo cuando se quiere sacar del hilo de UI un cálculo pesado de CPU. Envolver una espera de E/S con Task.Run solo reenvía esa espera al ThreadPool sin necesidad, sin ninguna ventaja. Únicamente lo que está dentro de Task.Run corre en otro hilo; la continuación de await Task.Run(...), si el await es simple, normalmente vuelve al hilo de UI, así que reflejar el resultado en pantalla se puede escribir tal cual.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog