Chapitre 716 minIntermédiaire
async et await en WinForms : une interface qui ne se fige pas
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- expliquer pourquoi une fenêtre affiche « Ne répond pas » ;
- écrire un gestionnaire d'événement async qui attend une opération longue ;
- distinguer une attente d'entrée-sortie d'un calcul à déplacer avec Task.Run ;
- afficher une progression et proposer une annulation ;
- éviter le blocage avec .Result et l'erreur d'accès inter-threads.
4 exercices corrigés1 schéma
async et await sont les mots-clés de C# qui permettent d'attendre la fin d'une opération longue (une requête à la base, un appel à une API, la lecture d'un gros fichier) sans bloquer le programme pendant l'attente. Dans une application WinForms, ils évitent que la fenêtre se fige et que Windows affiche « (Ne répond pas) » dans sa barre de titre. Toutes les bibliothèques modernes d'accès aux données en dépendent : ce chapitre vient donc avant la base de données.
Pourquoi la fenêtre se fige
Souviens-toi de la boucle de messages : un seul message est traité à la fois, sur un seul fil d'exécution, le thread de l'interface (UI thread). Si ton gestionnaire de clic attend trois secondes la réponse du serveur MySQL, la boucle est bloquée trois secondes : la fenêtre ne se redessine plus, les clics s'accumulent, et au bout de quelques secondes Windows grise la fenêtre.
// À NE PAS FAIRE : la fenêtre est figée pendant 3 secondes
private void btnCharger_Click(object? sender, EventArgs e)
{
Thread.Sleep(3000); // simule une requête lente
lblStatut.Text = "Chargé";
}
La solution : rendre la main pendant l'attente
Avec await, la méthode se met en pause à l'endroit de l'attente et rend la main à la boucle de messages. La fenêtre continue de vivre. Quand l'opération est terminée, la suite de la méthode reprend, sur le thread de l'interface : tu peux modifier les contrôles sans précaution.
private async void btnCharger_Click(object? sender, EventArgs e)
{
lblStatut.Text = "Chargement…";
await Task.Delay(3000); // simule une requête lente, sans bloquer
lblStatut.Text = "Chargé";
}
Les trois règles d'écriture :
- Une méthode qui contient
awaitest marquéeasync. - Elle renvoie
Task(rien à renvoyer) ouTask<T>(un résultat de typeT), et son nom se termine parAsyncpar convention :ChargerAsync,ListerAsync. - Un appelant attend son résultat avec
await:List<Patient> liste = await _service.ListerAsync();.
private async Task ChargerAsync()
{
btnActualiser.Enabled = false;
lblStatut.Text = "Chargement…";
try
{
_tous = await _service.ListerAsync(); // la fenêtre reste réactive pendant la requête
Filtrer();
}
catch (Exception ex)
{
lblStatut.Text = "Erreur de chargement";
MessageBox.Show($"Impossible de charger les patients.\n{ex.Message}", "Erreur",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
btnActualiser.Enabled = true; // exécuté dans tous les cas
}
}
private async void FrmPatients_Load(object? sender, EventArgs e) => await ChargerAsync();
private async void btnActualiser_Click(object? sender, EventArgs e) => await ChargerAsync();
Désactiver le bouton pendant le chargement empêche l'utilisateur impatient de lancer dix requêtes à la suite. Le bloc finally le réactive, même en cas d'erreur.
async void : seulement pour les gestionnaires d'événements
Les gestionnaires d'événements doivent garder leur signature (void), d'où async void. C'est le seul cas où l'on écrit async void. Partout ailleurs, une méthode asynchrone renvoie Task, pour que son appelant puisse l'attendre et récupérer ses erreurs.
async Task | async void | |
|---|---|---|
| L'appelant peut l'attendre | Oui, avec await | Non |
| Une exception non interceptée | remonte à l'appelant, au await | remonte jusqu'à la boucle de messages : WinForms affiche la boîte « Exception non gérée » |
| Usage | toutes tes méthodes | uniquement les gestionnaires d'événements |
Le piège mortel : .Result et .Wait()
Il est tentant d'écrire var liste = _service.ListerAsync().Result; pour obtenir le résultat sans transformer la méthode en async. Dans une application WinForms, c'est un interblocage (deadlock) presque assuré : .Result bloque le thread de l'interface en attendant la fin de la tâche ; la tâche, une fois la réponse reçue, veut reprendre sa suite sur le thread de l'interface… qui est bloqué. Chacun attend l'autre, la fenêtre reste figée pour toujours.
La règle : async jusqu'en haut. Si une méthode appelle une méthode asynchrone, elle devient asynchrone à son tour, jusqu'au gestionnaire d'événement.
Lancer deux opérations en même temps : Task.WhenAll
À l'ouverture de la fenêtre d'ordonnance, il faut la liste des médecins et celle des patients. Les attendre l'une après l'autre additionne les deux durées. On peut lancer les deux requêtes, puis attendre qu'elles soient toutes les deux terminées :
private async void FrmOrdonnance_Load(object? sender, EventArgs e)
{
Task<List<Medecin>> tacheMedecins = _medecinService.ListerAsync(); // lancée
Task<List<Patient>> tachePatients = _patientService.ListerAsync(); // lancée aussi
await Task.WhenAll(tacheMedecins, tachePatients); // attend les deux
cboMedecin.DataSource = await tacheMedecins; // déjà terminée : ne fait que lire le résultat
cboPatient.DataSource = await tachePatients;
}
Appeler une méthode …Async sans await lance l'opération et renvoie une Task en cours ; c'est le await qui attend. Ici, c'est possible parce que chaque méthode du DAO ouvre sa propre connexion : deux requêtes ne peuvent pas partager en même temps une même connexion MySQL, ni un même DbContext d'Entity Framework.
Attendre ou calculer : Task.Run
await libère le thread pendant une attente : réseau, base de données, disque. Ces opérations ne consomment pas de processeur pendant qu'elles attendent, et les bibliothèques fournissent leurs versions …Async (OpenAsync, ExecuteReaderAsync, GetFromJsonAsync, File.ReadAllTextAsync).
Un calcul long (parcourir 100 000 lignes, générer un PDF, hacher des mots de passe), lui, occupe le processeur. Marquer la méthode async ne suffit pas : il faut l'exécuter sur un autre thread avec Task.Run, et attendre son résultat.
private async void btnStatistiques_Click(object? sender, EventArgs e)
{
btnStatistiques.Enabled = false;
StatistiquesOrdonnances stats = await Task.Run(() => CalculerStatistiques(_ordonnances));
lblStatut.Text = $"{stats.Nombre} ordonnances, {stats.MoyenneLignes:N1} médicaments en moyenne";
btnStatistiques.Enabled = true;
}
Le code passé à Task.Run s'exécute sur un thread du pool : il ne doit pas toucher aux contrôles. Il calcule et renvoie un résultat ; c'est la suite, après le await, de retour sur le thread de l'interface, qui l'affiche.
Afficher une progression
IProgress<T> transmet des nouvelles d'une tâche de fond vers l'interface, en toute sécurité. L'objet Progress<T>, créé sur le thread de l'interface, y exécute son action à chaque Report :
private async void btnAnalyser_Click(object? sender, EventArgs e)
{
btnAnalyser.Enabled = false;
var progression = new Progress<int>(pourcent => barProgression.Value = pourcent);
int total = await Task.Run(() => AnalyserFichier(progression));
lblStatut.Text = $"{total} lignes analysées";
btnAnalyser.Enabled = true;
}
private static int AnalyserFichier(IProgress<int> progression)
{
int total = 0;
for (int i = 1; i <= 100; i++)
{
total += TraiterBloc(i); // un centième du travail
progression.Report(i); // 1 %, 2 %… jusqu'à 100 %
}
return total;
}
Proposer une annulation
Une recherche qui traîne doit pouvoir être annulée. Le mécanisme standard est le jeton d'annulation : un CancellationTokenSource crée un CancellationToken, que l'on passe aux méthodes …Async. Appeler Cancel() les interrompt avec une OperationCanceledException.
private CancellationTokenSource? _annulation;
private async void btnRechercher_Click(object? sender, EventArgs e)
{
_annulation = new CancellationTokenSource();
btnAnnuler.Enabled = true;
try
{
dgvMedicaments.DataSource = await _api.RechercherAsync(txtNom.Text, _annulation.Token);
}
catch (OperationCanceledException)
{
lblStatut.Text = "Recherche annulée.";
}
finally
{
btnAnnuler.Enabled = false;
}
}
private void btnAnnuler_Click(object? sender, EventArgs e) => _annulation?.Cancel();
Le chapitre 11 reprend cet exemple complet, avec le délai d'attente de HttpClient.
L'erreur « opération inter-threads non valide »
Si du code qui s'exécute sur un autre thread (dans un Task.Run, dans un System.Threading.Timer) modifie un contrôle, WinForms lève une InvalidOperationException : « Opération inter-threads non valide : le contrôle 'lblStatut' a fait l'objet d'un accès à partir d'un thread autre que celui sur lequel il a été créé ». Les contrôles ne doivent être touchés que par le thread de l'interface.
Trois solutions, de la meilleure à la moins bonne :
- Renvoyer un résultat depuis
Task.Runet l'afficher après leawait(le cas ordinaire, ci-dessus) ; IProgress<T>pour les nouvelles intermédiaires ;Invokeen dernier recours, pour exécuter un bout de code sur le thread de l'interface :lblStatut.Invoke(() => lblStatut.Text = "Terminé");.
Exercices
Exercice 1 sur 4 Prévoir l'affichage Réussi À revoir
Le label lblStatut est vide au départ. L'utilisateur clique une fois sur le bouton. Qu'affiche le label juste après le clic, puis au bout de deux secondes ? La fenêtre peut-elle être déplacée pendant ces deux secondes ?
private async void btnTest_Click(object? sender, EventArgs e)
{
lblStatut.Text = "A";
await Task.Delay(2000);
lblStatut.Text += "B";
}
Voir la réponseMasquer la réponse de l'exercice 1
Juste après le clic, « A » : la méthode s'exécute jusqu'au await, puis rend la main, et la boucle de messages redessine le label. Au bout de deux secondes, la suite reprend sur le thread de l'interface : « AB ». Pendant l'attente, la fenêtre reste réactive : on peut la déplacer, et même recliquer sur le bouton, ce qui relancerait la méthode (d'où l'intérêt de désactiver le bouton).
Tu avais trouvé ?
Exercice 2 sur 4 La fenêtre figée Réussi À revoir
Ce code fige l'application au premier clic, définitivement. Explique pourquoi et corrige-le.
private void btnCharger_Click(object? sender, EventArgs e)
{
List<Patient> patients = _service.ListerAsync().Result;
dgvPatients.DataSource = patients;
}
Voir la réponseMasquer la réponse de l'exercice 2
.Result bloque le thread de l'interface jusqu'à la fin de ListerAsync. Mais la suite de ListerAsync, après son propre await, doit reprendre sur ce même thread, qui est bloqué : interblocage. La correction est d'attendre avec await, ce qui rend la main pendant la requête :
private async void btnCharger_Click(object? sender, EventArgs e)
{
try
{
dgvPatients.DataSource = await _service.ListerAsync();
}
catch (Exception ex)
{
MessageBox.Show(ex.Message, "Erreur", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
Tu avais trouvé ?
Exercice 3 sur 4 Await ou Task.Run ? Réussi À revoir
Pour chaque opération, dis si tu l'attends directement avec await (attente d'entrée-sortie) ou si tu la places dans un Task.Run (calcul).
await conn.OpenAsync(), l'ouverture d'une connexion MySQL.- Le hachage BCrypt d'un mot de passe, qui prend environ 250 ms de calcul.
- Le téléchargement de la liste des médicaments depuis l'API.
- Le calcul de la durée moyenne de traitement sur 200 000 lignes déjà chargées en mémoire.
- La lecture d'un fichier CSV de 5 Mo avec
File.ReadAllLinesAsync.
Voir la réponseMasquer la réponse de l'exercice 3
await: attente réseau, la méthode est déjà asynchrone.Task.Run: c'est du calcul pur, il bloquerait le thread de l'interface un quart de seconde.await: attente réseau (GetFromJsonAsync).Task.Run: calcul sur des données en mémoire.await: lecture disque, la méthode…Asyncexiste. Le découpage des lignes qui suit, s'il est lourd, peut aller dans unTask.Run.
Tu avais trouvé ?
Exercice 4 sur 4 Trouve le bug Réussi À revoir
L'application plante avec « Opération inter-threads non valide ». Corrige ce code sans utiliser Invoke.
private async void btnCompter_Click(object? sender, EventArgs e)
{
await Task.Run(() =>
{
int n = _ordonnances.Count(o => o.DateEmission.Year == 2026);
lblStatut.Text = $"{n} ordonnances en 2026";
});
}
Voir la réponseMasquer la réponse de l'exercice 4
Le label est modifié dans le code passé à Task.Run, donc sur un thread du pool. La tâche doit seulement calculer et renvoyer le résultat ; l'affichage se fait après le await, de retour sur le thread de l'interface :
private async void btnCompter_Click(object? sender, EventArgs e)
{
int n = await Task.Run(() => _ordonnances.Count(o => o.DateEmission.Year == 2026));
lblStatut.Text = $"{n} ordonnances en 2026";
}
Tu avais trouvé ?