Tableau de décision pratique pour C# async/await - Task.Run et ConfigureAwait
· Mis à jour le: · Go Komura · C#, async/await, .NET, Conception
Nous utilisons async / await en C# au quotidien, mais ce qui pose problème en pratique n’est pas tant la syntaxe elle-même que le choix de la bonne écriture selon la situation.
Ce que l’on recherche le plus souvent, ce sont justement ces questions de décision : quand utiliser Task.Run, où placer ConfigureAwait(false), ou encore si l’on peut se permettre un fire-and-forget.
- Envelopper une attente d’E/S dans
Task.Runalors que ce n’est pas nécessaire - Faire un
awaiten série, un par un, sur des traitements pourtant indépendants - Introduire un
fire-and-forgetsans y réfléchir et perdre la trace des exceptions ou du moment où le traitement se termine - Ajouter
ConfigureAwait(false)partout de la même façon, sans distinction - Choisir
ValueTaskuniquement parce que « ça a l’air léger »
Plutôt que de mémoriser chacun de ces points séparément, on s’égare moins en commençant par identifier le type de traitement concerné.
Dans cet article, en nous plaçant principalement dans le cadre d’un développement C# / .NET classique sous .NET 6 et versions ultérieures,
nous passons en revue les façons d’écrire async / await dans l’ordre qui facilite la décision.
Les types de développement envisagés sont par exemple les suivants :
- Applications de bureau telles que WinForms / WPF
- Applications web / API ASP.NET Core
- Workers / services en arrière-plan
- Applications console
- Bibliothèques de classes réutilisables
Le code qui apparaît dans cet article est publié sur GitHub sous la forme d’un ensemble d’exemples complet, compilable et exécutable (une bibliothèque, une démo console et des tests unitaires vérifiant chaque motif du tableau de décision).
csharp-async-await-best-practices - komurasoft-blog-samples (GitHub)
Table des matières
- La conclusion d’abord (en une phrase)
- Les termes utilisés dans cet article
- 2.1. Les termes à distinguer en premier
- 2.2. Les termes fréquents
- Le tableau de décision à consulter en premier
- 3.1. Vue d’ensemble
- 3.2. Pour une attente d’E/S,
awaitdirectement l’API async - 3.3. Pour un calcul CPU lourd, choisir où utiliser Task.Run
- 3.4. Pour plusieurs traitements indépendants, Task.WhenAll
- 3.5. Pour utiliser celui qui se termine en premier, Task.WhenAny
- 3.6. Pour beaucoup d’éléments avec un parallélisme limité, Parallel.ForEachAsync ou SemaphoreSlim
- 3.7. Pour traiter dans l’ordre, Channel<T>
- 3.8. Pour tourner à intervalle régulier, PeriodicTimer
- 3.9. Pour des données qui arrivent au fil de l’eau, IAsyncEnumerable<T>
- 3.10. Pour une libération asynchrone, await using
- 3.11. Pour une exclusion mutuelle traversant un await, SemaphoreSlim
- 3.12. Écrire await différemment selon UI / code applicatif / bibliothèque
- Les règles de base d’écriture
- 4.1. Privilégier d’abord Task / Task<T> comme type de retour
- 4.2. async void uniquement pour les gestionnaires d’événements
- 4.3. Recevoir un CancellationToken et le transmettre en aval
- 4.4. Garder les API asynchrones asynchrones jusqu’au bout
- 4.5. Lors de la création de tâches avec LINQ, matérialiser avec ToArray / ToList
- Anti-patterns fréquents
- Liste de vérification pour la revue
- Guide rapide de choix
- Résumé
- Références
1. La conclusion d’abord (en une phrase)
async/awaitest une façon d’écrire le code pour ne pas bloquer le thread pendant l’attente, et non un mécanisme qui accélère automatiquement tout ou qui déplace le traitement sur un autre thread de son propre chef- Commencez par déterminer si le traitement est une attente d’E/S ou un calcul CPU
- Pour une attente d’E/S, la base est d’attendre (await) directement l’API async
- Pour un calcul CPU, réfléchissez à où ce calcul doit s’exécuter. Dans l’UI,
Task.Runpeut être utile, mais dans le traitement des requêtes d’ASP.NET Core, il faut en principe éviter d’écrire unTask.Runsuivi immédiatement d’un await - Pour plusieurs traitements indépendants, envisagez d’abord
Task.WhenAllplutôt qu’un await en série - Quand le nombre d’éléments est important, ne lancez pas tout en même temps avec
Task.WhenAll: fixez plutôt une limite de parallélisme - Le
fire-and-forgetparaît simple mais est difficile à gérer. Si vous devez réellement détacher la durée de vie du traitement de l’appelant, il est plus stable de le confier à un emplacement géré comme un Channel ou un HostedService - Pour le type de retour, privilégiez d’abord
Task/Task<T>. Ne choisissezValueTaskqu’après avoir mesuré et constaté que c’est nécessaire ConfigureAwait(false)est pertinent dans le code de bibliothèque générique, mais dans le code UI ou applicatif, unawaitordinaire suffit dans un premier temps- N’utilisez
async voidnulle part ailleurs que dans les gestionnaires d’événements
En résumé, ce qui compte le plus autour d’async / await, c’est de ne pas tomber dans le réflexe
« Task.Run par défaut », « fire-and-forget par défaut » ou « ValueTask par défaut ».
Commencez par vous poser ces trois questions :
- Qu’est-ce que ce traitement attend réellement ?
- Qui porte la responsabilité de la durée de vie de ce traitement ?
- Où le nombre d’exécutions simultanées est-il contrôlé ?
En examinant ces trois points, l’hésitation diminue considérablement.
2. Les termes utilisés dans cet article
2.1. Les termes à distinguer en premier
Séparer ces deux notions dès le départ réduit considérablement la confusion.
| Terme | Sens ici |
|---|---|
| I/O-bound | Traitement centré sur l’attente d’une complétion externe - HTTP, base de données, fichiers, sockets |
| CPU-bound | Traitement centré sur le calcul CPU lui-même - compression, traitement d’image, calcul de hachage, transformations lourdes |
async / await est particulièrement efficace pour les attentes d’E/S, car le thread peut être rendu disponible pour d’autres tâches pendant l’attente. Le calcul CPU, en revanche, n’est pas une « attente » mais du temps réellement passé à calculer ; la question centrale devient alors sur quel thread l’exécuter et comment décider du degré de parallélisme.
2.2. Les termes fréquents
| Terme | Sens ici |
|---|---|
| Blocage (blocking) | Continuer à occuper un thread pendant l’attente d’une complétion |
fire-and-forget |
Un mode de lancement où l’appelant n’attend pas la fin de l’exécution |
SynchronizationContext |
Le mécanisme permettant de « revenir à l’emplacement d’exécution d’origine », par exemple dans l’UI |
| Backpressure | Un mécanisme qui fait attendre le côté écriture lorsque le flux entrant est trop rapide, afin d’éviter une croissance incontrôlée |
Le point particulièrement important est que l’asynchronisme et le parallélisme sont deux choses différentes.
- Asynchronisme : une question de la façon d’attendre
- Parallélisme : une question de faire avancer plusieurs choses en même temps
Quand ces deux notions se mélangent, on en vient à vouloir utiliser Task.Run partout.
C’est le premier embranchement à ne pas manquer.
3. Le tableau de décision à consulter en premier
3.1. Vue d’ensemble
En partant de ce tableau, l’orientation générale se dessine déjà en grande partie.
| Situation | À utiliser en premier | Point à surveiller |
|---|---|---|
| Attente HTTP / DB / fichier | await directement l’API async |
Ne pas l’envelopper dans Task.Run |
| Calcul lourd sans figer l’UI | Task.Run |
Sortir le calcul CPU du thread UI |
| Traitement des requêtes d’ASP.NET Core | await ordinaire |
Ne pas faire Task.Run suivi immédiatement d’un await |
| Quelques traitements asynchrones indépendants | Task.WhenAll |
Tout démarrer d’abord, puis attendre ensemble |
| N’utiliser que celui qui finit en premier | Task.WhenAny |
Penser à l’annulation du reste et à la collecte des exceptions |
| Nombreux éléments, avec une limite souhaitée | Parallel.ForEachAsync / SemaphoreSlim |
Expliciter le degré de parallélisme |
| Traitement en arrière-plan à traiter dans l’ordre | Channel<T> |
Penser à une file bornée et au backpressure |
| Traitement asynchrone à intervalle régulier | PeriodicTimer |
Respecter un timer pour un seul consommateur |
| Traiter les résultats au fil de l’eau | IAsyncEnumerable<T> / await foreach |
Avancer sans attendre que tout soit terminé |
| Libération asynchrone nécessaire | await using |
Utiliser IAsyncDisposable |
| Exclusion mutuelle traversant un await | SemaphoreSlim.WaitAsync |
Toujours Release dans un try/finally |
| Code de bibliothèque générique | Envisager ConfigureAwait(false) |
Ne pas dépendre d’un contexte UI / applicatif spécifique |
flowchart TD
start["Le traitement que vous voulez faire"] --> q1{"Attente d'E/S externe ?"}
q1 -- "Oui" --> p1["await directement l'API async"]
q1 -- "Non" --> q2{"Calcul CPU lourd ?"}
q2 -- "Oui" --> q3{"Où l'exécuter ?"}
q3 -- "Événement UI / bureau" --> p2["Envisager Task.Run"]
q3 -- "Requête ASP.NET Core" --> p3["Ne pas envelopper dans Task.Run<br/>Si besoin, passer par un worker ou une file"]
q3 -- "Worker / arrière-plan" --> p4["Exécuter sur place ou<br/>expliciter le degré de parallélisme"]
q2 -- "Non" --> q4{"Plusieurs tâches à traiter ?"}
q4 -- "Attendre que tout se termine" --> p5["Task.WhenAll"]
q4 -- "Utiliser ce qui finit en premier" --> p6["Task.WhenAny"]
q4 -- "Beaucoup d'éléments" --> p7["Parallel.ForEachAsync<br/>ou SemaphoreSlim"]
q4 -- "Traiter dans l'ordre" --> p8["Channel<T>"]
q4 -- "Intervalle régulier" --> p9["PeriodicTimer"]
q4 -- "Flux séquentiel" --> p10["IAsyncEnumerable<T>"]
Nous examinons ci-dessous chaque motif tour à tour.
3.2. Pour une attente d’E/S, await directement l’API async
C’est le motif le plus fondamental.
Pour HTTP, la base de données, la lecture/écriture de fichiers, etc., commencez par vérifier s’il existe une version async de l’API.
Si c’est le cas, la base est de l’await directement.
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
return await File.ReadAllTextAsync(path, cancellationToken);
}
Ce qu’il faut éviter ici, c’est d’envelopper dans Task.Run une E/S déjà asynchrone.
// Mauvais exemple
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
return await Task.Run(() => File.ReadAllTextAsync(path, cancellationToken), cancellationToken);
}
Cela ne fait que renvoyer l’attente d’E/S vers un autre thread : le code devient plus confus sans apporter aucun bénéfice.
- Pour une attente d’E/S,
Task.Runest inutile - Cherchez d’abord une API async
- Si vous recevez un token, transmettez-le directement en aval
C’est un terrain bien balisé.
3.3. Pour un calcul CPU lourd, choisir où utiliser Task.Run
Task.Run est efficace quand vous voulez sortir un calcul CPU du thread actuel.
Par exemple, exécuter directement un calcul lourd dans un gestionnaire d’événements UI fige l’écran.
Dans ce cas, Task.Run est la solution naturelle.
public Task<byte[]> HashManyTimesAsync(byte[] data, int repeat, CancellationToken cancellationToken)
{
return Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
using var sha256 = System.Security.Cryptography.SHA256.Create();
byte[] current = data;
for (int i = 0; i < repeat; i++)
{
cancellationToken.ThrowIfCancellationRequested();
current = sha256.ComputeHash(current);
}
return current;
}, cancellationToken);
}
Ce qui compte ici, cependant, c’est l’endroit d’où vous appelez.
- UI comme WinForms / WPF : il existe des cas où
Task.Runest efficace - Traitement des requêtes ASP.NET Core : évitez en principe d’écrire un
Task.Runsuivi immédiatement d’unawait - Worker / traitement en arrière-plan : traitez sur place, ou concevez le degré de parallélisme
Le traitement des requêtes d’ASP.NET Core s’exécute déjà sur le ThreadPool ; y insérer une couche de Task.Run puis l’await immédiatement ne fait généralement qu’ajouter une planification superflue.
C’est pourquoi, sous ASP.NET Core, il vaut mieux raisonner ainsi :
- Pour une attente d’E/S, un
awaitordinaire - Pour un court traitement CPU, exécutez-le sur place
- Pour un traitement long, ou que l’on souhaite détacher de la durée de vie de la requête, confiez-le à une file d’attente ou à un HostedService
Notez que, lorsqu’on appelle depuis l’UI une API qui n’existe qu’en version synchrone, il peut arriver d’utiliser Task.Run pour préserver la réactivité de l’UI.
Mais il ne s’agit pas alors d’« E/S asynchrone » : c’est simplement un contournement qui occupe un thread entier.
Côté serveur, comme sous ASP.NET Core, cette échappatoire ne passe fondamentalement pas bien à l’échelle.
3.4. Pour plusieurs traitements indépendants, Task.WhenAll
Il est fréquent de rencontrer du code qui attend un par un, comme ceci, alors que plusieurs traitements asynchrones sont en réalité indépendants.
// Des traitements indépendants rendus séquentiels
string a = await _httpClient.GetStringAsync(urlA, cancellationToken);
string b = await _httpClient.GetStringAsync(urlB, cancellationToken);
string c = await _httpClient.GetStringAsync(urlC, cancellationToken);
S’ils ne dépendent pas les uns des autres, il est plus naturel de tous les démarrer d’abord, puis d’attendre ensemble à la fin.
public async Task<string[]> DownloadAllAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
Task<string>[] tasks = urls
.Select(url => _httpClient.GetStringAsync(url, cancellationToken))
.ToArray();
return await Task.WhenAll(tasks);
}
Le point clé est ToArray().
Comme LINQ est évalué paresseusement, un simple Select peut ne rien avoir encore énuméré.
En matérialisant avec ToArray() ou ToList(), toutes les tâches sont garanties d’avoir démarré à ce moment-là.
sequenceDiagram
participant Caller as Appelant
participant T1 as Tâche 1
participant T2 as Tâche 2
participant T3 as Tâche 3
Caller->>T1: Démarrage
Caller->>T2: Démarrage
Caller->>T3: Démarrage
Caller->>Caller: await Task.WhenAll(...)
T1-->>Caller: Terminée
T2-->>Caller: Terminée
T3-->>Caller: Terminée
Ce motif convient dans les cas suivants :
- Le nombre d’éléments est faible ou modéré
- Vous voulez attendre l’ensemble, ensemble
- Il n’y a pas de problème à les exécuter tous simultanément, sans limite
Si le nombre d’éléments est important, il est plus sûr de fixer une limite de parallélisme, comme indiqué en 3.6 ci-dessous.
3.5. Pour utiliser celui qui se termine en premier, Task.WhenAny
Par exemple, si vous voulez utiliser le premier miroir qui répond parmi plusieurs, Task.WhenAny est le choix le plus clair.
public async Task<byte[]> DownloadFromFirstMirrorAsync(
IReadOnlyList<string> urls,
CancellationToken cancellationToken)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task<byte[]>[] tasks = urls
.Select(url => _httpClient.GetByteArrayAsync(url, cts.Token))
.ToArray();
Task<byte[]> winner = await Task.WhenAny(tasks);
cts.Cancel();
try
{
return await winner;
}
finally
{
try
{
await Task.WhenAll(tasks);
}
catch
{
// Récupérer les annulations et échecs des tâches non gagnantes
}
}
}
Le point de vigilance ici est que WhenAny ne renvoie qu’un seul gagnant.
Les autres traitements continuent de s’exécuter si vous ne faites rien.
Il faut donc décider à l’avance :
- Si vous voulez annuler le reste
- Si vous voulez observer leurs exceptions
Task.WhenAny est pratique, mais demande un peu plus de conception que WhenAll.
Il reste clair si vous ne le choisissez que lorsque « seul le premier résultat suffit ».
3.6. Pour beaucoup d’éléments avec un parallélisme limité, Parallel.ForEachAsync ou SemaphoreSlim
Task.WhenAll exécute simultanément toutes les tâches créées.
Ainsi, lorsque le nombre d’éléments est important, les connexions HTTP, les connexions à la base de données, la consommation mémoire et la charge sur les services externes augmentent toutes d’un coup.
Dans ce cas, il est plus stable de décider combien d’éléments s’exécutent simultanément au maximum.
Parallel.ForEachAsync rend cette intention très lisible.
public async Task DownloadAndSaveAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 8,
CancellationToken = cancellationToken
};
await Parallel.ForEachAsync(
urls.Select((url, index) => (url, index)),
options,
async (item, token) =>
{
string html = await _httpClient.GetStringAsync(item.url, token);
string path = Path.Combine("cache", $"{item.index}.html");
await File.WriteAllTextAsync(path, html, token);
});
}
Ce motif convient dans les cas suivants :
- Le nombre d’éléments est important
- Le traitement de chaque élément est indépendant
- Mais il faut éviter de tout lancer d’un coup
Si vous voulez un contrôle plus fin, il existe aussi la méthode SemaphoreSlim -
par exemple pour limiter à « au maximum 4 appels simultanés vers telle API externe ».
Autrement dit :
- Quelques éléments →
Task.WhenAll - Un grand volume →
Parallel.ForEachAsyncouSemaphoreSlim
Avec cette répartition, vous ne vous tromperez guère.
3.7. Pour traiter dans l’ordre, Channel<T>
Il arrive de vouloir détacher de l’appelant un travail qui « n’a pas besoin de se terminer immédiatement, mais qui doit absolument être traité ». L’envoi d’e-mails, le transfert de journaux, le post-traitement de webhooks, la conversion de fichiers, etc.
Si vous vous contentez de lancer un Task.Run sans plus vous en soucier, les points suivants deviennent flous :
- Où observe-t-on les exceptions ?
- Attend-on ce traitement à l’arrêt ?
- Jusqu’où accepte-t-on la charge quand le volume augmente ?
Ce type de travail est plus facile à gérer en le plaçant dans une file, traitée dans l’ordre par un consommateur dédié.
flowchart LR
p["producteur"] --> w["WriteAsync"]
w --> q{"Y a-t-il de la place dans la file ?"}
q -- "Oui" --> c["Entre dans le Channel"]
q -- "Non" --> b["Attend qu'il y ait de la place"]
c --> d["consommateur ReadAsync"]
d --> e["await et traite dans l'ordre"]
Channel<T> permet d’écrire le schéma producteur/consommateur de façon très naturelle.
public sealed class BackgroundTaskQueue
{
private readonly Channel<Func<CancellationToken, ValueTask>> _queue =
Channel.CreateBounded<Func<CancellationToken, ValueTask>>(
new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait
});
public ValueTask EnqueueAsync(
Func<CancellationToken, ValueTask> workItem,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(workItem);
return _queue.Writer.WriteAsync(workItem, cancellationToken);
}
public ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken)
=> _queue.Reader.ReadAsync(cancellationToken);
}
Dans cet exemple, BoundedChannelFullMode.Wait signifie faire attendre le côté écriture lorsque la file est pleine.
C’est cela, le backpressure.
Sous ASP.NET Core, il est clair de consommer une telle file en la combinant avec un BackgroundService.
Comparé à un « véritable fire-and-forget », cette approche gère bien mieux les exceptions, l’arrêt, le parallélisme et les limites.
3.8. Pour tourner à intervalle régulier, PeriodicTimer
Pour un traitement asynchrone à intervalle régulier, PeriodicTimer est très lisible.
public async Task RunPeriodicAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
await RefreshCacheAsync(cancellationToken);
}
}
Ce qui est appréciable dans cette écriture :
- Le flux est plus facile à suivre qu’avec un Timer à base de callback
- On peut l’écrire en s’appuyant sur
await - Le
CancellationTokens’utilise naturellement pour l’arrêt
Attention : PeriodicTimer s’utilise en partant du principe qu’on ne lance pas plusieurs appels simultanés à WaitForNextTickAsync pour un même timer.
Par ailleurs, si le temps de traitement dépasse la période, ce retard doit être pris en compte dans la conception.
Le timer ne se parallélise pas de lui-même pour rattraper le retard.
3.9. Pour des données qui arrivent au fil de l’eau, IAsyncEnumerable<T>
Il y a des cas où l’on préfère traiter les éléments au fur et à mesure qu’ils arrivent plutôt que de tout accumuler dans une List<T> avant de la retourner.
- Lire dans l’ordre une API paginée
- Lire les lignes d’un fichier petit à petit
- Faire passer directement des résultats en streaming
Dans ces cas, IAsyncEnumerable<T> et await foreach sont le choix naturel.
public async Task ProcessUsersAsync(CancellationToken cancellationToken)
{
await foreach (User user in _userRepository.StreamUsersAsync(cancellationToken))
{
await ProcessUserAsync(user, cancellationToken);
}
}
Cette forme convient dans les cas suivants :
- Vous ne voulez pas attendre que tous les éléments soient réunis
- Vous voulez traiter un élément à la fois
- Vous ne voulez pas tout stocker en mémoire
Pour décider entre Task<List<T>> et IAsyncEnumerable<T> comme type de retour, le critère le plus clair est :
utilisez-vous les résultats une fois tous réunis, ou au fur et à mesure de leur arrivée ?
3.10. Pour une libération asynchrone, await using
Les types qui nécessitent un traitement asynchrone lors de la libération - flush, fermeture de connexion, etc. - implémentent IAsyncDisposable.
Dans ce cas, utilisez await using plutôt que using.
public async Task WriteFileAsync(string path, byte[] data, CancellationToken cancellationToken)
{
await using var stream = new FileStream(
path,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
useAsync: true);
await stream.WriteAsync(data, cancellationToken);
}
Les points clés sont :
- Pour
IAsyncDisposable, utilisezawait using - Il est tout à fait normal que « l’ouverture » soit synchrone alors que « la fermeture » est asynchrone
Cela évite le décalage consistant à avoir rendu l’écriture asynchrone tout en laissant la libération finale synchrone.
3.11. Pour une exclusion mutuelle traversant un await, SemaphoreSlim
Dans du code qui traverse un await, il y a des situations où SemaphoreSlim remplace lock.
public sealed class CacheRefresher
{
private readonly SemaphoreSlim _gate = new(1, 1);
public async Task RefreshAsync(CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken);
try
{
await RefreshCoreAsync(cancellationToken);
}
finally
{
_gate.Release();
}
}
private static Task RefreshCoreAsync(CancellationToken cancellationToken)
=> Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}
Ce qui compte, ce sont ces deux points :
- Entrer avec
WaitAsync - Toujours appeler
Releasedans unfinally
Pour des cas comme « je ne veux laisser entrer qu’un seul élément à la fois » ou « je veux limiter à 3 appels simultanés vers une API externe »,
SemaphoreSlim est tout à fait pratique.
3.12. Écrire await différemment selon UI / code applicatif / bibliothèque
ConfigureAwait(false) n’est pas quelque chose à ajouter systématiquement, en toute circonstance.
Voici, dans les grandes lignes, comment répartir son usage.
flowchart LR
a["UI / code applicatif"] --> b["await someAsync()"]
b --> c["Reprend sur le contexte d'origine"]
d["Bibliothèque générique"] --> e["await someAsync().ConfigureAwait(false)"]
e --> f["Ne suppose pas de revenir sur un contexte particulier"]
- Code UI / applicatif
- Un
awaitordinaire suffit dans un premier temps - Si un traitement dépendant de la mise à jour de l’UI ou du contexte applicatif suit l’await, il est plus naturel de ne pas ajouter
ConfigureAwait(false)
- Un
- Code applicatif d’ASP.NET Core
- Un
awaitordinaire suffit généralement - Il n’est pas nécessaire d’appliquer
ConfigureAwait(false)systématiquement comme une règle stricte
- Un
- Code de bibliothèque générique
- S’il ne dépend ni de l’UI ni d’un modèle applicatif,
ConfigureAwait(false)est pertinent
- S’il ne dépend ni de l’UI ni d’un modèle applicatif,
Autrement dit :
- Le code applicatif utilise un
awaitordinaire - La bibliothèque générique envisage
ConfigureAwait(false)
Avec ce repère en tête, vous ne devriez guère rencontrer de difficultés en pratique.
4. Les règles de base d’écriture
4.1. Privilégier d’abord Task / Task<T> comme type de retour
Pour le type de retour d’une méthode async, réfléchissez d’abord dans cet ordre.
| Type de retour | Premier réflexe |
|---|---|
Task |
Par défaut pour une méthode async sans valeur de retour |
Task<T> |
Par défaut pour une méthode async qui retourne une valeur |
ValueTask / ValueTask<T> |
À choisir uniquement après avoir mesuré et constaté que c’est nécessaire |
ValueTask a l’air pratique, mais il n’est pas toujours meilleur que Task.
C’est une structure, ce qui implique un coût de copie, et son usage comporte des contraintes.
Le point particulièrement important est que ValueTask est fondamentalement conçu pour n’être attendu (await) qu’une seule fois.
Il ne se prête pas à être stocké négligemment dans une variable locale pour être attendu plusieurs fois.
Ainsi, pour le code applicatif du quotidien, Task / Task<T> suffit amplement pour commencer.
Par ailleurs, ajouter le suffixe Async aux noms de méthode rend les choses plus claires.
public Task SaveAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task<int> CountAsync(CancellationToken cancellationToken)
{
return Task.FromResult(_count);
}
Comme ci-dessus, s’il n’y a rien à await, il est plus naturel de retourner Task.CompletedTask ou Task.FromResult plutôt que de forcer l’ajout d’async.
4.2. async void uniquement pour les gestionnaires d’événements
La règle de base est d’éviter async void en dehors des gestionnaires d’événements.
La raison est simple :
- L’appelant ne peut pas faire d’await
- On ne peut pas attendre la fin de l’exécution
- La gestion des exceptions devient difficile
- C’est difficile à tester
Seuls les gestionnaires d’événements imposent void, c’est donc uniquement là qu’on l’utilise.
private async void SaveButton_Click(object? sender, EventArgs e)
{
try
{
await SaveAsync(_saveCancellation.Token);
_statusLabel.Text = "Enregistré.";
}
catch (OperationCanceledException)
{
_statusLabel.Text = "Annulé.";
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "Erreur d'enregistrement");
}
}
Dans les gestionnaires d’événements, il est important d’avoir conscience d’écrire soi-même, jusqu’au bout, la capture des exceptions à l’intérieur et leur remontée côté UI.
4.3. Recevoir un CancellationToken et le transmettre en aval
Pour une opération annulable, recevez un CancellationToken et transmettez-le directement en aval.
public async Task<string> DownloadTextAsync(string url, CancellationToken cancellationToken)
{
using HttpResponseMessage response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
Un cas fréquent ici est de recevoir le token au niveau supérieur sans le transmettre en aval. Cela tend à produire du code qui « a l’air annulable, mais qui ne s’arrête pas réellement en cours de route ».
Par ailleurs, le sens d’un timeout change selon que l’on veut « limiter uniquement l’attente » ou « arrêter aussi le traitement lui-même ».
- Limiter uniquement l’attente :
WaitAsync - Arrêter aussi le traitement lui-même :
CancellationTokenSource.CancelAftercombiné à la propagation du token
Cette distinction devient facilement une source de bug par la suite ; la fixer dès le départ apporte de la stabilité.
4.4. Garder les API asynchrones asynchrones jusqu’au bout
Si vous utilisez async / await, il est plus naturel de rester asynchrone jusqu’au bout autant que possible.
Voici un repère pour les remplacements.
| Écriture tentante | À remplacer par |
|---|---|
Task.Result / Task.Wait() |
await |
Task.WaitAll() |
await Task.WhenAll(...) |
Task.WaitAny() |
await Task.WhenAny(...) |
Thread.Sleep(...) |
await Task.Delay(...) |
En particulier dans l’UI ou sous ASP.NET Core, mélanger des attentes synchrones rend les blocages difficiles à comprendre.
Le C# actuel permet aussi d’utiliser async Task Main(), donc même dans une application console, les raisons de forcer un fonctionnement synchrone se sont considérablement réduites.
4.5. Lors de la création de tâches avec LINQ, matérialiser avec ToArray / ToList
Lorsque vous combinez Task.WhenAll ou Task.WhenAny avec LINQ,
il est plus sûr de matérialiser d’abord avec ToArray() ou ToList().
Task<User>[] tasks = userIds
.Select(id => _userRepository.GetAsync(id, cancellationToken))
.ToArray();
User[] users = await Task.WhenAll(tasks);
La raison est que LINQ est évalué paresseusement. Lire le code en pensant que « tout est déjà démarré » alors qu’en réalité rien n’a encore été énuméré est un piège discret mais dangereux.
- Pour attendre l’ensemble ensemble :
ToArray() - Pour supprimer ou remplacer des éléments en cours de route :
ToList()
Garder ce repère en tête facilite le choix entre les deux.
5. Anti-patterns fréquents
| Anti-pattern | Ce qui pose problème | Premier remplacement |
|---|---|---|
Task.Run(async () => await IoAsync()) |
Renvoie inutilement une attente d’E/S vers un autre thread | await IoAsync() |
Task.Result / Wait() |
Bloque le thread ; sujet aux blocages | await |
Mélanger Thread.Sleep() dans un flux async |
Occupe le thread même pendant l’attente | Task.Delay() |
Utiliser async void sur une méthode ordinaire |
Impossible à attendre, gestion des exceptions difficile | Task / Task<T> |
Await en série là où Task.WhenAll conviendrait |
Ralentit inutilement | Tout démarrer d’abord, puis WhenAll |
Lancer un grand volume d’un coup avec WhenAll |
La charge explose | Parallel.ForEachAsync / SemaphoreSlim |
Essayer de traverser un await avec lock |
Ne convient pas à l’usage | SemaphoreSlim.WaitAsync |
Se contenter d’un Task.Run brut pour un fire-and-forget |
Gestion floue des exceptions, de l’arrêt et des limites | Channel<T> / BackgroundService |
Ajouter ConfigureAwait(false) mécaniquement au code UI |
La mise à jour de l’UI après l’await se casse facilement | await ordinaire |
Faire de ValueTask le standard |
La complexité paie rarement au vu du gain | Task en premier lieu |
Parmi ce tableau, les trois cas suivants sont particulièrement fréquents en pratique :
Task.Runalors qu’il s’agit d’E/S- Await en série alors que les traitements sont en réalité indépendants
- Absence de gestion de la durée de vie du fire-and-forget
Corriger seulement ces trois points améliore déjà nettement la lisibilité du code.
6. Liste de vérification pour la revue
Lors d’une revue de code portant sur async/await, vérifiez les points suivants dans l’ordre.
- Peut-on expliquer d’emblée, en mots, si le traitement est I/O-bound ou CPU-bound ?
- Reste-t-il des
Task.Result/Task.Wait()/Thread.Sleep()? - Une attente d’E/S est-elle enveloppée dans
Task.Run? - Des traitements indépendants sont-ils inutilement attendus en série ?
- À l’inverse, un grand volume est-il envoyé sans limite via
WhenAll? - Si un
CancellationTokenest reçu, est-il correctement transmis en aval ? - Y a-t-il un
async voiden dehors d’un gestionnaire d’événements ? - Si un
fire-and-forgetest présent, a-t-on décidé qui gère les exceptions, l’arrêt et les limites ? - Si
SemaphoreSlimest utilisé,Releasese trouve-t-il bien dans unfinally? - Si
ValueTaskest utilisé, y a-t-il une raison mesurée, et respecte-t-on l’hypothèse d’un seul await ? - La présence ou l’absence de
ConfigureAwait(false)correspond-elle au type de code ?- Code UI / applicatif :
awaitordinaire - Bibliothèque générique : envisager
ConfigureAwait(false)
- Code UI / applicatif :
Cette liste de vérification est aussi pratique pour aligner les critères de revue au sein d’une équipe.
7. Guide rapide de choix
| Ce que vous voulez faire | À choisir en premier |
|---|---|
| Une seule E/S HTTP / DB / fichier | await directement l’API async |
| Calcul lourd sans figer l’UI | Task.Run |
| Quelques traitements asynchrones indépendants | Task.WhenAll |
| Ne vouloir que le premier résultat | Task.WhenAny |
| Traiter un grand volume avec une limite | Parallel.ForEachAsync / SemaphoreSlim |
| Traitement en arrière-plan ordonné | Channel<T> |
| Tourner à intervalle régulier | PeriodicTimer |
| Traiter un flux séquentiel | IAsyncEnumerable<T> / await foreach |
| Exclusion mutuelle traversant un await | SemaphoreSlim |
| Bibliothèque générique | Envisager ConfigureAwait(false) |
| Hésiter sur le type de retour | Task / Task<T> en premier lieu |
8. Résumé
Les bonnes pratiques d’async / await relèvent moins de la mémorisation de nombreuses techniques ponctuelles que du principe organisateur consistant à choisir le type adapté au genre de traitement - c’est ce qui paie en pratique.
L’ordre d’examen est globalement le suivant.
- Distinguer l’attente d’E/S du calcul CPU
- Pour l’E/S,
awaitdirectement l’API async - Pour le calcul CPU, décider où il doit s’exécuter
- Pour plusieurs traitements, choisir entre
WhenAll/WhenAny/ une limite de parallélisme - Pour se détacher de la durée de vie de la requête, mettre en file plutôt que faire un fire-and-forget brut
- Harmoniser le traitement du type de retour, de l’annulation, des exceptions, de l’exclusion mutuelle et des contextes
Comme l’écriture d’async / await est en elle-même concise, un usage négligent rend l’intention difficile à percevoir. À l’inverse,
- Traiter l’E/S comme de l’E/S
- Traiter le CPU comme du CPU
- Gérer la durée de vie du traitement en arrière-plan en tant que tel
Il suffit de séparer ces trois éléments pour rendre le code nettement plus lisible.
9. Références
- L’ensemble du code d’exemple de cet article (bibliothèque, démo, tests unitaires) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/csharp-async-await-best-practices
- Asynchronous programming scenarios - C#
- Asynchronous programming with async and await
- Task-based Asynchronous Pattern (TAP) in .NET
- ConfigureAwait FAQ
- Parallel.ForEachAsync Method
- Task.WaitAsync Method
- System.Threading.Channels library
- Create a Queue Service
- Background tasks with hosted services in ASP.NET Core
- Generate and consume async streams
- Implement a DisposeAsync method
- ValueTask Struct
- CA2012: Use ValueTasks correctly
</content>
Articles associés
Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.
Comment choisir la communication inter-processus sous Windows ── Tableau de décision : tubes nommés / TCP / gRPC / mémoire partagée / COM
Comment choisir le moyen de faire communiquer des applications Windows entre elles ? Cet article organise les tubes nommés, le TCP local,...
Qu'est-ce que le .NET Generic Host ? - Le socle de la DI, de la configuration et des logs
Cet article clarifie le rôle du Generic Host à travers ses liens avec la DI, la configuration, les logs, IHostedService et BackgroundServ...
Qu'est-ce que .NET Native AOT ? - Les différences avec JIT et le trimming
Ce qu'est Native AOT en .NET, expliqué à travers ses différences avec JIT, ReadyToRun, self-contained, single-file, trimming et les sourc...
Bien choisir entre les 3 minuteurs .NET - PeriodicTimer/Timer/DispatcherTimer
Cet article explique les différences entre PeriodicTimer, System.Threading.Timer et DispatcherTimer, et comment bien les choisir selon qu...
Pourquoi utiliser le Generic Host .NET et BackgroundService dans une application de bureau
Comment utiliser le Generic Host et BackgroundService pour organiser le démarrage, le traitement périodique, l'arrêt, la journalisation, ...
Sujets associés
Ces pages replacent le sujet dans un contexte plus large de services et de décisions.
Thèmes techniques Windows
Portail des sujets sur le développement Windows, l'analyse des incidents et la valorisation des actifs existants.
Thread UI et minuteries
Thread UI WPF / WinForms, flux asynchrones, Dispatcher et temporisation.
Services liés à ce sujet
Cet article est directement lié aux services suivants.
Développement d'applications Windows
Dans les applications Windows impliquant l'UI, le traitement en arrière-plan et les E/S, savoir choisir le bon usage d'async/await a un impact direct sur la qualité de l'implémentation.
Conseil technique et revue de conception
Si vous souhaitez clarifier les décisions autour de Task.Run et de ConfigureAwait en lien avec le découpage des responsabilités, cela relève naturellement du conseil technique et de la revue de conception.
Questions fréquentes
Questions souvent posées lors d’une consultation sur le sujet de cet article.
- Quand faut-il utiliser Task.Run en C# ?
- Task.Run est efficace quand vous voulez sortir un calcul CPU du thread actuel. Par exemple, si un calcul lourd tourne directement dans un gestionnaire d'événements UI de WinForms ou WPF, l'écran se fige ; il est alors naturel d'utiliser Task.Run pour sortir ce calcul du thread UI. En revanche, le traitement des requêtes d'ASP.NET Core s'exécute déjà sur le ThreadPool, donc insérer un Task.Run juste avant un await ne fait généralement qu'ajouter une planification superflue, et cela doit en principe être évité. Pour un traitement long, ou que l'on souhaite détacher de la durée de vie de la requête, il est préférable de le confier à une file d'attente ou à un HostedService.
- Ne faut-il jamais envelopper un traitement d'E/S dans await Task.Run() ?
- Pour les attentes d'E/S comme HTTP, la base de données ou la lecture/écriture de fichiers, la règle de base est d'attendre (await) directement la version asynchrone de l'API, sans avoir besoin de l'envelopper dans Task.Run. Envelopper une E/S déjà asynchrone dans Task.Run ne fait que renvoyer l'attente d'E/S vers un autre thread : cela complique le code sans apporter de bénéfice. Notez que, lorsqu'on appelle depuis l'UI une API qui n'existe qu'en version synchrone, on peut utiliser Task.Run pour préserver la réactivité de l'UI ; mais il ne s'agit pas alors d'E/S asynchrone, seulement d'un contournement qui occupe un thread entier, ce qui passe mal à l'échelle côté serveur.
- Où faut-il placer ConfigureAwait(false) ?
- Dans le code UI ou applicatif, un simple await suffit dans la plupart des cas. Si un traitement dépendant de la mise à jour de l'UI ou du contexte applicatif suit l'await, il est plus naturel de ne pas ajouter ConfigureAwait(false). Le code applicatif d'ASP.NET Core se contente lui aussi généralement d'un await ordinaire, sans qu'il soit nécessaire d'appliquer ConfigureAwait(false) systématiquement comme une règle stricte. ConfigureAwait(false) est en revanche pertinent dans le code de bibliothèque générique, qui ne dépend ni de l'UI ni d'un modèle applicatif particulier. Retenez que « le code applicatif utilise un await ordinaire, la bibliothèque générique envisage ConfigureAwait(false) » : avec ce repère, vous ne devriez guère rencontrer de difficultés en pratique.
- Pourquoi faut-il éviter async void en dehors des gestionnaires d'événements ?
- Parce qu'avec async void, l'appelant ne peut pas faire d'await, ne peut pas attendre la fin de l'exécution, la gestion des exceptions devient difficile, et le code est aussi difficile à tester. Une méthode ordinaire doit, par principe, retourner Task ou Task<T>. Seuls les gestionnaires d'événements imposent void au niveau de la signature ; c'est donc uniquement là qu'on l'utilise, et il faut alors avoir conscience d'écrire soi-même, dans le gestionnaire, un try/catch qui intercepte les exceptions et les renvoie côté UI.
Profil de l’auteur
Page de présentation de l’auteur de l’article.
Go Komura
Représentant de KomuraSoft LLC
Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.
Liens publics