Tableau de décision pratique pour C# async/await - Task.Run et ConfigureAwait

· Mis à jour le: · · 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.Run alors que ce n’est pas nécessaire
  • Faire un await en série, un par un, sur des traitements pourtant indépendants
  • Introduire un fire-and-forget sans 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 ValueTask uniquement 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

  1. La conclusion d’abord (en une phrase)
  2. Les termes utilisés dans cet article
    • 2.1. Les termes à distinguer en premier
    • 2.2. Les termes fréquents
  3. Le tableau de décision à consulter en premier
    • 3.1. Vue d’ensemble
    • 3.2. Pour une attente d’E/S, await directement 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
  4. 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
  5. Anti-patterns fréquents
  6. Liste de vérification pour la revue
  7. Guide rapide de choix
  8. Résumé
  9. Références

1. La conclusion d’abord (en une phrase)

  • async / await est 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.Run peut être utile, mais dans le traitement des requêtes d’ASP.NET Core, il faut en principe éviter d’écrire un Task.Run suivi immédiatement d’un await
  • Pour plusieurs traitements indépendants, envisagez d’abord Task.WhenAll plutô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-forget paraî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 choisissez ValueTask qu’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, un await ordinaire suffit dans un premier temps
  • N’utilisez async void nulle 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 :

  1. Qu’est-ce que ce traitement attend réellement ?
  2. Qui porte la responsabilité de la durée de vie de ce traitement ?
  3. 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
OuiNonOuiÉvénement UI / bureauRequête ASP.NET CoreWorker / arrière-planNonAttendre que tout se termineUtiliser ce qui finit en premierBeaucoup d'élémentsTraiter dans l'ordreIntervalle régulierFlux séquentielLe traitement que vous voulez faireAttente d'E/S externe ?await directement l'API asyncCalcul CPU lourd ?Où l'exécuter ?Envisager Task.RunNe pas envelopper dans Task.RunSi besoin, passer par un worker ou une fileExécuter sur place ouexpliciter le degré de parallélismePlusieurs tâches à traiter ?Task.WhenAllTask.WhenAnyParallel.ForEachAsyncou SemaphoreSlimChannel&lt;T&gt;PeriodicTimerIAsyncEnumerable&lt;T&gt;

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.Run est 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.Run est efficace
  • Traitement des requêtes ASP.NET Core : évitez en principe d’écrire un Task.Run suivi immédiatement d’un await
  • 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 await ordinaire
  • 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à.

Tâche 3Tâche 2Tâche 1AppelantTâche 3Tâche 2Tâche 1AppelantDémarrageDémarrageDémarrageawait Task.WhenAll(...)TerminéeTerminéeTerminé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.ForEachAsync ou SemaphoreSlim

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é.

OuiNonproducteurWriteAsyncY a-t-il de la place dans la file ?Entre dans le ChannelAttend qu'il y ait de la placeconsommateur ReadAsyncawait 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 CancellationToken s’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, utilisez await 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 Release dans un finally

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.

UI / code applicatifawait someAsync()Reprend sur le contexte d'origineBibliothèque génériqueawait someAsync().ConfigureAwait(false)Ne suppose pas de revenir sur un contexte particulier
  • Code UI / applicatif
    • Un await ordinaire 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)
  • Code applicatif d’ASP.NET Core
    • Un await ordinaire suffit généralement
    • Il n’est pas nécessaire d’appliquer ConfigureAwait(false) systématiquement comme une règle stricte
  • 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

Autrement dit :

  • Le code applicatif utilise un await ordinaire
  • 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.CancelAfter combiné à 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 :

  1. Task.Run alors qu’il s’agit d’E/S
  2. Await en série alors que les traitements sont en réalité indépendants
  3. 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 CancellationToken est reçu, est-il correctement transmis en aval ?
  • Y a-t-il un async void en dehors d’un gestionnaire d’événements ?
  • Si un fire-and-forget est présent, a-t-on décidé qui gère les exceptions, l’arrêt et les limites ?
  • Si SemaphoreSlim est utilisé, Release se trouve-t-il bien dans un finally ?
  • Si ValueTask est 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 : await ordinaire
    • Bibliothèque générique : envisager ConfigureAwait(false)

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.

  1. Distinguer l’attente d’E/S du calcul CPU
  2. Pour l’E/S, await directement l’API async
  3. Pour le calcul CPU, décider où il doit s’exécuter
  4. Pour plusieurs traitements, choisir entre WhenAll / WhenAny / une limite de parallélisme
  5. 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
  6. 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

</content>

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

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.

Retour au blog