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.

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.Delayen 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ère | Task.WhenAny | Task.WhenEach | Task.WhenAll |
|---|---|---|---|
| Rend la main | à la première complétion | à chaque complétion | quand tout est terminé |
| Usage typique | course, repli, délai maximal | traitement progressif | agrégation finale |
| Tâches restantes | à annuler ou surveiller | toutes consommées | toutes attendues |
| Erreurs | à inspecter tâche par tâche | à inspecter tâche par tâche | agré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.
