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.

L'image

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.

SYNCHRONE — 1 thread bloqué par requête code ATTENTE (thread bloqué) code → 3 requêtes = 3 threads immobilisés ASYNCHRONE — le thread est rendu pendant l'attente code await : thread LIBRE, il sert d'autres requêtes suite → 3 requêtes ≈ 1 thread suffit le même thread enchaîne d'autres travaux au lieu d'attendre
async n'accélère pas une requête individuelle. Il permet d'en servir beaucoup plus avec les mêmes ressources.

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.

Sous le capot : ce que fait vraiment await

Le compilateur découpe ta méthode en une machine à états. À chaque await :

  1. l'opération est lancée ;
  2. si elle n'est pas déjà terminée, la méthode retourne immédiatement à son appelant un Task non complété ;
  3. la suite du corps est enregistrée comme continuation, avec les variables locales sauvegardées dans un objet ;
  4. le thread est rendu au pool ;
  5. 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

Ne jamais faire
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.

Toujours faire
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.

Pourquoi .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 travailExemplesLa bonne réponse
I/O-bound — on attend quelqu'unBase de données, HTTP, fichier, file de messagesawait la méthode ...Async native. Jamais Task.Run
CPU-bound — on calculeRedimensionner 500 images, compresser, chiffrerawait Task.Run(() => Calcul()), ou Parallel.ForEachAsync
Piège : 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

En série : 900 ms
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épendants
En parallèle : 300 ms
var 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ée
Attention avec EF Core

Ne 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);
Bon réflexe

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, ValueTask, limitation de concurrence

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 ?

Une requête isolée peut même être très légèrement plus lente (machine à états). Le gain est la scalabilité : le thread n'est plus immobilisé pendant l'attente.

2. Ton API ASP.NET Core devient très lente sous charge, sans aucune erreur dans les logs. Premier suspect ?

Symptôme typique : temps de réponse qui grimpent en escalier, pool épuisé, aucune exception. Cherche .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 ?

Trois 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 ?

Ailleurs, l'appelant ne peut ni attendre la fin, ni attraper l'exception — qui fait tomber le processus entier.

5. ConfigureAwait(false) dans un contrôleur ASP.NET Core :

Utile dans une bibliothèque réutilisable (susceptible d'être appelée depuis WPF ou WinForms). Dans une application web ASP.NET Core, c'est du bruit.

Fiches de révision

Que fait await ?
Il rend le thread pendant l'attente et reprend la suite quand le résultat arrive — souvent sur un autre thread.
Le péché mortel ?
.Result / .Wait() sur du code async : interblocage ou famine du pool.
I/O-bound vs CPU-bound ?
Attendre (réseau, base) → await ...Async. Calculer → Task.Run.
Trois appels indépendants ?
Lancer sans await, puis await Task.WhenAll(...).
Signature idéale ?
Task<T> FaireAsync(..., CancellationToken ct = default).
DbContext et parallélisme ?
Interdit : un contexte à la fois. Sinon IDbContextFactory et un contexte par tâche.
À retenir