Menu

async et await en C# : Task, Task<T>, WhenAll et pièges courants

async et await permettent au code C# d'attendre des opérations lentes sans bloquer de thread. Apprenez comment s'exécute une méthode async, Task et Task<T>, lancer en même temps des opérations indépendantes avec Task.WhenAll, les exceptions dans le code async, et pourquoi async void et .Result provoquent des bugs et des interblocages.

Cette page contient des éditeurs exécutables - modifiez, exécutez et voyez la sortie instantanément.

La plupart des opérations lentes d'un programme sont des attentes : qu'un serveur web réponde, qu'une base de données renvoie des lignes, qu'un fichier soit lu. async et await permettent à une méthode de se mettre en pause pendant une telle attente sans prendre un thread en otage, puis de reprendre là où elle s'était arrêtée. Le code se lit toujours de haut en bas comme du code ordinaire.

Une première méthode async

Une méthode async renvoie Task (pas de résultat) ou Task<T> (un résultat de type T). À l'intérieur, await attend une autre tâche et vous donne son résultat.

Sortie :

Tea 2.50, cake 4.00, total 6.50

GetPriceAsync écrit return 2.50m, et le compilateur l'enveloppe dans la Task<decimal> que renvoie la méthode. await la déballe à nouveau. Task.Delay est la version async de Thread.Sleep : elle se termine une fois le temps écoulé, sans bloquer de thread entre-temps.

Depuis C# 7.1, Main lui-même peut être async, et c'est ainsi que vous écririez ce programme en .NET moderne :

static async Task Main()
{
    decimal tea = await GetPriceAsync("tea");
    Console.WriteLine(tea);
}

Les exemples exécutables de cette page appellent une méthode async RunAsync depuis un Main ordinaire avec .GetAwaiter().GetResult(), ce que génère le compilateur pour un Main async. Dans une application console, c'est sûr ; la section sur les interblocages explique pourquoi le même appel est dangereux dans du code d'interface.

Ce qui se passe à un await

Une méthode async s'exécute de façon synchrone jusqu'à son premier await sur une tâche non terminée. À ce moment, elle renvoie une Task à son appelant, et le reste de la méthode devient une continuation qui s'exécute quand la tâche attendue se termine.

Sortie :

Calling DownloadAsync
  Download: starting
DownloadAsync returned a task; doing other work
  Download: finished
Download awaited

« Download: starting » s'affiche avant que DownloadAsync ne revienne, car tout ce qui précède le premier await s'exécute sur le thread de l'appelant. Ensuite, la méthode rend une tâche non terminée, l'appelant continue, et « Download: finished » n'apparaît qu'une fois le délai écoulé. Appeler une méthode async la démarre ; l'attendre avec await est la façon d'attendre son résultat.

async ne crée pas de thread. Pendant que la méthode est en pause, aucun thread ne l'attend, c'est pourquoi un serveur peut avoir des milliers de requêtes en attente d'une base de données avec seulement quelques threads. Pour un travail gourmand en CPU qui doit s'exécuter sur un autre thread, utilisez Task.Run, présenté dans tâches.

Exécuter des opérations en même temps avec Task.WhenAll

Attendre un appel après l'autre est séquentiel : chacun ne démarre qu'une fois le précédent terminé. Quand les opérations ne dépendent pas les unes des autres, démarrez-les toutes puis attendez-les ensemble avec Task.WhenAll :

Exemple de sortie :

Sequential: 160 units in ~900 ms
Concurrent: 160 units in ~300 ms

La version séquentielle prend la somme des trois attentes, la version concurrente à peu près la durée de la plus lente. Task.WhenAll renvoie les résultats dans l'ordre des tâches passées, quel que soit l'ordre dans lequel elles se sont terminées. Elle fonctionne aussi avec une liste, ce qui est la forme habituelle pour « récupérer chaque élément » : await Task.WhenAll(ids.Select(id => FetchAsync(id))).

Task.WhenAny est son pendant, qui se termine dès que la première tâche se termine ; c'est utile pour les délais d'expiration et « la première réponse l'emporte ».

Les exceptions dans le code async

Une exception levée dans une méthode async est stockée dans la tâche renvoyée et relevée quand la tâche est attendue, donc un try/catch ordinaire autour de l'await fonctionne :

Sortie :

Task created, completed yet: False
Caught ArgumentOutOfRangeException for userId
WhenAll rethrew the first failure
All failures: 1
The other task still succeeded: profile 7

Appeler LoadProfileAsync(-1) n'a pas levé d'exception : l'exception vit dans la tâche jusqu'à ce que quelque chose l'attende. Une tâche que personne n'attend jamais cache entièrement son exception, une raison de plus de toujours attendre ce que vous démarrez. Avec WhenAll, await relève la première exception, tandis que la propriété Exception de la tâche combinée (une AggregateException) les contient toutes. Lire ok.Result est sans risque ici, car ok est déjà terminée.

async void : seulement pour les gestionnaires d'événements

Une méthode async peut aussi renvoyer void. Évitez-le partout, sauf dans les gestionnaires d'événements :

// Bad: the caller cannot await it or catch its exceptions.
static async void SaveAsync(Order order)
{
    await db.InsertAsync(order);   // if this throws, the process may crash
}

// Good: return Task, so callers can await and handle errors.
static async Task SaveAsync(Order order)
{
    await db.InsertAsync(order);
}

// Acceptable: an event handler must return void.
private async void SaveButton_Click(object sender, EventArgs e)
{
    try { await SaveAsync(currentOrder); }
    catch (Exception ex) { ShowError(ex); }
}

