Programmation asynchrone
Task, async et await, ConfigureAwait, interblocages.
Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 17 min
Un thread coûte une pile — un mégaoctet réservé par défaut sous Windows — et un basculement de contexte chaque fois que l'ordonnanceur le remplace. Tant qu'un appel réseau ou disque immobilise un thread, un serveur qui tient mille requêtes paie mille piles pour du code qui n'exécute rien. async et await servent à rendre ce thread pendant l'attente et à reprendre le travail ensuite : c'est leur seul objet. Presque toutes les erreurs classiques viennent d'une idée fausse sur ce que le compilateur écrit à la place du mot await, et ce cours part de là.
Ce qu'async ne fait pas
async n'est pas une propriété de la signature : c'est une instruction au compilateur de réécrire le corps de la méthode. Aucun thread n'est créé, aucun travail n'est lancé en arrière-plan. Appeler une méthode async l'exécute sur le thread appelant, comme n'importe quelle méthode, et ce jusqu'au premier await qui suspend vraiment. Si aucun ne suspend — toutes les tâches attendues étaient déjà terminées — la méthode entière s'exécute de façon synchrone et rend une tâche déjà achevée.
Asynchrone et parallèle répondent à deux questions différentes. Parallèle : combien de threads calculent en même temps. Asynchrone : combien de threads sont immobilisés à ne rien faire. Une vraie entrée-sortie asynchrone ne repose sur aucun thread — la demande est remise au système, qui signale son achèvement par un port de complétion sous Windows, par epoll sous Linux, et un thread du pool récupère alors la continuation. Entre l'envoi et la réponse, personne n'attend.
La concurrence ne vient donc pas du mot async mais du moment où l'on attend : deux appels attendus l'un après l'autre s'exécutent en série, les mêmes démarrés d'abord puis attendus ensemble se recouvrent.
using System;
using System.Diagnostics;
using System.Threading.Tasks;
static class Duree
{
// Aucun thread ne porte cette attente : Task.Delay arme un minuteur et rend
// la main. La methode ne reprendra qu'au declenchement.
static async Task<int> LireAsync(string source, int millisecondes)
{
await Task.Delay(millisecondes);
return source.Length;
}
public static async Task ExecuterAsync()
{
var chrono = Stopwatch.StartNew();
// await suspend la methode sur place : le second appel n'est meme pas
// parti quand la premiere attente commence.
var clients = await LireAsync("clients", 400);
var commandes = await LireAsync("commandes", 400);
Console.WriteLine($"serie {clients + commandes} en {chrono.ElapsedMilliseconds} ms");
// serie 16 en 8xx ms : les deux attentes se suivent
chrono.Restart();
// Appeler une methode async la demarre. Les deux Task sont en vol avant
// le premier await, donc les deux attentes se recouvrent.
var tacheClients = LireAsync("clients", 400);
var tacheCommandes = LireAsync("commandes", 400);
var tailles = await Task.WhenAll(tacheClients, tacheCommandes);
var total = tailles[0] + tailles[1];
Console.WriteLine($"concurrent {total} en {chrono.ElapsedMilliseconds} ms");
// concurrent 16 en 4xx ms : la duree est celle de la plus longue
// Les millisecondes exactes varient : Task.Delay garantit un minimum,
// pas une precision. Le rapport du simple au double, lui, tient.
}
} De la même confusion vient l'idée d'« asynchroniser » une méthode bloquante en l'enveloppant dans Task.Run. Rien n'y devient asynchrone : un appel bloquant reste bloquant, il bloque simplement un autre thread. Sur un serveur, le thread de la requête est libéré et un thread du même pool est occupé exactement aussi longtemps.
using System.Data;
using System.Threading.Tasks;
public sealed class DepotTarifs
{
private readonly IDbConnection connexion;
public DepotTarifs(IDbConnection connexion) => this.connexion = connexion;
private int Lire(string reference)
{
using var commande = connexion.CreateCommand();
commande.CommandText = "select tarif from tarifs where reference = @r";
return (int)commande.ExecuteScalar()!;
}
// Task.Run ne rend rien asynchrone. Il deplace un appel bloquant sur un
// thread du pool : le thread de la requete est libere, un autre thread du
// pool est bloque exactement aussi longtemps. Sur un serveur, ou tous les
// threads viennent du meme pool, le bilan est negatif : deux basculements
// de contexte de plus, et pas une attente de moins.
public Task<int> LireAsync(string reference) => Task.Run(() => Lire(reference));
} La version juste ne consiste pas à mieux envelopper, mais à appeler l'API asynchrone qui existe déjà. Task.Run garde un usage : sortir un calcul d'un thread qu'il ne faut pas bloquer, celui de l'interface graphique. Sur un serveur, tous les threads viennent du pool, et il n'y a rien à libérer.
using System.Data.Common;
using System.Threading;
using System.Threading.Tasks;
public sealed class DepotTarifs
{
private readonly DbConnection connexion;
public DepotTarifs(DbConnection connexion) => this.connexion = connexion;
// Quand l'operation est une entree-sortie, l'API asynchrone existe deja :
// elle rend la main pendant que la carte reseau travaille, et aucun thread
// n'attend la reponse.
public async Task<int> LireAsync(string reference, CancellationToken cancellationToken)
{
await using var commande = connexion.CreateCommand();
commande.CommandText = "select tarif from tarifs where reference = @r";
var valeur = await commande.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return (int)valeur!;
}
// Task.Run garde un usage : sortir un calcul d'un thread qu'il ne faut pas
// bloquer, celui de l'interface. Sur un serveur, il n'y a rien a liberer.
public static Task<long> CumulerAsync(int[] valeurs, CancellationToken cancellationToken) =>
Task.Run(
() =>
{
var total = 0L;
foreach (var valeur in valeurs)
{
cancellationToken.ThrowIfCancellationRequested();
total += (long)valeur * valeur;
}
return total;
},
cancellationToken);
}La machine à états derrière await
Le compilateur transforme chaque méthode async en un type à états qui implémente IAsyncStateMachine : une struct en Release, une classe en Debug, où le débogueur a besoin d'une référence stable. Le corps d'origine devient le contenu d'un MoveNext découpé en portions, une par await, séparées par un champ d'état entier. Les variables locales qui survivent à un await deviennent des champs de ce type — c'est précisément pour cela qu'elles survivent.
La méthode que l'appelant voit se réduit alors à trois gestes : instancier la machine, appeler builder.Start, qui exécute MoveNext sur place, et rendre builder.Task.
using System.Net.Http;
using System.Threading.Tasks;
public static class Catalogue
{
private static readonly HttpClient Client = new();
public static async Task<int> TailleAsync(string url)
{
var contenu = await Client.GetStringAsync(url);
return contenu.Length;
}
// Ce que le compilateur ecrit a la place, en substance. En Release la
// machine a etats est une struct, en Debug une classe, car le debogueur a
// besoin d'une reference stable.
//
// public static Task<int> TailleAsync(string url)
// {
// var machine = new Machine
// {
// url = url,
// builder = AsyncTaskMethodBuilder<int>.Create(),
// etat = -1,
// };
//
// // Start appelle MoveNext tout de suite, sur le thread appelant : le
// // corps s'execute de facon synchrone jusqu'au premier await qui
// // suspend vraiment.
// machine.builder.Start(ref machine);
// return machine.builder.Task;
// }
//
// struct Machine : IAsyncStateMachine
// {
// public int etat;
// public AsyncTaskMethodBuilder<int> builder;
// public string url;
// private TaskAwaiter<string> attente;
//
// public void MoveNext()
// {
// string contenu;
// try
// {
// if (etat != 0)
// {
// attente = Client.GetStringAsync(url).GetAwaiter();
//
// // Le raccourci qui fait tout l'interet du modele : si la
// // tache est deja terminee, rien n'est suspendu, rien
// // n'est alloue sur le tas, et on tombe directement sur
// // GetResult.
// if (!attente.IsCompleted)
// {
// etat = 0;
//
// // Copie la struct sur le tas, puis inscrit MoveNext
// // comme continuation de l'attente. La methode rend
// // la main ici : l'appelant recoit une Task en cours.
// builder.AwaitUnsafeOnCompleted(ref attente, ref this);
// return;
// }
// }
//
// // Reprise. GetResult rend la valeur, ou relance l'exception
// // de la tache telle quelle, sans AggregateException autour.
// contenu = attente.GetResult();
// }
// catch (Exception erreur)
// {
// // Une exception ne remonte pas a l'appelant : elle est
// // deposee dans la Task rendue.
// builder.SetException(erreur);
// return;
// }
//
// builder.SetResult(contenu.Length);
// }
// }
} Deux détails de ce squelette portent l'essentiel. Le test IsCompleted d'abord : quand la tâche attendue est déjà terminée, rien n'est suspendu, la machine à états reste sur la pile, aucune continuation n'est inscrite — une méthode async qui sert un cache en mémoire ne coûte donc presque rien. GetResult ensuite, qui relance l'exception de la tâche telle quelle, sans AggregateException autour, contrairement à Wait() et à .Result.
await n'est d'ailleurs pas lié à Task : c'est un motif. Tout type exposant un GetAwaiter() — même par méthode d'extension — qui rend un objet à IsCompleted, GetResult() et OnCompleted s'attend.
Reste l'endroit où la méthode reprend. Au moment de suspendre, l'awaiter capture SynchronizationContext.Current s'il n'est pas nul, sinon TaskScheduler.Current s'il n'est pas celui par défaut ; à la reprise, il poste la continuation dans ce qu'il a capturé, et à défaut la planifie sur le pool. Un SynchronizationContext est une politique d'exécution : celui de WinForms, de WPF et de MAUI poste dans la file de messages du thread d'interface, si bien que toute continuation y revient.
ASP.NET Core n'en installe aucun. Dans une requête, SynchronizationContext.Current vaut null et chaque continuation part au pool. Une requête peut donc changer de thread à chaque await, ce qui disqualifie ThreadStatic : ce qui doit suivre une requête, HttpContext compris, est porté par AsyncLocal, qui suit le flux d'exécution et non le thread.
using System;
using System.Threading;
using System.Threading.Tasks;
static class Reprise
{
public static async Task ExecuterAsync()
{
Console.WriteLine(SynchronizationContext.Current is null); // True : une console n'en a pas
Console.WriteLine(Thread.CurrentThread.IsThreadPoolThread); // False : le thread principal
// Sans SynchronizationContext et avec l'ordonnanceur par defaut, il n'y
// a aucun contexte a restaurer : la continuation est planifiee sur le
// pool de threads.
await Task.Delay(50);
Console.WriteLine(Thread.CurrentThread.IsThreadPoolThread); // True
// Une tache deja terminee ne suspend rien : IsCompleted est vrai, la
// suite s'execute dans la foulee, sur le meme thread. Un await n'est
// donc pas un point de bascule, c'est un point de bascule possible.
var avant = Environment.CurrentManagedThreadId;
await Task.CompletedTask;
Console.WriteLine(Environment.CurrentManagedThreadId == avant); // True
// Task.Yield ne se termine jamais de facon synchrone : il reprend la
// methode par le contexte courant, ou par le pool a defaut. Depuis
// .NET 8, ConfigureAwait(ForceYielding) impose la meme chose.
await Task.Yield();
Console.WriteLine(Thread.CurrentThread.IsThreadPoolThread); // True
}
}Task, Task<T> et ValueTask
Task est une classe : un appel qui suspend alloue un objet unique — une boîte qui est la Task rendue, porte la machine à états recopiée depuis la pile et sert elle-même de continuation. Les trois objets d'autrefois n'en font plus qu'un depuis .NET Core 2.1. Le cas synchrone, lui, est largement optimisé — une méthode async qui se termine sans jamais suspendre réutilise des instances mises en cache pour quelques valeurs courantes, dont true, false et de petits entiers. Au-delà, chaque retour alloue.
ValueTask<T> répond à ce cas précis : une struct en lecture seule qui porte soit le résultat directement, soit une Task<T>, soit un IValueTaskSource<T> accompagné d'un jeton. Quand le chemin chaud rend une valeur déjà connue, il n'y a plus rien à allouer du tout.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
sealed class CacheTarifs
{
private readonly Dictionary<string, int> cache = [];
private static async Task<int> ChargerAsync(string reference)
{
await Task.Delay(20);
return reference.Length * 150;
}
// Le cas frequent rend une valeur deja connue. Une Task ici serait une
// allocation par appel pour un resultat immediat ; ValueTask est une struct
// qui porte soit le resultat, soit la Task du cas lent.
public ValueTask<int> ObtenirAsync(string reference)
{
if (cache.TryGetValue(reference, out var tarif))
{
return new ValueTask<int>(tarif);
}
return new ValueTask<int>(ChargerPuisMemoriserAsync(reference));
}
private async Task<int> ChargerPuisMemoriserAsync(string reference)
{
var tarif = await ChargerAsync(reference);
cache[reference] = tarif;
return tarif;
}
}
static class Tarifs
{
public static async Task ExecuterAsync()
{
var cache = new CacheTarifs();
// Un ValueTask s'attend une fois, et une seule. Apres GetResult, la
// structure ne promet plus rien : quand elle enveloppe un
// IValueTaskSource, c'est le cas de Stream.ReadAsync ou d'un Channel,
// la source recycle son jeton et repond a la question suivante.
Console.WriteLine(await cache.ObtenirAsync("USB-64")); // 900
Console.WriteLine(await cache.ObtenirAsync("USB-64")); // 900, servi par le cache
// Pour le garder, le passer a plusieurs endroits ou l'attendre deux
// fois, il faut d'abord le figer avec AsTask.
var tache = cache.ObtenirAsync("CLE-USB-32").AsTask();
Console.WriteLine(await tache); // 1500
Console.WriteLine(await tache); // 1500 : une Task, elle, se rattend
}
} Le prix de cette économie est une règle stricte : un ValueTask s'attend une fois, et une seule. La raison tient à la troisième forme. Un IValueTaskSource est fait pour être réutilisé — Socket, Stream et Channel<T> en gardent un par instance et le recyclent d'une opération à la suivante. Le jeton porté par la struct identifie la question posée ; la réponse lue, la source repart sur une autre, et la même struct attendue deux fois lirait le résultat d'une opération qui n'est pas la sienne. D'où quatre interdits : la stocker, l'attendre deux fois, l'attendre depuis deux endroits, lire .Result avant l'achèvement. AsTask() les lève tous, au prix de l'allocation qu'on voulait éviter.
| Task | ValueTask | |
|---|---|---|
| Nature | classe, allouée dès qu'un await suspend | struct, n'alloue que dans le cas lent |
| Attente | autant de fois qu'on veut | une seule fois |
| Stockable, partageable | oui | non, sauf après AsTask() |
WhenAll, WhenAny | oui | non |
| Emploi | par défaut | chemin chaud au résultat souvent immédiat |
Hors de ce chemin chaud, ValueTask impose sa règle à tous les appelants pour épargner une allocation que Task paie sans rien demander.
Les trois fautes qui bloquent
.Result, .Wait() et GetAwaiter().GetResult() font la même chose : ils bloquent le thread appelant jusqu'à l'achèvement de la tâche. Le danger n'est pas le même partout.
L'interblocage classique demande un SynchronizationContext à emplacement unique, c'est-à-dire une file vidée par un seul thread — WinForms, WPF, MAUI et l'ancien ASP.NET en ont un. Le scénario tient alors en une phrase : l'await intérieur s'est engagé à reprendre sur ce thread-là, et ce thread est précisément celui qui est bloqué à attendre la fin de la tâche.
using System;
using System.Net.Http;
using System.Threading.Tasks;
// WinForms, WPF, MAUI : le gestionnaire s'execute sur le thread d'interface,
// qui porte un SynchronizationContext a file unique : une seule chose a la
// fois, et toujours sur ce thread-la.
public sealed class FenetreCommandes
{
private static readonly HttpClient Client = new();
private static async Task<string> LireAsync()
{
// Cet await capture le contexte courant, donc celui de l'interface, et
// s'engage a reprendre dessus.
return await Client.GetStringAsync("https://exemple.fr/commandes");
}
public void Bouton_Click(object? expediteur, EventArgs e)
{
// .Result bloque le thread d'interface jusqu'a la fin de la tache. Mais
// la tache ne peut finir que si la suite de LireAsync s'execute, et
// cette suite est en file d'attente sur ce meme thread, qui ne depilera
// plus rien tant qu'il est bloque ici. Les deux s'attendent, pour
// toujours. Wait(), GetAwaiter().GetResult() et WaitAll produisent le
// meme effet, pour la meme raison.
var texte = LireAsync().Result;
Console.WriteLine(texte.Length);
}
} La correction n'est pas de mieux bloquer, mais de ne plus bloquer : await remonte jusqu'en haut de la pile d'appels.
using System;
using System.Net.Http;
using System.Threading.Tasks;
public sealed class FenetreCommandes
{
private static readonly HttpClient Client = new();
private static Task<string> LireAsync() =>
Client.GetStringAsync("https://exemple.fr/commandes");
// async void est tolere ici, et seulement ici : un gestionnaire d'evenement
// doit respecter la signature du delegue. Le thread d'interface est rendu
// au premier await, la file continue de tourner, et la suite y revient
// quand la reponse arrive.
public async void Bouton_Click(object? expediteur, EventArgs e)
{
// Le corps entier est enveloppe : une exception qui sort d'un async
// void n'a aucune Task ou se poser. Elle part au gestionnaire de
// dernier recours de l'hote, que l'appelant ne voit jamais.
try
{
var texte = await LireAsync();
Console.WriteLine(texte.Length);
}
catch (HttpRequestException erreur)
{
Console.WriteLine(erreur.Message);
}
}
}Dans ASP.NET Core, cet interblocage-là ne se produit pas : la continuation part au pool et l'appel finit par revenir. La règle tient quand même, pour une autre raison : le thread de la requête est un thread du pool, et le bloquer le retire de la circulation sans rien libérer. Sous charge, le pool n'agrandit son effectif au-delà de son minimum que par petites injections espacées, si bien que quelques dizaines d'appels bloquants simultanés suffisent à faire attendre les requêtes suivantes avant même qu'elles aient commencé. C'est la famine du pool, et elle se lit comme une lenteur générale sans point chaud identifiable.
La troisième faute est async void. Son builder n'a aucune tâche où déposer quoi que ce soit : l'appelant ne peut ni l'attendre, ni savoir qu'elle est finie, ni attraper son exception. Celle-ci est relancée sur le SynchronizationContext capturé au démarrage, ou sur le pool à défaut. Sans contexte — console, service, ASP.NET Core — elle devient non gérée et le processus s'arrête ; sous un hôte d'interface, elle échoit au gestionnaire de dernier recours de cet hôte, que WinForms résout par une boîte de dialogue. Dans les deux cas, l'appelant n'a aucun moyen de la voir.
using System;
using System.Threading.Tasks;
static class Import
{
// async void : le builder est AsyncVoidMethodBuilder. Il n'a pas de Task
// ou deposer un resultat, ni une exception.
static async void TraiterAsync(string fichier)
{
await Task.Delay(50);
throw new InvalidOperationException($"Fichier illisible : {fichier}");
}
public static async Task ExecuterAsync()
{
try
{
// L'appel rend la main au premier await, donc avant l'exception.
// Le try est referme depuis longtemps quand elle est levee.
TraiterAsync("stock.csv");
}
catch (InvalidOperationException)
{
Console.WriteLine("rattrapee"); // jamais affiche
}
// Faute de Task, le builder relance l'exception sur le
// SynchronizationContext capture au demarrage, ou sur le pool de
// threads s'il n'y en avait pas. Elle devient une exception non geree :
// le processus s'arrete.
await Task.Delay(500);
Console.WriteLine("jamais atteint");
}
}La même méthode en async Task rend l'exception observable :
using System;
using System.Threading.Tasks;
static class Import
{
// async Task : l'exception est deposee dans la tache rendue, et relancee
// telle quelle par l'await de l'appelant.
static async Task TraiterAsync(string fichier)
{
await Task.Delay(50);
throw new InvalidOperationException($"Fichier illisible : {fichier}");
}
public static async Task ExecuterAsync()
{
try
{
await TraiterAsync("stock.csv");
}
catch (InvalidOperationException erreur)
{
Console.WriteLine(erreur.Message); // Fichier illisible : stock.csv
}
// Le type de retour ne suffit pas : c'est l'await qui lit la tache.
// Sans lui, l'exception reste dedans, personne ne la regarde, et depuis
// .NET Framework 4.5 elle ne termine plus le processus : elle disparait
// en silence, ce qui est pire.
_ = TraiterAsync("ventes.csv");
await Task.Delay(500);
Console.WriteLine("termine"); // termine
}
} Ce second exemple montre aussi la limite : c'est l'await qui lit la tâche, pas le type de retour. Une tâche fautive que personne n'attend ne termine plus le processus depuis .NET Framework 4.5 — elle disparaît, ce qui se diagnostique plus mal qu'un plantage. async void ne reste défendable que pour un gestionnaire d'événement, dont la signature est imposée par le délégué, et à condition d'envelopper tout le corps dans un try.
ConfigureAwait(false)
ConfigureAwait(false) ne règle pas une performance, il règle une reprise. Il pose ContinueOnCapturedContext à faux sur l'awaiter : rien n'est capturé à la suspension, et la continuation est planifiée sur le pool, ou exécutée en ligne sur le thread qui achève la tâche. Ce qu'il économise, c'est un Post vers un contexte. Là où il n'y a pas de contexte, il n'économise rien.
C'est le cas d'ASP.NET Core, sans contexte et avec l'ordonnanceur par défaut : l'y écrire est du bruit, et le faire au nom des performances, un contresens.
Dans une bibliothèque, il redevient nécessaire, parce qu'une bibliothèque ignore qui l'appelle. Si une application WPF l'appelle et bloque sur .Result — un défaut qu'on ne contrôle pas d'ici — c'est ConfigureAwait(false) qui empêche l'interblocage, la continuation n'ayant plus besoin du thread d'interface.
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
// Une bibliotheque ignore qui l'appelle. Revenir sur le contexte de l'appelant
// ne lui sert a rien, et le lui imposer suffit a l'interbloquer quand cet
// appelant est une interface graphique qui bloque son propre thread.
public sealed class ClientTarifs
{
private readonly HttpClient client;
public ClientTarifs(HttpClient client) => this.client = client;
public async Task<int> LireAsync(string reference, CancellationToken cancellationToken)
{
var url = $"https://exemple.fr/tarifs/{reference}";
// ConfigureAwait(false) dit « la suite n'a pas besoin du contexte ». La
// continuation part alors au pool de threads, ou s'execute en ligne sur
// le thread qui termine la tache.
var corps = await client.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
// A partir d'ici, aucun contexte n'est capture : on a quitte celui de
// l'appelant, et rien ne le retablit avant le retour.
return corps.Length;
}
// Il porte sur le seul await qui le suit, pas sur la methode. Un await
// oublie remet les continuations suivantes dans le contexte de l'appelant.
public async Task<int> LireDeuxAsync(string a, string b, CancellationToken cancellationToken)
{
var premier = await LireAsync(a, cancellationToken).ConfigureAwait(false);
var second = await LireAsync(b, cancellationToken).ConfigureAwait(false);
return premier + second;
}
// Et il ne fait rien du tout quand la tache est deja terminee : l'await ne
// suspend pas, donc il n'y a aucune continuation a planifier, et rien a
// configurer.
public ValueTask<int> LireEnCacheAsync(int deja) => new(deja);
// .NET 8 ajoute une surcharge a options, sur Task comme sur Task<T>.
// ForceYielding impose la suspension meme sur une tache deja terminee : de
// quoi garantir qu'une methode ne rend jamais la main de facon synchrone.
// SuppressThrowing, lui, n'a de sens que sur le Task non generique ; sur un
// Task<T> il leve ArgumentOutOfRangeException, et l'analyseur CA2261 le
// signale des la compilation.
public static async Task RendreLaMainAsync() =>
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
} Il porte sur un await, pas sur une méthode : il faut donc l'écrire sur tous. On objecte qu'un seul suffit, puisque après une reprise hors contexte il n'y a plus rien à capturer. C'est vrai — à condition que cet await ait réellement suspendu. Une tâche déjà terminée ne suspend pas, l'option n'a alors aucun effet, et le contexte de l'appelant est toujours là au suivant. Or on ne sait pas, à l'écriture, lequel suspendra.
.NET 8 ajoute une surcharge à options, sur Task comme sur Task<T> : ForceYielding impose la suspension même sur une tâche achevée, et SuppressThrowing attend l'achèvement sans relancer l'exception. Cette dernière n'a de sens que sur le Task non générique, un résultat manquant ne pouvant pas être inventé : sur un Task<T> elle lève ArgumentOutOfRangeException, et l'analyseur CA2261 la signale dès la compilation.
Annulation
CancellationToken est une struct qui ne fait que lire l'état d'un CancellationTokenSource. La séparation est le point : qui reçoit le jeton observe l'annulation sans jamais pouvoir la déclencher, et le droit de l'ordonner reste chez qui détient la source.
Rien ne transporte un jeton tout seul : il n'est pas ambiant, il ne suit pas le flux d'exécution, c'est un paramètre à faire suivre de niveau en niveau. Une chaîne d'appels qui le perd au milieu ne s'annule qu'à moitié, et le symptôme est une opération qui continue longtemps après que l'appelant y a renoncé.
Le respecter demande deux gestes distincts : passer le jeton aux API asynchrones de la bibliothèque standard, qui abandonnent alors leur attente sans aller au bout, et appeler ThrowIfCancellationRequested entre les opérations, parce que personne d'autre ne surveille le temps passé dans la méthode elle-même.
using System;
using System.Threading;
using System.Threading.Tasks;
static class Traitement
{
// Le jeton est un parametre ordinaire : rien ne le transporte tout seul
// d'un niveau d'appel au suivant. La convention le met en dernier, sous le
// nom cancellationToken.
static async Task<int> LireLotAsync(int numero, CancellationToken cancellationToken)
{
// Les API asynchrones de la BCL prennent le jeton et abandonnent
// l'attente elles-memes, sans aller au bout du delai.
await Task.Delay(100, cancellationToken);
return numero * 10;
}
static async Task<int> TraiterAsync(int lots, CancellationToken cancellationToken)
{
var total = 0;
for (var numero = 1; numero <= lots; numero++)
{
// Entre deux appels, c'est au code de regarder. Un jeton qu'on se
// contente de faire suivre n'interrompt rien de ce qui se passe
// entre les operations d'entree-sortie.
cancellationToken.ThrowIfCancellationRequested();
total += await LireLotAsync(numero, cancellationToken);
}
return total;
}
public static async Task ExecuterAsync()
{
// Le CancellationTokenSource detient un minuteur : il se libere.
using var source = new CancellationTokenSource(TimeSpan.FromMilliseconds(250));
try
{
// Dix lots de 100 ms : l'arret tombe a coup sur avant la fin, mais
// le lot sur lequel il tombe depend de la machine.
await TraiterAsync(10, source.Token);
Console.WriteLine("termine");
}
catch (OperationCanceledException)
{
// TaskCanceledException en derive : un seul catch couvre les deux.
// Qui a demande l'arret se lit sur la source de l'appelant : le jeton
// porte par l'exception n'est le sien que si personne ne l'a lie plus bas.
Console.WriteLine("annule"); // annule
Console.WriteLine(source.IsCancellationRequested); // True
}
}
} L'annulation se signale par OperationCanceledException, dont TaskCanceledException dérive : un seul catch couvre les deux. Qui l'a demandée se lit sur la source de l'appelant, pas sur le jeton de l'exception, qu'un niveau intermédiaire a pu lier (cours Parallélisme et flux). L'attraper pour rendre une valeur par défaut ment à l'appelant : la tâche paraît réussie alors qu'elle n'a rien fini.
Deux détails coûtent cher quand on les ignore. CancellationTokenSource est IDisposable : construit avec un délai, ou armé par CancelAfter, il détient un minuteur, et une source liée par CreateLinkedTokenSource détient une inscription chez chacun de ses parents — ne pas la libérer laisse fuir de la mémoire tant que ces parents vivent. Et Cancel() exécute les rappels inscrits par Register de façon synchrone, sur le thread qui l'appelle : un rappel lent y retarde tout le monde. Dans ASP.NET Core, le jeton de la requête est HttpContext.RequestAborted, qu'un paramètre CancellationToken sur une action ou un point de terminaison reçoit sans rien écrire de plus.
Attendre plusieurs tâches
Task.WhenAll ne démarre rien : les tâches qu'il reçoit tournent déjà, et il rend seulement une tâche qui s'achève quand toutes le sont.
La gestion des exceptions y est particulière. Si plusieurs tâches échouent, la tâche rendue porte une AggregateException complète, mais l'await n'en relance que la première. C'est ce qui permet d'écrire catch (HttpRequestException) plutôt que de déballer un agrégat, et aussi ce qui fait disparaître les autres échecs des journaux : pour les voir, il faut garder la tâche rendue et lire sa propriété Exception.
using System;
using System.Threading.Tasks;
static class Agregat
{
static async Task<int> LireAsync(string source, int valeur)
{
await Task.Delay(30);
if (valeur < 0)
{
throw new InvalidOperationException($"{source} indisponible");
}
return valeur;
}
public static async Task ExecuterAsync()
{
// WhenAll ne demarre rien : les taches tournent deja. Il ne fait
// qu'observer, et rend une tache qui s'acheve quand toutes sont finies.
var clients = LireAsync("clients", -1);
var stocks = LireAsync("stocks", -1);
var tarifs = LireAsync("tarifs", 12);
var toutes = Task.WhenAll(clients, stocks, tarifs);
try
{
await toutes;
}
catch (InvalidOperationException premiere)
{
// await ne relance que la premiere exception du lot. C'est ce qui
// permet d'ecrire un catch sur le type reel, mais cela masque les
// autres echecs.
Console.WriteLine(premiere.Message); // clients indisponible
// Elles sont dans la tache, pas dans l'exception attrapee.
var agregat = toutes.Exception!;
Console.WriteLine(agregat.InnerExceptions.Count); // 2
foreach (var erreur in agregat.InnerExceptions)
{
Console.WriteLine($" {erreur.Message}");
}
// clients indisponible
// stocks indisponible
}
// Un echec n'annule pas les autres : elles vont au bout, et restent
// lisibles une a une.
Console.WriteLine(await tarifs); // 12
}
} Un échec n'interrompt pas les autres tâches : elles vont au bout, et l'agrégat n'est constitué qu'à la fin. Deux pièges en découlent. Le premier est l'énumération multiple du cours Collections et LINQ : un Select qui produit des tâches, parcouru deux fois, relance tous les appels ; on le matérialise avant d'attendre. Le second est que WhenAll ne limite rien : dix mille tâches font dix mille appels simultanés, que Parallel.ForEachAsync (cours Parallélisme et flux) ou un SemaphoreSlim bornent.
Task.WhenAny rend la tâche gagnante, pas son résultat, et cette tâche peut avoir échoué : c'est un second await qui la déballe. Les perdantes continuent de tourner, et il faut décider de leur sort.
using System;
using System.Threading;
using System.Threading.Tasks;
static class PremierArrive
{
static async Task<string> InterrogerAsync(
string miroir,
int millisecondes,
CancellationToken cancellationToken)
{
await Task.Delay(millisecondes, cancellationToken);
return miroir;
}
public static async Task ExecuterAsync()
{
using var source = new CancellationTokenSource();
var rapide = InterrogerAsync("miroir-A", 50, source.Token);
var lent = InterrogerAsync("miroir-B", 5000, source.Token);
// WhenAny rend la tache gagnante, pas son resultat, et elle peut avoir
// echoue : c'est le second await qui la deballe et relance son
// exception s'il y en a une.
var gagnante = await Task.WhenAny(rapide, lent);
Console.WriteLine(await gagnante); // miroir-A
// La perdante n'est pas arretee. Sans annulation, elle tient sa
// connexion jusqu'au bout et son echec eventuel ne sera jamais observe.
await source.CancelAsync();
try
{
await lent;
}
catch (OperationCanceledException)
{
Console.WriteLine(lent.IsCanceled); // True
}
// Depuis .NET 6, un delai d'attente ne s'ecrit plus a la main :
// WaitAsync fait le WhenAny, la source liee et le menage, et leve
// TimeoutException — sans arreter pour autant l'operation sous-jacente,
// qui continue jusqu'au bout.
try
{
await InterrogerAsync("miroir-C", 5000, CancellationToken.None)
.WaitAsync(TimeSpan.FromMilliseconds(50));
}
catch (TimeoutException)
{
Console.WriteLine("delai depasse"); // delai depasse
}
// Et depuis .NET 9, Task.WhenEach rend les taches au fil de leur
// achevement, sans retirer la gagnante d'une liste a chaque tour.
var lots = new[]
{
InterrogerAsync("lot-1", 10, CancellationToken.None),
InterrogerAsync("lot-2", 200, CancellationToken.None),
};
await foreach (var finie in Task.WhenEach(lots))
{
Console.WriteLine(await finie); // lot-1, puis lot-2
}
}
} Le délai d'attente écrit à la main avec WhenAny et Task.Delay n'a plus lieu d'être : depuis .NET 6, WaitAsync fait le même travail et lève TimeoutException. Et depuis .NET 9, Task.WhenEach rend les tâches au fil de leur achèvement sous forme d'IAsyncEnumerable, ce qui remplace la boucle qui appelait WhenAny puis retirait la gagnante d'une liste — boucle dont le coût croissait comme le carré du nombre de tâches.