.NET 9C#AsyncConcurrencyPerformance

Comparez Task.WhenAny et Task.WhenEach en .NET 9 pour réagir au premier résultat ou traiter progressivement plusieurs tâches asynchrones sans confusion.

Yva Hajatiana
11 mars 2026
5 min de lecture
Partager :X / TwitterLinkedIn
Composition éditoriale sur la précision et la fiabilité logicielles

Task.WhenAny ou WhenEach : bien choisir en .NET 9

La concurrence applicative en .NET devient difficile quand plusieurs tâches doivent être orchestrées avec des comportements différents : attendre la première réponse, traiter les résultats au fil de l'eau, annuler les retardataires ou agréger proprement les erreurs.

Dans ce contexte, Task.WhenAny et Task.WhenEach (apparu avec .NET 9 et toujours disponible en .NET 10) paraissent proches, mais ils servent des objectifs différents.

Ce que fait vraiment Task.WhenAny

Task.WhenAny retourne dès qu'une tâche est terminée parmi une collection. Cela ne signifie pas que les autres s'arrêtent, ni que la tâche retournée a réussi : « terminée » inclut l'échec et l'annulation.

var tasks = new[]
{
    FetchFromPrimaryAsync(ct),
    FetchFromReplicaAsync(ct),
    FetchFromCacheAsync(ct)
};

Task<Product> firstCompleted = await Task.WhenAny(tasks);
Product result = await firstCompleted; // rethrows if this task failed

Ce pattern est utile pour :

  • les requêtes « hedgées » (la même lecture envoyée à plusieurs répliques) ;
  • un repli rapide vers une source secondaire ;
  • un délai d'attente maximal, en mettant Task.Delay en concurrence avec le traitement ;
  • l'arbitrage sur la première réponse exploitable.

Les deux pièges classiques avec WhenAny

Premier piège : prendre le premier échec pour un résultat. Si le cache répond en 2 ms par une exception, l'exemple précédent échoue alors que la réplique allait répondre correctement. Pour obtenir la première réponse réussie, il faut boucler :

static async Task<T> FirstSuccessfulAsync<T>(
    IEnumerable<Func<CancellationToken, Task<T>>> sources,
    CancellationToken cancellationToken)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
    var pending = sources.Select(source => source(cts.Token)).ToList();
    var errors = new List<Exception>();

    while (pending.Count > 0)
    {
        Task<T> completed = await Task.WhenAny(pending);
        pending.Remove(completed);

        if (completed.IsCompletedSuccessfully)
        {
            cts.Cancel(); // stop the slower sources
            return completed.Result;
        }

        errors.Add(completed.Exception?.InnerException
            ?? new OperationCanceledException(cts.Token));
    }

    throw new AggregateException("No source returned a result.", errors);
}

Second piège : oublier les tâches perdantes. Elles continuent de consommer connexions et CPU. Le CancellationTokenSource lié ci-dessus les annule dès qu'un gagnant est trouvé ; encore faut-il que chaque source respecte réellement son jeton. Une tâche abandonnée qui échoue plus tard ne fait pas planter le processus, mais son exception est perdue : journalisez-la si elle a une valeur de diagnostic.

Ce que change Task.WhenEach

Avant .NET 9, traiter des tâches dans leur ordre de complétion obligeait à écrire une boucle autour de WhenAny en retirant la tâche terminée de la liste à chaque tour. Ce code est verbeux et coûte O(n²), car chaque appel à WhenAny réenregistre une continuation sur toutes les tâches restantes.

Task.WhenEach retourne un IAsyncEnumerable<Task<T>> qui produit chaque tâche au moment où elle se termine :

await foreach (Task<Product> task in Task.WhenEach(tasks))
{
    Product result = await task;
    Console.WriteLine(result.Name);
}

Cette approche est très utile quand :

  • l'ordre final n'a pas d'importance ;
  • chaque résultat peut être traité indépendamment ;
  • on veut commencer le travail sans attendre la fin globale.

Quand préférer WhenEach

WhenEach est plus lisible que WhenAny pour :

  • un pipeline de fan-out / fan-in ;
  • le chargement parallèle de données ;
  • l'enrichissement progressif d'un document ;
  • un traitement par lot avec rendu partiel, par exemple une page qui affiche chaque bloc dès qu'il est prêt.

Le bénéfice n'est pas seulement syntaxique. Il clarifie l'intention : traiter chaque fin de tâche, pas seulement la première.

Une comparaison simple

CritèreTask.WhenAnyTask.WhenEachTask.WhenAll
Rend la mainà la première complétionà chaque complétionquand tout est terminé
Usage typiquecourse, repli, délai maximaltraitement progressifagrégation finale
Tâches restantesà annuler ou surveillertoutes consomméestoutes attendues
Erreursà inspecter tâche par tâcheà inspecter tâche par tâcheagrégées dans une exception

Task.WhenAll reste le bon choix quand le résultat n'a de sens qu'une fois toutes les tâches terminées.

Gestion des erreurs

Avec WhenEach, chaque await task peut lever une exception. Il est donc souvent préférable de traiter erreur et succès au même endroit :

await foreach (var task in Task.WhenEach(tasks))
{
    try
    {
        var item = await task;
        await ProcessAsync(item, ct);
    }
    catch (Exception ex) when (ex is not OperationCanceledException)
    {
        logger.LogWarning(ex, "Parallel task failed.");
    }
}

Le filtre when laisse remonter une annulation demandée par l'appelant au lieu de la journaliser comme une panne. Cette granularité est souvent meilleure qu'un échec global unique.

Limiter le parallélisme

Ni WhenAny ni WhenEach ne limitent le nombre de tâches lancées : ils observent des tâches déjà démarrées. Lancer 5 000 appels HTTP d'un coup puis les consommer avec WhenEach sature le pool de connexions ou le service appelé. Pour borner la concurrence, utilisez Parallel.ForEachAsync avec MaxDegreeOfParallelism, un SemaphoreSlim ou un canal producteur-consommateur. La question de la mesure avant optimisation est développée dans le guide performance .NET : mesurer, profiler puis optimiser.

Performance et lisibilité

Le gain principal de WhenEach n'est pas une révolution de performance, même s'il supprime le coût quadratique de la boucle manuelle. C'est surtout une amélioration de lisibilité pour des patterns fréquents d'orchestration asynchrone.

Quand le code concurrent devient lisible :

  • il est plus simple à relire ;
  • il est plus facile à tester ;
  • il devient moins risqué à faire évoluer.

En ingénierie, ce gain vaut souvent autant qu'une micro-optimisation. Pour les appels HTTP eux-mêmes, combinez ces primitives avec des clients typés et une politique de résilience, comme dans Refit en .NET : concevoir des clients HTTP fiables.

Conclusion

Task.WhenAny et Task.WhenEach ne sont pas concurrents. Ils répondent à deux besoins distincts.

Utilisez WhenAny quand seule la première complétion compte, en vérifiant qu'elle a réussi et en annulant les perdantes. Utilisez WhenEach quand vous voulez traiter un ensemble de tâches à mesure qu'elles se terminent. Le bon choix dépend de l'intention du flux, pas d'une préférence de syntaxe.

Mots-clés :.NET 9C#AsyncConcurrencyPerformance
Y

Yva Hajatiana

Lead développeur .NET à Paris et architecte logiciel, 10 ans d'expérience sur des SI critiques bancaires (.NET, Angular, systèmes distribués, IAM, DevOps).