Accueil › Les fondations › Chapitre 8
async / await sans douleur
Deux mots-clés, une idée simple, et un nombre impressionnant de façons de se tromper. On commence par le problème que ça résout — sans ça, le reste ressemble à de la magie noire.
Le problème : attendre coûte cher
Un serveur web passe l'essentiel de son temps à… attendre : la base de données, une API distante, le disque. Pendant cette attente, le thread qui exécute la requête ne fait strictement rien — mais il est occupé, donc indisponible pour les autres visiteurs.
Version synchrone : le serveur prend ta commande, l'apporte en cuisine, et reste planté devant le four jusqu'à ce que le plat soit prêt. Les quinze autres tables attendent. Pour servir plus de monde, il faut embaucher des serveurs — et un serveur, ça coûte cher (environ 1 Mo de pile par thread).
Version asynchrone (await) : le serveur passe la commande en cuisine, puis
va servir les autres tables. Quand la cuisine sonne, il revient chercher le plat — pas forcément le
même serveur, d'ailleurs. Même équipe, cinq fois plus de couverts.
La syntaxe, en trois règles
// 1. La méthode qui attend est marquée async et renvoie Task (ou Task<T>)
// 2. Son nom finit par Async (convention universelle)
// 3. On écrit await devant l'appel qui prend du temps
public async Task<Client> ChargerClientAsync(int id)
{
var client = await _db.Clients.FindAsync(id); // attente sans blocage
var commandes = await _api.GetCommandesAsync(id); // puis une autre
client.Commandes = commandes;
return client; // ← on renvoie un Client, pas un Task<Client>
}
Le code se lit de haut en bas, comme du code synchrone : c'est tout l'intérêt. Sans
async/await, il faudrait écrire des callbacks imbriqués.
await
Le compilateur découpe ta méthode en une machine à états. À chaque await :
- l'opération est lancée ;
- si elle n'est pas déjà terminée, la méthode retourne immédiatement à son appelant un
Tasknon complété ; - la suite du corps est enregistrée comme continuation, avec les variables locales sauvegardées dans un objet ;
- le thread est rendu au pool ;
- quand l'opération se termine, la continuation est planifiée — sur un thread du pool, généralement pas le même.
Conséquences pratiques : Thread.CurrentThread.ManagedThreadId peut changer d'une ligne à
l'autre ; il n'y a aucun thread supplémentaire créé pour attendre une opération réseau (c'est le système
d'exploitation qui prévient) ; et un await sur une tâche déjà terminée est presque gratuit — pas
de retour au pool.
Les quatre façons de tout casser
1. .Result et .Wait() : le blocage
var client = ChargerClientAsync(42).Result; // 💥
ChargerClientAsync(42).Wait(); // 💥
var c2 = ChargerClientAsync(42).GetAwaiter().GetResult(); // 💥 (juste mieux déguisé)Conséquences : interblocage garanti dans les contextes à synchronisation
(anciennes applis ASP.NET, WPF, WinForms) ; famine du pool de threads sous charge en ASP.NET Core ;
et les exceptions arrivent enveloppées dans une AggregateException illisible.
var client = await ChargerClientAsync(42);« async de bout en bout » : si une méthode attend, elle est async, et
son appelant aussi, jusqu'au point d'entrée. En ASP.NET Core, le point d'entrée est le gestionnaire de route
ou l'action du contrôleur : il peut être async, tout va bien.
.Result provoque un interblocage
Dans un contexte à synchronisation à thread unique (l'UI, l'ancien ASP.NET), la continuation d'un
await doit revenir sur ce thread précis. Or ce thread est justement bloqué par
.Result, qui attend la fin de la tâche… laquelle attend la libération du thread. Chacun attend
l'autre : plus rien ne bouge, sans exception ni message.
ASP.NET Core n'a pas de contexte de synchronisation, donc pas d'interblocage de ce type. Le problème devient une famine : chaque appel bloquant confisque un thread du pool ; sous charge, le pool s'épuise, les temps de réponse explosent, et l'application semble « figée » sans qu'aucune erreur n'apparaisse.
2. async void
public async void Traiter() // ❌ personne ne peut attendre cette méthode
{
await Task.Delay(100);
throw new Exception("perdu"); // 💥 exception non observable → crash du processus
}
public async Task TraiterAsync() // ✅ l'appelant peut await, et attraper l'exception
{
await Task.Delay(100);
}
Seule exception légitime : un gestionnaire d'événement d'interface
(async void OnClick(object s, EventArgs e)), parce que la signature est imposée. Partout ailleurs :
async Task.
3. Confondre attente d'entrée/sortie et calcul
| Nature du travail | Exemples | La bonne réponse |
|---|---|---|
| I/O-bound — on attend quelqu'un | Base de données, HTTP, fichier, file de messages | await la méthode ...Async native. Jamais Task.Run |
| CPU-bound — on calcule | Redimensionner 500 images, compresser, chiffrer | await Task.Run(() => Calcul()), ou Parallel.ForEachAsync |
Task.Run autour d'un appel réseau
// ❌ occupe un thread du pool pour... attendre
var r = await Task.Run(() => http.GetString(url));
// ✅ n'occupe aucun thread pendant l'attente
var r = await http.GetStringAsync(url);Dans une application web, Task.Run ne « libère » rien : le thread vient du même pool que celui
qui traite les requêtes. Tu déplaces le problème en ajoutant un changement de contexte.
4. Attendre en série ce qui pourrait être parallèle
var meteo = await ApiMeteoAsync(); // 300 ms
var change = await ApiChangeAsync(); // 300 ms
var stock = await ApiStockAsync(); // 300 ms
// total ≈ 900 ms — alors qu'ils
// sont indépendantsvar tMeteo = ApiMeteoAsync(); // on LANCE
var tChange = ApiChangeAsync(); // sans await
var tStock = ApiStockAsync();
await Task.WhenAll(tMeteo, tChange, tStock);
var meteo = tMeteo.Result; // ✅ ici .Result est sûr :
// la tâche est terminéeNe parallélise jamais plusieurs requêtes sur le même DbContext : il n'est pas
thread-safe, tu obtiendrais « A second operation was started on this context instance before a previous
operation completed ». Pour du parallélisme base de données, crée un contexte par tâche via une
IDbContextFactory.
Annulation : le jeton qu'on oublie toujours
// En Minimal API, ASP.NET Core fournit le jeton automatiquement :
// il est annulé si le client ferme l'onglet ou si le délai est dépassé.
app.MapGet("/recherche", async (string q, AppDbContext db, CancellationToken ct) =>
{
var resultats = await db.Produits
.Where(p => p.Nom.Contains(q))
.Take(50)
.ToListAsync(ct); // ← on le transmet !
return Results.Ok(resultats);
});
// Le propager partout, jusqu'au bout de la chaîne
public async Task<Rapport> GenererAsync(CancellationToken ct)
{
var données = await _db.ChargerAsync(ct);
ct.ThrowIfCancellationRequested(); // point de contrôle dans un long calcul
return Composer(données);
}
// Poser un délai maximal
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var r = await http.GetStringAsync(url, cts.Token);
Toute méthode async publique devrait accepter un CancellationToken ct = default
en dernier paramètre et le transmettre à tous ses appels. C'est gratuit à l'écriture, impossible à
rattraper après coup, et c'est ce qui empêche un serveur de continuer à travailler pour des clients partis.
Recevoir au fil de l'eau : IAsyncEnumerable
// Le producteur : les éléments arrivent progressivement
async IAsyncEnumerable<Ligne> LireLignesAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
await using var flux = File.OpenRead("gros.csv");
using var lecteur = new StreamReader(flux);
while (await lecteur.ReadLineAsync(ct) is { } ligne)
yield return Analyser(ligne);
}
// Le consommateur
await foreach (var ligne in LireLignesAsync(ct))
Traiter(ligne); // le premier élément est traité sans attendre le dernier
C'est le mécanisme derrière les réponses Server-Sent Events et le streaming JSON en Minimal API (chapitre 14).
Exceptions et asynchrone
try
{
await ChargerAsync(); // l'exception se propage normalement
}
catch (HttpRequestException ex) { /* ... */ }
catch (OperationCanceledException) { /* annulation : souvent normal, pas une erreur */ }
// Avec WhenAll, SEULE la première exception est levée par await…
var taches = urls.Select(u => TelechargerAsync(u)).ToList();
try { await Task.WhenAll(taches); }
catch (Exception)
{
// …mais toutes sont accessibles ainsi :
foreach (var t in taches.Where(t => t.IsFaulted))
Console.WriteLine(t.Exception!.InnerException!.Message);
}
ConfigureAwait(false) — dit « je n'ai pas besoin de revenir sur le contexte d'origine ».
Inutile dans une application ASP.NET Core (il n'y a pas de contexte de synchronisation).
Recommandé dans une bibliothèque réutilisable, qui peut être consommée depuis une application WPF où
l'absence de ConfigureAwait(false) coûte un retour sur le thread d'interface — voire un
interblocage si l'appelant bloque.
ValueTask<T> — évite une allocation quand le résultat est souvent déjà disponible
(lecture dans un cache). Contraintes : ne pas l'attendre deux fois, ne pas stocker le résultat. À réserver aux
chemins très chauds et mesurés.
Limiter la concurrence — lancer 10 000 appels HTTP simultanés fait tomber la cible et ton application :
// Option 1 : Parallel.ForEachAsync (le plus simple, .NET 6+)
await Parallel.ForEachAsync(urls, new ParallelOptions { MaxDegreeOfParallelism = 8 },
async (url, ct) => await TelechargerAsync(url, ct));
// Option 2 : un sémaphore, quand on a besoin de contrôle fin
using var portillon = new SemaphoreSlim(8);
var taches = urls.Select(async url =>
{
await portillon.WaitAsync();
try { return await TelechargerAsync(url); }
finally { portillon.Release(); }
});
var resultats = await Task.WhenAll(taches);Runtime async (.NET 11) — le runtime prend en charge la suspension nativement au lieu de s'appuyer
sur une machine à états générée par le compilateur. Bénéfices : piles d'appels lisibles dans les traces
d'erreur, moins d'allocations. Aucun changement de code requis : c'est actif pour les projets
net11.0, et les bibliothèques du framework sont déjà compilées ainsi.
Vérifie que c'est passé
🎯 Quiz — 5 questions
1. async rend-il une requête individuelle plus rapide ?
2. Ton API ASP.NET Core devient très lente sous charge, sans aucune erreur dans les logs. Premier suspect ?
.Result, .Wait() et GetAwaiter().GetResult().
(Un index manquant est aussi un excellent suspect — mais il apparaît, lui, dans les métriques base.)3. Trois appels d'API indépendants de 300 ms chacun. Comment obtenir ~300 ms au total ?
await successifs = 900 ms. Task.Run ne change rien
pour de l'attente réseau. WhenAll est la bonne réponse.4. Où async void est-il acceptable ?
5. ConfigureAwait(false) dans un contrôleur ASP.NET Core :
Fiches de révision
await ?.Result / .Wait() sur du code async : interblocage ou famine du pool.await ...Async. Calculer → Task.Run.await Task.WhenAll(...).Task<T> FaireAsync(..., CancellationToken ct = default).IDbContextFactory et un contexte par tâche.- async ne va pas plus vite, il libère des threads : c'est de la capacité, pas de la vitesse.
- async de bout en bout : jamais
.Resultni.Wait(). async Task, jamaisasync void(sauf gestionnaire d'événement).Task.Runpour le calcul, jamais pour l'attente réseau.- Indépendants ?
Task.WhenAll. Mais pas sur le mêmeDbContext. - Un
CancellationTokenpartout, transmis jusqu'au bout.