Quand on utilise async / await dans WPF / WinForms, ce qui prête le plus à confusion, c’est sur quel thread l’exécution revient après await, et à quel moment il est sûr de toucher l’UI.
Et dès que Dispatcher, BeginInvoke, ConfigureAwait(false) et .Result / .Wait() se mélangent, les causes de gel de l’écran ou d’exceptions inter-thread deviennent difficiles à repérer.
Cet article se concentre uniquement sur la relation entre le thread UI de WPF / WinForms et async / await.
Pour les critères de décision globaux concernant async / await, voir l’article associé C# async/await : bonnes pratiques - tableau de décision pour Task.Run et ConfigureAwait.
Ce qui sent vraiment le vécu douloureux dans la pratique, c’est à peu près ceci.
- On ne sait pas où la suite s’exécute après
await - On ne sait pas si l’on peut toucher l’UI après être passé par
Task.Run - On hésite sur l’endroit où placer
ConfigureAwait(false) - L’écran se fige à cause de
.Result/.Wait()/.GetAwaiter().GetResult() - Le
Dispatcherde WPF et lesInvoke/BeginInvoke/InvokeAsyncde WinForms se mélangent dans la tête
WPF et WinForms sont tous deux des modèles centrés sur le thread UI.
Ainsi, pour clarifier async / await, ce qui est le plus efficace n’est pas un discours quasi philosophique sur « ce qu’est l’asynchrone », mais le fait de rendre explicite ce que l’on fait concrètement au thread UI et à la boucle de messages.
Cet article suppose principalement des applications WPF / WinForms sur .NET 6 ou ultérieur, et parcourt, dans un ordre utile en pratique, l’endroit où revient l’exécution après await, le Dispatcher, ConfigureAwait(false), et les raisons pour lesquelles .Result / .Wait() se bloquent.
Notez que Control.InvokeAsync de WinForms n’est disponible qu’à partir de .NET 9.
Sur les versions antérieures de WinForms, on utilise essentiellement BeginInvoke / Invoke.
Par ailleurs, le code présenté dans cet article est publié sur GitHub sous la forme d’un ensemble d’exemples complets, compilables et exécutables (une bibliothèque indépendante de l’UI, des exemples WPF / WinForms, et des tests unitaires reproduisant les points de retour de await ainsi que le blocage mutuel).
wpf-winforms-ui-thread-async-await-one-sheet - komurasoft-blog-samples (GitHub)
Table des matières
- D’abord, la conclusion (en une phrase)
- D’abord, tout sur une fiche
- 2.1. Vue d’ensemble
- 2.2. Le tableau de décision de départ
- Termes utilisés dans cet article
- 3.1. Le thread UI et la boucle de messages
- 3.2.
SynchronizationContext/Dispatcher/Invoke
- Schémas typiques
- 4.1. Plain
awaitdans un gestionnaire d’événements UI - 4.2.
Task.Rununiquement pour les calculs CPU lourds - 4.3.
ConfigureAwait(false)ne « garantit pas l’absence de retour », il « ne force pas le retour » - 4.4. Pourquoi
.Result/.Wait()/.GetAwaiter().GetResult()bloquent
- 4.1. Plain
- Quand utiliser
Dispatcher/Invoke - Anti-patterns courants
- Liste de vérification pour la revue
- Aide-mémoire rapide
- Conclusion
- Références
1. D’abord, la conclusion (en une phrase)
- Avec un plain
awaitdans un gestionnaire d’événements UI de WPF / WinForms, on peut considérer que la suite aprèsawaitrevient essentiellement au thread UI Task.Runsert à sortir un calcul CPU du thread UI ; ce n’est pas un outil pour envelopper une attente d’E/S- Même avec
await Task.Run(...)dans un gestionnaire UI, si cetawaitreste un plainawait, la suite revient normalement au thread UI ConfigureAwait(false)signifie que cetawaitne force pas le retour vers le contexte UI capturé. Toucher directement l’UI dans la suite après cet appel est dangereux.Result/.Wait()/.GetAwaiter().GetResult()bloquent le thread UI. Si la continuation de l’awaitdoit revenir sur l’UI, cela se bloque assez couramment- Pour revenir explicitement à l’UI en WPF, utilisez
Dispatcher.InvokeAsync - Pour revenir explicitement à l’UI en WinForms, la manière traditionnelle est
BeginInvoke; à partir de .NET 9,InvokeAsyncs’harmonise bien avec le flux async - La politique de départ est : plain
awaità la couche la plus externe de l’UI, envisagerConfigureAwait(false)dans les bibliothèques génériques, et ne rendre le retour vers l’UI explicite que là où c’est nécessaire
En résumé, dans WPF / WinForms, si l’on garde à l’œil
- Sur quel thread on s’exécute actuellement
- Où revient la suite de l’
await - Qui porte la responsabilité du retour vers l’UI
ces trois points suffisent à rendre la vision beaucoup plus claire.
2. D’abord, tout sur une fiche
2.1. Vue d’ensemble
Le plus rapide est d’abord de saisir la vue d’ensemble grâce à ce schéma.
flowchart LR
A["Gestionnaire d'événements UI<br/>(WPF / WinForms)"] --> B["plain await<br/>API d'E/S"]
B --> C["Capture le UI SynchronizationContext"]
C --> D["Reprend sur le thread UI après await"]
D --> E["Peut écrire la mise à jour de l'UI directement"]
A --> F["await Task.Run(...)<br/>traitement CPU lourd"]
F --> G["Le calcul s'exécute sur le ThreadPool"]
G --> H["Reprend sur le thread UI après await"]
H --> E
A --> I["await SomeAsync().ConfigureAwait(false)"]
I --> J["Ne force pas le retour vers l'UI"]
J --> K["La suite reprend sur un thread quelconque"]
K --> L["Mise à jour directe de l'UI dangereuse<br/>Dispatcher / Invoke nécessaire"]
A --> M["SomeAsync().Result / Wait()<br/>GetAwaiter().GetResult()"]
M --> N["Bloque le thread UI"]
N --> O["La continuation ne peut pas revenir sur l'UI"]
O --> P["Hang / interblocage / au minimum un gel"]
Ce que l’on rencontre en pratique se résume à peu près à ces 4 schémas.
- Plain
awaitdans un gestionnaire d’événements UI - Utiliser
Task.Rundans un gestionnaire d’événements UI pour faire échapper le CPU - Retirer le point de retour avec
ConfigureAwait(false) - Bloquer le thread UI avec
.Result/.Wait()
2.2. Le tableau de décision de départ
| Situation | Ce qui s’exécute pendant l’attente | Suite après await |
Peut-on toucher l’UI directement ? | Premier choix |
|---|---|---|---|---|
await SomeIoAsync() dans un gestionnaire UI |
Attente de la fin de l’E/S. Le thread UI lui-même peut revenir à la boucle de messages | Essentiellement le thread UI | Oui | plain await |
await Task.Run(...) dans un gestionnaire UI |
Le CPU lourd part sur le ThreadPool | Essentiellement le thread UI | Oui | Task.Run uniquement pour le CPU |
await x.ConfigureAwait(false) dans un gestionnaire UI |
Le point de retour n’est pas fixé sur l’UI | Un thread quelconque | Non | À éviter en général dans le code UI |
x.Result / x.Wait() sur le thread UI |
Le thread UI est bloqué par l’attente | La continuation a du mal à s’exécuter, pour commencer | Non | À ne pas utiliser |
Vouloir mettre à jour l’UI depuis un thread d’arrière-plan ou après ConfigureAwait(false) |
S’exécute sur un thread différent de l’UI | Ce n’est pas l’UI tel quel | Non | Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync |
Ce qui compte dans ce tableau, c’est que le plain await est en fait un allié dans le code UI.
L’ennemi n’est pas await en lui-même, mais le fait de bloquer le thread UI de façon synchrone.
3. Termes utilisés dans cet article
3.1. Le thread UI et la boucle de messages
L’UI de WPF / WinForms fonctionne fondamentalement selon le principe suivant : il existe un seul thread UI, qui gère les entrées, le rendu et le traitement des événements.
Le rôle de ce thread UI se résume à peu près à ceci.
- Traiter les messages tels que les clics de bouton, les frappes clavier, les demandes de réaffichage
- Être le seul thread pouvant toucher les contrôles et les objets UI en toute sécurité
- Si l’on y entasse trop de traitement, la mise à jour de l’écran et la réactivité aux entrées s’arrêtent
Le point essentiel ici est que le travail du thread UI consiste à « tourner vite ». Le bloquer longtemps engorge à la fois la souris, le clavier et le réaffichage — du point de vue de l’utilisateur, l’application « a figé ».
Garder cette image en tête sous forme de schéma aide à éviter la confusion.
flowchart LR
A["Entrée utilisateur / demande de réaffichage"] --> B["Boucle de messages du thread UI"]
B --> C["Exécution du gestionnaire d'événements"]
C --> D["Mise à jour de l'écran"]
D --> B
C --> E["Traitement synchrone long"]
E --> F["La boucle de messages ne tourne plus"]
F --> G["L'écran paraît figé"]
3.2. SynchronizationContext / Dispatcher / Invoke
Voici, réparti pour un usage pratique, le vocabulaire qui revient souvent ici.
| Terme | Signification ici |
|---|---|
| Thread UI | Le thread qui a créé les objets UI. En principe, lui seul peut toucher l’UI en toute sécurité |
| Boucle de messages | Le mécanisme par lequel le thread UI traite les messages les uns après les autres |
SynchronizationContext |
Une abstraction pour « ramener le traitement à cet emplacement d’exécution » |
Dispatcher |
La file d’attente de WPF pour le thread UI |
Invoke / BeginInvoke / InvokeAsync |
Les API permettant d’envoyer du traitement vers le thread UI |
Plus précisément, pour décider où va la continuation, await privilégie d’abord le SynchronizationContext courant, et à défaut, regarde aussi un TaskScheduler non par défaut.
Mais en pratique, dans WPF / WinForms, il suffit de considérer que le SynchronizationContext de l’UI est en jeu.
La correspondance par framework est plus claire sous forme de tableau.
| Framework | Contexte côté UI | API représentatives pour revenir explicitement à l’UI |
|---|---|---|
| WPF | DispatcherSynchronizationContext |
Dispatcher.InvokeAsync / Dispatcher.BeginInvoke / Dispatcher.Invoke |
| WinForms | WindowsFormsSynchronizationContext |
Control.BeginInvoke / Control.Invoke / .NET 9+ Control.InvokeAsync |
WPF est centré sur le Dispatcher.
WinForms est centré sur le handle des contrôles et la boucle de messages, avec BeginInvoke / Invoke qui apparaissent au premier plan.
En pratique, retenir la relation entre l’abstraction et le concret à ce niveau de détail évite de tout mélanger.
flowchart TD
A["Code actuel"] --> 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. Schémas typiques
4.1. Plain await dans un gestionnaire d’événements UI
C’est la forme la plus directe.
private async void LoadButton_Click(object sender, RoutedEventArgs e)
{
LoadButton.IsEnabled = false;
StatusText.Text = "Chargement...";
try
{
string text = await File.ReadAllTextAsync(FilePathTextBox.Text);
PreviewTextBox.Text = text;
StatusText.Text = "Terminé";
}
catch (Exception ex)
{
StatusText.Text = ex.Message;
}
finally
{
LoadButton.IsEnabled = true;
}
}
Dans ce code, LoadButton_Click démarre sur le thread UI.
Et comme await File.ReadAllTextAsync(...) est un plain await, il capture normalement le contexte UI à cet instant.
Par conséquent :
- Le thread UI n’est pas monopolisé pendant l’attente de l’E/S fichier
- La suite après la fin de la lecture revient essentiellement au thread UI
- On peut écrire
PreviewTextBox.Text = text;directement
Voilà la forme que cela prend.
Aucun Dispatcher superflu n’est nécessaire ici.
Si l’on s’est contenté d’un plain await dans un gestionnaire UI, on peut normalement toucher l’UI directement.
La façon de voir les choses est la même en WinForms.
Tant que l’on fait un plain await dans un gestionnaire Click, la suite revient essentiellement du côté UI.
Sous forme de schéma, le déroulement est le suivant.
sequenceDiagram
participant UI as Thread UI
participant IO as E/S asynchrone
participant Ctx as UI SynchronizationContext
UI->>UI: Démarrage du gestionnaire Click
UI->>IO: await ReadAllTextAsync
UI-->>Ctx: Planifie le retour de la suite vers l'UI
Note over UI: Retourne à la boucle de messages pendant l'attente
IO-->>Ctx: E/S terminée
Ctx-->>UI: Reprend la suite sur le thread UI
UI->>UI: Met à jour TextBox / Label
4.2. Task.Run uniquement pour les calculs CPU lourds
Task.Run est efficace quand on veut sortir un calcul CPU lourd du thread UI.
private async void HashButton_Click(object sender, RoutedEventArgs e)
{
HashButton.IsEnabled = false;
ResultText.Text = "Calcul en cours...";
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;
}
}
Ce qui se passe dans ce code se résume à peu près à ceci.
- Le gestionnaire d’événements démarre sur le thread UI
- L’attente d’E/S de
File.ReadAllBytesAsyncs’écoule de façon asynchrone - Seul le calcul de hachage lourd est envoyé au ThreadPool via
Task.Run - La suite de
await Task.Run(...)est un plainawait, donc elle revient au thread UI - On peut écrire
ResultText.Text = hash;directement
Autrement dit, seul l’intérieur de Task.Run s’exécute sur un autre thread.
On ne migre pas de façon permanente, au-delà de l’await, vers « un endroit qui n’est plus l’UI ».
Voir ceci sur une seule fiche limite les malentendus.
sequenceDiagram
participant UI as Thread UI
participant IO as E/S asynchrone
participant Pool as ThreadPool
UI->>IO: await ReadAllBytesAsync
IO-->>UI: Plain await, donc reprise sur l'UI
UI->>Pool: Envoie le traitement CPU lourd via Task.Run
Pool-->>UI: Renvoie le résultat du calcul
Note over UI: La suite de await Task.Run(...) reprend sur l'UI
UI->>UI: Reflète le résultat à l'écran
Il y a deux précautions ici.
- Ne pas envelopper une attente d’E/S dans
Task.Run - Considérer que
Task.Runne sert pas à « rendre asynchrone », mais à créer « un lieu où décharger le CPU »
Une écriture comme Task.Run(async () => await File.ReadAllTextAsync(...)) ne fait que réexpédier inutilement l’attente d’E/S vers le ThreadPool, sans grand bénéfice.
4.3. ConfigureAwait(false) ne « garantit pas l’absence de retour », il « ne force pas le retour »
C’est le point le plus souvent mal compris.
D’abord, ConfigureAwait(false) convient à du code de bibliothèque générique qui ne dépend ni de l’UI ni d’un modèle d’application particulier.
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);
}
}
Cette méthode ne touche pas l’UI.
Elle peut s’utiliser aussi bien en WPF, en WinForms, en ASP.NET Core que dans un worker.
Pour ce type de code, ajouter ConfigureAwait(false) est naturel.
Et du côté de l’UI, l’appel peut se faire en plain await.
private readonly DocumentRepository _repository = new();
private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
OpenButton.IsEnabled = false;
StatusText.Text = "Chargement...";
try
{
string text = await _repository.LoadNormalizedTextAsync(
PathTextBox.Text,
CancellationToken.None);
PreviewTextBox.Text = text;
StatusText.Text = "Terminé";
}
catch (Exception ex)
{
StatusText.Text = ex.Message;
}
finally
{
OpenButton.IsEnabled = true;
}
}
Ce qui compte ici, c’est que le ConfigureAwait(false) à l’intérieur de la bibliothèque ne force pas l’await de l’appelant à devenir false lui aussi.
Autrement dit, on obtient cette séparation :
- À l’intérieur de la bibliothèque, on ne revient pas à l’UI
- Quand un gestionnaire UI fait un plain
awaitdessus, la suite côté appelant revient à l’UI
À l’inverse, écrire ceci directement dans le gestionnaire UI est dangereux.
private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
string text = await _repository.LoadNormalizedTextAsync(
PathTextBox.Text,
CancellationToken.None).ConfigureAwait(false);
PreviewTextBox.Text = text;
}
Dans ce cas, la suite de cet await dans OpenButton_Click n’est pas forcée de revenir à l’UI.
PreviewTextBox.Text = text; peut donc devenir un accès inter-thread.
Il y a un autre point discrètement important.
Ajouter ConfigureAwait(false) ne garantit pas un passage systématique au ThreadPool : si cet await se termine immédiatement sans attendre, la suite peut simplement continuer à s’écouler sur le thread actuel.
Le lire comme « on va forcément aller sur un autre thread » ou « à partir d’ici, ce n’est plus jamais l’UI » est une source d’accidents ; le sens se limite strictement à ceci : la continuation de cet await n’est pas forcée de revenir au contexte UI d’origine, rien de plus.
Sous forme de schéma, cela donne ceci.
flowchart LR
A["await dans un gestionnaire UI"] --> B{"Ajouter ConfigureAwait(false) ?"}
B -- Non --> C["La suite reste essentiellement sur le thread UI"]
C --> D["Facile de mettre à jour l'UI directement"]
B -- Oui --> E["La suite n'est pas fixée sur l'UI"]
E --> F["Peut reprendre sur un thread quelconque"]
F --> G["La mise à jour de l'UI nécessite Dispatcher / Invoke"]
4.4. Pourquoi .Result / .Wait() / .GetAwaiter().GetResult() bloquent
C’est l’accident que l’on rencontre le plus souvent.
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();
}
À première vue, cela ressemble simplement à une récupération synchrone du résultat, mais le faire sur le thread UI est dangereux.
Le déroulement, sous forme de schéma, est le suivant.
sequenceDiagram
participant UI as Thread UI
participant IO as E/S asynchrone
participant Ctx as UI SynchronizationContext
UI->>UI: Démarrage de LoadButton_Click
UI->>IO: Appel de LoadTextAsync()
IO-->>UI: Renvoie une Task non terminée
UI->>UI: Bloque en attendant sur .Result
IO-->>Ctx: E/S terminée, veut ramener la continuation sur l'UI
Ctx-->>UI: Veut exécuter la suite
Note over UI: Mais l'UI est bloquée par .Result
Note over UI, Ctx: La continuation ne peut pas s'exécuter, donc rien ne peut se terminer
Mettre en mots ce qui se passe donne ceci.
- Le thread UI appelle
LoadTextAsync() - L’
awaità l’intérieur deLoadTextAsync()capture le contexte UI - Le thread UI se met à attendre sur
.Result - L’E/S se termine
- La suite de
LoadTextAsync()veut revenir sur le thread UI - Mais le thread UI est bloqué par
.Result - La suite ne peut pas s’exécuter, donc
LoadTextAsync()ne se termine pas .Resultne se termine jamais
Autrement dit, l’UI dit « j’attends que tu termines », et le côté asynchrone dit « je peux terminer une fois revenu sur l’UI » — les deux s’attendent mutuellement. C’est vraiment désagréable.
Une méprise fréquente ici consiste à penser que passer par GetAwaiter().GetResult() serait sûr.
Mais le fond du problème — bloquer le thread UI — reste identique. Ce qui diffère, c’est surtout la façon dont l’exception est enveloppée.
Il est donc plus sûr, dans l’UI, de traiter ces trois-là comme relevant de la même odeur suspecte.
.Result.Wait().GetAwaiter().GetResult()
Notez aussi qu’appeler Task.Wait() sur la Task renvoyée par Dispatcher.InvokeAsync(...) en WPF est également dangereux.
La documentation de WPF elle-même indique qu’appeler Task.Wait sur la Task renvoyée par une DispatcherOperation provoque un interblocage.
Dans le contexte de l’UI, c’est la direction même consistant à « attendre de façon synchrone ce que l’on a envoyé » qui a tendance à se bloquer.
Est-ce que cela provoque toujours un interblocage ? Pas nécessairement. Si, par hasard, le code a des continuations qui ne reviennent pas sur l’UI, il se peut que cela ne fasse que geler l’UI sans interblocage. Mais c’est déjà suffisamment pénible, donc mieux vaut, en principe, ne pas le faire dans l’UI.
5. Quand utiliser Dispatcher / Invoke
Compte tenu de tout cela, dans un gestionnaire UI en plain await, on n’a normalement pas besoin de Dispatcher / Invoke explicite.
Cela devient nécessaire, par exemple, dans les cas suivants.
- On veut toucher l’UI dans la suite d’un
ConfigureAwait(false) - On est à l’intérieur de
Task.Run, ou l’ensemble est structuré pour ne pas revenir à l’UI même en dehors - Les notifications arrivent depuis un endroit qui n’est pas le thread UI dès le départ — réception socket, timer, callback d’événement
- Dans une couche qui sépare intentionnellement l’UI et le non-UI, on veut rendre explicite uniquement la mise à jour finale de l’UI
En WPF, l’API représentative est 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 = "Terminé";
});
}
En WinForms, à partir de .NET 9, InvokeAsync s’accorde naturellement avec le flux 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 = "Terminé";
});
}
Dans le schéma traditionnel de WinForms, on utilise BeginInvoke.
Invoke est un envoi synchrone qui fait attendre l’appelant. BeginInvoke poste et revient immédiatement.
Dans un flux async, c’est en général le côté non bloquant qui s’accorde le mieux.
Pour les distinguer, ce niveau de distinction suffit.
| Objectif | WPF | WinForms |
|---|---|---|
| Entrer sur l’UI de façon synchrone | Dispatcher.Invoke |
Control.Invoke |
| Envoyer vers l’UI de façon asynchrone | Dispatcher.InvokeAsync / Dispatcher.BeginInvoke |
Control.BeginInvoke / .NET 9+ Control.InvokeAsync |
| S’harmoniser naturellement avec async / await | Dispatcher.InvokeAsync |
.NET 9+ Control.InvokeAsync, sinon BeginInvoke |
En pratique, l’instinct à suivre est :
- Inutile si l’on se contente d’un plain
awaitdans un gestionnaire UI - À utiliser quand on veut toucher l’UI depuis un endroit qui n’est pas l’UI
- Ne pas multiplier les
Invokesynchrones dans un flux async
Cela réduit déjà considérablement les accidents.
En cas d’hésitation, un schéma de décision à ce niveau suffit.
flowchart TD
A["L'endroit où l'on écrit cette suite est-il le thread UI ?"] --> B{"Oui ?"}
B -- Oui --> C["On peut garder le plain await et mettre à jour l'UI"]
B -- Non --> D{"Veut-on toucher l'UI ?"}
D -- Non --> E["Continuer le traitement tel quel"]
D -- Oui --> F["WPF : Dispatcher.InvokeAsync"]
D -- Oui --> G["WinForms : BeginInvoke / InvokeAsync"]
6. Anti-patterns courants
| Anti-pattern | Ce qui est pénible | Premier remplacement |
|---|---|---|
LoadAsync().Result dans un gestionnaire UI |
Bloque le thread UI. Sujet aux interblocages | await LoadAsync() |
LoadAsync().Wait() dans un gestionnaire UI |
Idem. La boucle de messages s’arrête | await LoadAsync() |
LoadAsync().GetAwaiter().GetResult() dans un gestionnaire UI |
Seule la présentation de l’exception diffère, le blocage est identique | await LoadAsync() |
Ajouter mécaniquement ConfigureAwait(false) au code UI |
La mise à jour de l’UI après await casse facilement |
Garder un plain await à la couche la plus externe de l’UI |
Task.Run(async () => await IoAsync()) |
Réexpédie inutilement l’E/S | await IoAsync() |
Le code de bibliothèque tient directement Dispatcher ou Control |
La dépendance à l’UI s’approfondit. Difficile à réutiliser | La bibliothèque ne renvoie que des données ; le marshaling se fait côté UI |
Multiplier Dispatcher.Invoke / Control.Invoke dans des flux async |
Forme facilement des anneaux de blocage | Envisager Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync |
| Synchroniser l’async dans un constructeur ou un getter de propriété | Terreau des gels au démarrage | Déplacer vers Loaded / Shown / InitializeAsync |
Parmi ceux-ci, trois se rencontrent particulièrement souvent.
.Result/.Wait()sur le thread UI- Ajouter mécaniquement
ConfigureAwait(false)au code UI - Les responsabilités de la bibliothèque et de l’UI se mélangent, et le
Dispatchers’infiltre en profondeur
Éliminer seulement ces trois-là calme déjà considérablement le code.
7. Liste de vérification pour la revue
Lors de la revue de async / await en WPF / WinForms, on parcourt les points suivants dans l’ordre.
- Reste-t-il des
.Result/.Wait()/.GetAwaiter().GetResult()dans les gestionnaires d’événements UI ou les chemins d’initialisation de l’UI ? Task.Runest-il utilisé uniquement pour du calcul CPU ? N’enveloppe-t-il pas de l’E/S ?ConfigureAwait(false)s’est-il glissé mécaniquement dans le code UI ?- À l’inverse, une bibliothèque générique traîne-t-elle une dépendance au contexte UI ?
- Pour chaque endroit qui touche l’UI directement après un
await, peut-on vraiment affirmer que ce point se trouve sur le contexte UI ? - Là où un retour explicite vers l’UI est nécessaire,
Dispatcher.InvokeAsync/BeginInvoke/InvokeAsyncsont-ils utilisés ? - Des marshaling synchrones comme
Dispatcher.Invoke/Control.Invokese multiplient-ils inutilement ? - Synchronise-t-on de force de l’async depuis un constructeur, une propriété synchrone ou un événement synchrone ?
- La couche bibliothèque référence-t-elle directement
Window/Control/Dispatcher?
Cette liste de vérification est aussi pratique pour aligner l’équipe sur « ce qui relève de la responsabilité de l’UI ».
8. Aide-mémoire rapide
| Objectif | Premier choix |
|---|---|
| Attendre une E/S HTTP / BD / fichier dans un gestionnaire UI | plain await |
| Calcul CPU lourd que l’on ne veut pas laisser bloquer l’UI | await un Task.Run |
Mettre à jour l’UI après ConfigureAwait(false) ou depuis un thread d’arrière-plan |
WPF : Dispatcher.InvokeAsync / WinForms : BeginInvoke ou .NET 9+ InvokeAsync |
| Écrire une bibliothèque générique | Envisager ConfigureAwait(false) |
| Vouloir synchroniser de l’async dans l’UI | À éviter en principe. Étendre l’async jusqu’à l’appelant |
| Vouloir initialiser au démarrage | Loaded / Shown / un InitializeAsync explicite |
Vouloir toucher l’UI directement après await |
Garder un plain await à la couche la plus externe de l’UI |
9. Conclusion
Ce qui compte vraiment avec async / await en WPF / WinForms, ce n’est pas l’ambiance vague de « l’asynchrone, c’est difficile », mais le fait de penser séparément à :
- Où les choses ont commencé
- Où revient la suite de l’
await - Qui porte la responsabilité du retour vers l’UI
Comme règles de départ, respecter uniquement ceci suffit déjà à bien s’en sortir.
- Plain
awaità la couche la plus externe de l’UI Task.Rununiquement pour le CPU lourd- Envisager
ConfigureAwait(false)dans les bibliothèques génériques Dispatcher/BeginInvoke/InvokeAsyncseulement quand il faut revenir à l’UI- Ne jamais utiliser
.Result/.Wait()/.GetAwaiter().GetResult()sur le thread UI
async / await en lui-même n’est pas un mécanisme si difficile à vivre.
Mais l’utiliser sans garder le thread UI au centre de sa vision le transforme soudain en bourbier.
Dit autrement :
- Séparer l’extérieur et l’intérieur de l’UI
- Rester conscient du point de retour
- Ne pas introduire de blocage
Il suffit de respecter ces trois points pour que le code asynchrone de WPF / WinForms devienne beaucoup plus calme. Le code qui fige l’écran n’est généralement pas un cas où « l’asynchrone est mauvais » — c’est simplement une façon négligente d’emprunter au thread UI.
10. Références
- Ensemble complet du code d’exemple de cet article (bibliothèque indépendante de l’UI, exemples WPF / WinForms, tests unitaires) - komurasoft-blog-samples (GitHub)
- Article associé : C# async/await - bonnes pratiques - tableau de décision pour Task.Run et 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
Articles associés
Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.
CI/CD pratique pour les applications WinForms / WPF — Automatiser du build à la signature et à la distribution avec GitHub Actions
Guide pratique pour mettre en place le CI/CD des applications WinForms / WPF avec GitHub Actions. Couvre un YAML minimal de build+tests s...
Icônes de la zone de notification et notifications toast dans les applications Windows — les pièges de NotifyIcon et comment choisir la bonne AppNotification
Un guide pratique pour maintenir une application Windows métier résidente dans la zone de notification (system tray) et avertir l'utilisa...
Internationalisation des applications WinForms/WPF : resx, assemblies satellites et changement de culture en pratique
Un guide pratique pour internationaliser une application de bureau Windows : la différence entre CurrentCulture et CurrentUICulture, le f...
Intégrer l'authentification Entra ID dans une application WinForms/WPF — Architecture pratique avec MSAL.NET et le broker WAM
Guide pratique pour intégrer l'authentification Entra ID (ex Azure AD) dans une application de bureau WinForms/WPF : la logique du client...
Tests UI automatisés pour applications de bureau Windows — le fonctionnement de UI Automation et la construction de tests robustes avec FlaUI
Un guide pratique des tests UI automatisés pour applications WinForms/WPF, en partant du fonctionnement de Windows UI Automation (l'arbor...
Sujets associés
Ces pages replacent le sujet dans un contexte plus large de services et de décisions.
Thèmes techniques Windows
Portail des sujets sur le développement Windows, l'analyse des incidents et la valorisation des actifs existants.
Thread UI et minuteries
Thread UI WPF / WinForms, flux asynchrones, Dispatcher et temporisation.
Services liés à ce sujet
Cet article est directement lié aux services suivants.
Développement d'applications Windows
Le thread UI et async/await dans WPF / WinForms comptent parmi les points où l'implémentation du développement d'applications Windows se bloque le plus souvent.
Conseil technique et revue de conception
Si vous en êtes à l'étape où vous voulez clarifier les responsabilités entre l'UI et le traitement en arrière-plan, ainsi que l'usage du Dispatcher, ce sujet peut être repris dans le cadre d'un conseil technique et d'une revue de conception.
Questions fréquentes
Questions souvent posées lors d’une consultation sur le sujet de cet article.
- Après un await, sur quel thread l'exécution reprend-elle ?
- Avec un plain await (sans ConfigureAwait) dans un gestionnaire d'événements UI de WPF ou WinForms, la suite après l'await revient essentiellement sur le thread UI. En effet, l'await capture le SynchronizationContext de l'UI à cet instant précis pour y ramener la continuation, ce qui permet d'écrire directement une mise à jour de TextBox ou de Label juste après l'await. C'est vrai aussi pour await Task.Run(...) : le calcul lui-même s'exécute sur le ThreadPool, mais si l'await reste un plain await, la suite reprend sur le thread UI.
- Pourquoi utiliser .Result ou .Wait() sur le thread UI provoque-t-il un blocage ?
- Pendant que le thread UI attend sur .Result, la continuation du traitement asynchrone cherche à revenir sur le contexte UI qu'elle a capturé ; mais le thread UI étant occupé par ce .Result, la continuation ne peut pas s'exécuter, et les deux côtés s'attendent mutuellement, ce qui produit un interblocage (deadlock). GetAwaiter().GetResult() ne diffère que par la façon dont l'exception est enveloppée : le fond du problème, à savoir bloquer le thread UI, reste le même. Dans l'UI, il faut éviter .Result, .Wait() et GetAwaiter().GetResult() tous les trois, et utiliser await.
- Faut-il ajouter ConfigureAwait(false) au code UI ?
- Mieux vaut ne pas l'ajouter. ConfigureAwait(false) signifie que « le retour vers le contexte UI capturé n'est pas forcé » : la suite peut donc reprendre sur un thread quelconque, et une mise à jour de l'UI juste après peut devenir un accès inter-thread. Cette option convient au code de bibliothèque générique qui ne dépend pas de l'UI ; la règle est de garder un plain await à la couche la plus externe de l'UI.
- Quand faut-il utiliser Task.Run ?
- Uniquement quand vous voulez sortir un calcul CPU lourd du thread UI. Envelopper une attente d'E/S dans Task.Run ne fait que réexpédier inutilement cette attente vers le ThreadPool, sans aucun bénéfice. Seul l'intérieur de Task.Run s'exécute sur un autre thread : si l'await de await Task.Run(...) reste un plain await, la suite revient normalement sur le thread UI, ce qui permet d'écrire directement la mise à jour du résultat à l'écran.
Profil de l’auteur
Page de présentation de l’auteur de l’article.
Go Komura
Représentant de KomuraSoft LLC
Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.
Liens publics