Avec async void, il n'y a aucune tâche à attendre, donc l'appelant continue avant la fin du travail, et une exception levée à l'intérieur est relevée directement sur le contexte de synchronisation (ou le pool de threads), où aucun try/catch de l'appelant ne peut l'atteindre. Dans une application console ou serveur, cela termine en général le processus. Dans un gestionnaire d'événements async void, interceptez tout vous-même.

Une erreur voisine consiste à appeler une méthode async sans await. Le compilateur avertit (CS4014), et la méthode s'exécute en arrière-plan sans que rien n'observe son résultat ni ses erreurs.

Les interblocages de .Result et .Wait()

Bloquer sur une tâche avec .Result ou .Wait() est l'endroit où le code async tourne mal le plus souvent. Dans une application dotée d'un contexte de synchronisation (WinForms, WPF, MAUI, ASP.NET classique), la séquence est la suivante :

  1. Le thread d'interface appelle GetDataAsync().Result et se bloque en attendant la tâche.
  2. Dans GetDataAsync, un await se termine. Par défaut, sa continuation doit s'exécuter sur le contexte où elle a démarré : le thread d'interface.
  3. Le thread d'interface est bloqué à l'étape 1, donc la continuation ne s'exécute jamais, donc la tâche ne se termine jamais, donc l'étape 1 ne finit jamais.

L'application se fige sans aucune exception. Les applications console et ASP.NET Core n'ont pas ce contexte, c'est pourquoi le même code fonctionne dans un programme de test et se bloque dans une application de bureau. Les corrections :

  • Utilisez await tout au long de la chaîne d'appels au lieu de bloquer (« async jusqu'au bout »). Les gestionnaires d'événements peuvent être async void pour cela.
  • Dans du code de bibliothèque qui n'a pas besoin de revenir au contexte de l'appelant, écrivez await SomethingAsync().ConfigureAwait(false);. La continuation s'exécute alors sur le pool de threads au lieu du contexte capturé, et la bibliothèque évite un changement de thread inutile. Cela ne protège un appelant bloquant que si chaque await de la chaîne fait de même ; considérez-le donc comme une bonne hygiène de bibliothèque, pas comme une correction de .Result.

Le code applicatif d'ASP.NET Core n'a pas besoin de ConfigureAwait(false), puisqu'il n'y a pas de contexte auquel revenir.

Erreurs courantes

  • Attendre un par un des appels indépendants. Démarrez-les, puis await Task.WhenAll(...).
  • Des méthodes async void. Renvoyez Task ; gardez async void pour les gestionnaires d'événements.
  • .Result et .Wait() dans du code d'interface ou d'ASP.NET classique. Ils provoquent des interblocages ; utilisez await à la place.
  • Oublier await. Le travail s'exécute sans être observé et ses exceptions disparaissent.
  • Envelopper des E/S dans Task.Run. Une API async comme File.ReadAllTextAsync ou HttpClient.GetStringAsync libère déjà le thread ; Task.Run ne fait qu'ajouter un passage par le pool de threads.
  • Supposer qu'async signifie « s'exécute sur un autre thread ». Cela signifie « peut se mettre en pause sans bloquer ». Utilisez Task.Run pour le travail lié au CPU.

Questions fréquentes

Comment fonctionnent async et await en C# ?

Marquer une méthode async lui permet d'utiliser await. Quand la méthode atteint await sur une tâche qui n'est pas terminée, elle revient immédiatement à son appelant en lui rendant une Task qui représente le reste du travail. Quand l'opération attendue se termine, la méthode reprend après l'await. Aucun thread ne reste bloqué pendant l'attente.

Quelle est la différence entre Task et Task<T> ?

Task représente une opération qui ne produit pas de valeur, la version async d'une méthode void. Task<T> représente une opération qui produit un T : await sur une Task<int> vous donne l'int. Une méthode async déclarée async Task<int> écrit simplement return 42;, et le compilateur l'enveloppe dans la tâche.

Comment exécuter plusieurs opérations async en même temps ?

Démarrez-les toutes d'abord, puis attendez-les ensemble : var a = GetUserAsync(); var b = GetOrdersAsync(); await Task.WhenAll(a, b);. Écrire await GetUserAsync(); await GetOrdersAsync(); les exécute l'une après l'autre, donc le temps total est la somme au lieu de la plus longue.

Pourquoi async void est-il déconseillé en C# ?

Une méthode async void ne peut pas être attendue, donc l'appelant ne sait pas quand elle se termine, et une exception levée à l'intérieur ne peut pas être interceptée par l'appelant : elle est relevée sur le contexte de synchronisation et fait en général planter le processus. Renvoyez plutôt une Task. async void n'existe que pour les gestionnaires d'événements, dont la signature exige void.

Pourquoi .Result ou .Wait() provoquent-ils un interblocage ?

Dans une application dotée d'un contexte de synchronisation (WinForms, WPF, ASP.NET classique), .Result bloque le thread d'interface ou de requête pendant que la continuation de la méthode attendue attend de s'exécuter sur ce même thread. Chacun attend l'autre indéfiniment. Utilisez await jusqu'en haut au lieu de bloquer, et utilisez ConfigureAwait(false) dans le code de bibliothèque.

Main peut-il être async en C# ?

Oui, depuis C# 7.1 : static async Task Main() ou static async Task<int> Main(). Les instructions de niveau supérieur (C# 9) peuvent utiliser await directement. Dans du code plus ancien, l'équivalent consiste à appeler MainAsync().GetAwaiter().GetResult() depuis un Main normal, ce qui est sûr à cet endroit car une application console n'a pas de contexte de synchronisation.

Coddy programming languages illustration

Apprendre à coder avec Coddy

COMMENCER