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
Dispatcherde WPF y losInvoke/BeginInvoke/InvokeAsyncde 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
- Primero, la conclusión (en pocas palabras)
- Primero, un resumen en una imagen
- 2.1. Panorama general
- 2.2. Tabla de decisión inicial
- Términos usados en este artículo
- 3.1. El hilo de UI y el bucle de mensajes
- 3.2.
SynchronizationContext/Dispatcher/Invoke
- Patrones típicos
- 4.1.
awaitsimple en un controlador de eventos de UI - 4.2.
Task.Runsolo 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()
- 4.1.
- Cuándo usar
Dispatcher/Invoke - Antipatrones habituales
- Lista de verificación para la revisión de código
- Resumen práctico de cuándo usar cada cosa
- Resumen
- Referencias
1. Primero, la conclusión (en pocas palabras)
- Si se hace un
awaitsimple en un controlador de eventos de UI de WPF / WinForms, puede asumirse que la continuación después deawaitvuelve básicamente al hilo de UI Task.Runsirve 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 eseawaites simple, la continuación normalmente vuelve al hilo de UI ConfigureAwait(false)significa que no obliga a volver al contexto de UI capturado por eseawait. 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 unawaitnecesita 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,InvokeAsynccombina bien con el flujo async - La política de partida es: mantener
awaitsimple en la capa más externa de la UI, considerarConfigureAwait(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:
- En qué hilo se está ejecutando ahora mismo
- A dónde vuelve la continuación de
await - 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.
flowchart LR
accTitle: Cuatro patrones de await en WPF/WinForms y a dónde vuelven
accDescr: Diagrama 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 interbloqueo
A["Controlador de eventos de UI<br/>(WPF / WinForms)"] --> B["await simple<br/>API de E/S"]
B --> C["Captura el SynchronizationContext de la UI"]
C --> D["Tras el await, se reanuda en el hilo de UI"]
D --> E["Se puede actualizar la UI directamente"]
A --> F["await Task.Run(...)<br/>procesamiento pesado de CPU"]
F --> G["El cálculo corre en el ThreadPool"]
G --> H["Tras el await, se reanuda en el hilo de UI"]
H --> E
A --> I["await AlgoAsync().ConfigureAwait(false)"]
I --> J["No obliga a volver a la UI"]
J --> K["La continuación puede ser en cualquier hilo"]
K --> L["Actualizar la UI directamente es peligroso<br/>hace falta Dispatcher / Invoke"]
A --> M["AlgoAsync().Result / Wait()<br/>GetAwaiter().GetResult()"]
M --> N["Bloquea el hilo de UI"]
N --> O["La continuación no puede volver a la UI"]
O --> P["Cuelgue / interbloqueo / al menos congelamiento"]
En la práctica, lo que se ve son sobre todo estos 4 patrones.
awaitsimple en un controlador de eventos de UI- Usar
Task.Runen un controlador de eventos de UI para descargar la CPU - Quitar el destino de retorno con
ConfigureAwait(false) - 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 | Sí | await simple |
await Task.Run(...) en un controlador de UI |
La CPU pesada va al ThreadPool | Básicamente, el hilo de UI | Sí | 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.
flowchart LR
accTitle: El bucle de mensajes del hilo de UI y el efecto de un procesamiento síncrono largo
accDescr: Diagrama 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 congelada
A["Entrada del usuario / solicitud de redibujado"] --> B["Bucle de mensajes del hilo de UI"]
B --> C["Ejecución del controlador de eventos"]
C --> D["Actualización de pantalla"]
D --> B
C --> E["Procesamiento síncrono largo"]
E --> F["El bucle de mensajes deja de girar"]
F --> G["La 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.
flowchart TD
accTitle: Relación entre el código actual, SynchronizationContext y las API concretas de WPF/WinForms
accDescr: Diagrama 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 9
A["Código actual"] --> B["SynchronizationContext"]
B --> C["WPF: DispatcherSynchronizationContext"]
B --> D["WinForms: WindowsFormsSynchronizationContext"]
C --> E["Dispatcher.InvokeAsync / BeginInvoke / Invoke"]
D --> F["Control.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.
sequenceDiagram
accTitle: Secuencia de un await simple en un controlador de UI
accDescr: Diagrama 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 Label
participant UI as Hilo de UI
participant IO as E/S asíncrona
participant Ctx as SynchronizationContext de la UI
UI->>UI: Inicia el controlador Click
UI->>IO: await sobre ReadAllTextAsync
UI-->>Ctx: Reserva el regreso de la continuación a la UI
Note over UI: Mientras espera, vuelve al bucle de mensajes
IO-->>Ctx: Se completa la E/S
Ctx-->>UI: Reanuda la continuación en el hilo de UI
UI->>UI: Actualiza 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.
- El controlador de eventos comienza en el hilo de UI
- La espera de E/S de
File.ReadAllBytesAsyncse deja fluir de forma asíncrona - Solo el cálculo pesado del hash se envía al ThreadPool con
Task.Run - La continuación de
await Task.Run(...)vuelve al hilo de UI, porque es unawaitsimple - 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.
sequenceDiagram
accTitle: Secuencia de Task.Run para un cálculo pesado de CPU
accDescr: Diagrama 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 pantalla
participant UI as Hilo de UI
participant IO as E/S asíncrona
participant Pool as ThreadPool
UI->>IO: await sobre ReadAllBytesAsync
IO-->>UI: Al ser await simple, se reanuda en la UI
UI->>Pool: Envía el cálculo pesado de CPU con Task.Run
Pool-->>UI: Devuelve el resultado del cálculo
Note over UI: La continuación de await Task.Run(...) se reanuda en la UI
UI->>UI: Refleja 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.Runno 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
awaitsimple 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í.
flowchart LR
accTitle: Efecto de agregar ConfigureAwait(false) en un controlador de UI
accDescr: Diagrama 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 Invoke
A["await en un controlador de UI"] --> B{"¿Se agrega ConfigureAwait(false)?"}
B -- No --> C["La continuación es básicamente el hilo de UI"]
C --> D["Es fácil actualizar la UI directamente"]
B -- Sí --> E["La continuación no queda fija en la UI"]
E --> F["Puede reanudarse en cualquier hilo"]
F --> G["Actualizar 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í.
sequenceDiagram
accTitle: Secuencia del interbloqueo al usar .Result en el hilo de UI
accDescr: Diagrama 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 completa
participant UI as Hilo de UI
participant IO as E/S asíncrona
participant Ctx as SynchronizationContext de la UI
UI->>UI: Inicia LoadButton_Click
UI->>IO: Llama a LoadTextAsync()
IO-->>UI: Devuelve una Task sin completar
UI->>UI: Se bloquea esperando con .Result
IO-->>Ctx: Se completa la E/S, quiere devolver la continuación a la UI
Ctx-->>UI: Quiere ejecutar la continuación
Note over UI: Pero la UI está ocupada con .Result
Note over UI, Ctx: Como la continuación no puede correr, no se completa
Puesto en palabras, lo que ocurre es esto.
- El hilo de UI llama a
LoadTextAsync() - El
awaitdentro deLoadTextAsync()captura el contexto de UI - El hilo de UI se queda esperando con
.Result - Termina la E/S
- La continuación de
LoadTextAsync()quiere volver al hilo de UI - Pero el hilo de UI está ocupado con
.Result - Como la continuación no puede ejecutarse,
LoadTextAsync()no se completa .Resultnunca 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.Runni 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,tcsqueda cancelado y el llamador que hacíaawaitavanza 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» conInterlocked.Exchange: el lado que no consigue el derecho se vuelve sin hacer nada - Llamar a
BeginInvokeantes de que se cree el handle (antes deLoad) 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,
TaskCompletionSourceno recibe ni resultado ni excepción, por lo que el lado que haceawaitespera para siempre. Pase siempre, como en el ejemplo anterior, un token vinculado al cierre del formulario. El resultado del cierre se propaga comoOperationCanceledException File.ReadAllTextAsynces una API disponible desde .NET Core 2.0. Si se necesita la misma forma en .NET Framework 4.8, reemplácela por algo comoStreamReader.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
awaitsimple - Se usa cuando se quiere tocar la UI desde un lugar que no es la UI
- No abusar de
Invokesí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.
flowchart TD
accTitle: Diagrama de decisión para volver o no a la UI
accDescr: Diagrama 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í
A["¿El lugar donde se escribe esta continuación es el hilo de UI?"] --> B{"¿Sí?"}
B -- Sí --> C["Se puede actualizar la UI con await simple"]
B -- No --> D{"¿Se quiere tocar la UI?"}
D -- No --> E["Continuar el procesamiento tal cual"]
D -- Sí --> F["WPF: Dispatcher.InvokeAsync"]
D -- Sí --> G["WinForms: 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:
.Result/.Wait()en el hilo de UI- Agregar
ConfigureAwait(false)de forma mecánica al código de UI - Que se mezclen las responsabilidades de biblioteca y UI, y
Dispatchertermine 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.Runsolo 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
awaitsimple. Poder tocar la UI directamente después deawaitse debe a mantener esto Task.Runes un destino para descargar la CPU. No es una herramienta para envolver esperas de E/SConfigureAwait(false)es una herramienta de las bibliotecas de propósito general. No se agrega de forma mecánica al código de UIDispatcher/BeginInvoke/InvokeAsyncse 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, extiendaasynchasta 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.
awaitsimple en la capa más externa de la UITask.Runsolo para la CPU pesada- Considerar
ConfigureAwait(false)en las bibliotecas de propósito general Dispatcher/BeginInvoke/InvokeAsyncúnicamente cuando haga falta volver a la UI- 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
- Conjunto completo de código de ejemplo de este artículo (biblioteca independiente de la UI, ejemplos de WPF / WinForms, pruebas unitarias) - komurasoft-blog-samples (GitHub)
- Artículo relacionado: Tabla práctica de decisiones sobre async/await en C# - Task.Run y ConfigureAwait
- Threading Model - WPF
- DispatcherSynchronizationContext Class
- How to handle cross-thread operations with controls - Windows Forms
- WindowsFormsSynchronizationContext Class
- Events Overview - Windows Forms
- TaskScheduler.FromCurrentSynchronizationContext Method
- ConfigureAwait FAQ
- How Async/Await Really Works in C#
- Await, and UI, and deadlocks! Oh my!
- Threading model for WebView2 apps
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
Práctica de CI/CD para aplicaciones WinForms / WPF — automatizar desde la compilación hasta la firma y la distribución con GitHub Actions
CI/CD para WinForms/WPF con GitHub Actions: build y pruebas, versión por tags, firma con signtool y tabla de decisión por formato de dist...
Iconos de la bandeja del sistema y notificaciones toast en aplicaciones Windows — los escollos de NotifyIcon y cómo elegir el AppNotification adecuado
Organiza la implementación de la residencia en la bandeja del sistema y las notificaciones toast en aplicaciones Windows empresariales: e...
Internacionalización de aplicaciones WinForms/WPF — la práctica de resx, ensamblados satélite y el cambio de cultura
Organizamos la internacionalización de aplicaciones de escritorio Windows: la diferencia entre CurrentCulture y CurrentUICulture, el meca...
Integrar la autenticación de Entra ID en aplicaciones WinForms/WPF — Configuración práctica con MSAL.NET y el bróker WAM
Cómo integrar Entra ID en apps WinForms/WPF: cliente público, registro de la app, AcquireTokenSilent, bróker WAM y persistencia de la cac...
Pruebas de UI automatizadas para aplicaciones de escritorio de Windows ── el funcionamiento de UI Automation y pruebas resistentes a roturas con FlaUI
Organizamos las pruebas de UI automatizadas para apps WinForms/WPF desde el funcionamiento de Windows UI Automation: implementación mínim...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Hilo de UI y temporizadores
Hilo de UI de WPF / WinForms, flujos asíncronos, Dispatcher y diseño de temporizadores.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
El hilo de UI y async/await en WPF / WinForms son uno de los puntos donde más se atasca la implementación de desarrollo de aplicaciones Windows.
Consultoría técnica y revisión de diseño
Si está en la etapa de aclarar las responsabilidades entre la UI y el procesamiento en segundo plano, o cómo usar Dispatcher, esto puede revisarse como una consultoría técnica y una revisión de diseño.
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.