Les pièges du async/await que tout le monde rencontre

async/await — deux mots-clés qui ont transformé la façon d’écrire du code asynchrone en C#. Depuis leur introduction en C# 5.0, ils promettent un code plus lisible, plus fluide, presque aussi naturel à lire que du code synchrone classique, et ils tiennent cette promesse… la plupart du temps.

Le vrai problème avec async/await, c’est que les erreurs qu’on y fait sont rarement visibles immédiatement. Le code compile, les tests passent, l’application tourne, et puis un jour, en production, un deadlock fige l’interface utilisateur, une exception disparaît dans la nature sans laisser de trace, ou une opération continue de consommer des ressources alors qu’elle aurait dû s’arrêter depuis longtemps. Ce sont précisément ces bugs-là qui sont les plus coûteux : ceux qu’on ne voit pas venir, et qui se manifestent au pire moment.

Dans cet article, on passe en revue les 5 pièges les plus fréquents que les développeurs .NET rencontrent avec async/await : des erreurs que l’on retrouve dans des codebases de toutes tailles, chez des juniors comme des seniors. Sans théorie abstraite : pour chaque piège, on verra pourquoi ça arrivece que ça coûte, et comment l’éviter.

 


Piège #1 — async void : la bombe à retardement

C’est sans doute le piège le plus classique, et pourtant il continue de s’inviter régulièrement dans les codebases. Lorsqu’on commence à asyncifier du code, il est tentant d’écrire async void par réflexe, notamment parce que le compilateur ne protestera pas :

// ❌ À éviter
public async void ChargerLesDonnees()
{
    var data = await _repository.GetAsync();
    // traitement...
}

Le problème est fondamental : une méthode async void ne retourne aucune Task. Elle ne peut donc pas être attendue par l’appelant, et surtout, toute exception levée à l’intérieur sera non observée, ce qui, selon la version de .NET et la configuration, peut faire planter le process entier de façon inattendue, ou passer silencieusement sans aucune trace dans les logs.

// ✅ La bonne pratique
public async Task ChargerLesDonnees()
{
    var data = await _repository.GetAsync();
    // traitement...
}

La règle est simple et sans exception : toujours retourner async Task (ou async Task<T>), sauf dans un seul cas légitime : les gestionnaires d’événements (event handlers), où la signature imposée par le framework ne laisse pas le choix :

// ✅ Seul cas acceptable pour async void
private async void Button_Click(object sender, EventArgs e)
{
    await ChargerLesDonnees();
}

Même dans ce cas, il est conseillé de déléguer immédiatement le travail à une méthode async Task séparée, afin de conserver une gestion d’erreur correcte.

 


Piège #2 — Deadlock : quand .Result et .Wait() bloquent tout

Celui-ci est plus subtil, mais ses conséquences sont immédiates et spectaculaires : l’application se fige complètement, sans message d’erreur, sans exception. Un deadlock.

Il survient lorsqu’on tente d’appeler du code asynchrone de manière synchrone, typiquement en utilisant .Result ou .Wait() :

// ❌ Recette garantie pour un deadlock
public string ObtenirDonnees()
{
    return _service.GetDataAsync().Result; // bloque le thread courant
}

Voici ce qui se passe : .Result bloque le thread appelant en attendant que la Task se termine. Mais la Task, une fois complétée, cherche à reprendre son exécution sur ce même thread, qui est justement bloqué en train de l’attendre. Les deux se bloquent mutuellement indéfiniment. Ce comportement est particulièrement fréquent dans les contextes où il existe un SynchronizationContext unique.

La solution est de ne jamais mélanger code synchrone et code asynchrone de cette façon : il faut propager l’asynchronisme jusqu’au bout de la chaîne d’appel.

// ✅ Async jusqu'au bout
public async Task<string> ObtenirDonnees()
{
    return await _service.GetDataAsync();
}

Si vous vous retrouvez dans une situation où vous n’avez vraiment pas d’autre choix que d’appeler du code async depuis un contexte synchrone, ce qui devrait rester exceptionnel : il existe des contournements comme Task.Run(...).GetAwaiter().GetResult(), mais ils ne sont pas sans risques et méritent une attention particulière.

 


Piège #3 — Fire and forget : la tâche qu’on croit lancée… et qui disparaît

Il arrive qu’on souhaite lancer une opération asynchrone en arrière-plan sans attendre son résultat : envoyer un email de notification, écrire dans un log externe, déclencher un traitement non critique. L’intention est légitime, mais la mise en œuvre est souvent hasardeuse :

// ❌ La tâche est lancée, mais personne ne l'observe
public void EnvoyerNotification(string message)
{
    _emailService.SendAsync(message); // pas d'await, pas de gestion d'erreur
}

Ici, la Task retournée par SendAsync est immédiatement abandonnée. Si une exception est levée à l’intérieur, elle sera silencieusement ignorée. Pire : selon la configuration du runtime, elle peut déclencher un événement UnobservedTaskException qui, dans certains cas, peut faire planter l’application.

Lorsque le fire and forget est vraiment intentionnel, il faut le faire proprement, en prenant soin d’observer les exceptions :

// ✅ Fire and forget maîtrisé
public void EnvoyerNotification(string message)
{
    _ = EnvoyerNotificationAsync(message);
}

private async Task EnvoyerNotificationAsync(string message)
{
    try
    {
        await _emailService.SendAsync(message);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Échec de l'envoi de la notification");
    }
}

L’affectation à _ (discard) signale explicitement que l’on choisit de ne pas awaiter la tâche, ce qui évite l’avertissement du compilateur tout en rendant l’intention claire pour les relecteurs du code.

 


Piège #4 — Oublier le CancellationToken

C’est le piège de l’omission : rien ne plante, rien ne compile en erreur, mais des opérations continuent de tourner en arrière-plan alors que leur résultat n’intéresse plus personne. Un utilisateur ferme un onglet, annule une requête HTTP, ou navigue ailleurs, et le serveur continue consciencieusement de requêter la base de données, d’appeler des services externes et de consommer des ressources inutilement.

// ❌ Aucune possibilité d'annulation
public async Task<List<Produit>> RechercherProduitsAsync(string terme)
{
    return await _repository.SearchAsync(terme);
}

La bonne pratique consiste à systématiquement accepter et propager un CancellationToken à travers toute la chaîne d’appel :

// ✅ Annulation correctement propagée
public async Task<List<Produit>> RechercherProduitsAsync(string terme, CancellationToken cancellationToken = default)
{
    return await _repository.SearchAsync(terme, cancellationToken);
}

En ASP.NET Core, le framework injecte automatiquement un CancellationToken dans les actions de contrôleur via HttpContext.RequestAborted, qui sera déclenché dès que le client abandonne la connexion. Il serait dommage de ne pas en profiter. Au-delà de l’aspect performance, propager les tokens d’annulation rend également le code plus testable et plus explicite sur ses contrats de durée de vie.

 


Piège #5 — Le await inutile en pass-through

Ce dernier piège est particulièrement intéressant parce qu’il ressemble à du bon code. Il est écrit par des développeurs qui ont bien compris async/await, et c’est précisément pourquoi il passe souvent inaperçu en code review :

// ❌ Le await est superflu ici
public async Task MaMethodeAsync()
{
    return await MaDeuxiemeMethodeAsync();
}

Lorsqu’une méthode se contente de retourner le résultat d’une autre méthode async sans rien faire d’autre, le await est inutile. Le compilateur C# génère pour chaque méthode async une state machine : une classe entière avec ses allocations mémoire et sa gestion d’état. En ajoutant un await superflu, on crée cette state machine pour rien, on ajoute une entrée supplémentaire dans la call stack, et on dégrade légèrement les performances sans aucune contrepartie.

La version correcte est simplement de retourner la Task directement :

// ✅ Pass-through propre
public Task MaMethodeAsync()
{
    return MaDeuxiemeMethodeAsync();
}

Cependant, attention à un cas particulier où le await devient obligatoire même en pass-through : lorsque l’appel se trouve dans un bloc try/catch. Sans await, la Task est retournée avant que l’exception ne soit levée, et le catch ne l’interceptera jamais :

// ✅ Ici, le await est nécessaire pour capturer l'exception
public async Task MaMethodeAsync()
{
    try
    {
        return await MaDeuxiemeMethodeAsync(); // le await est indispensable
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Une erreur est survenue");
        throw;
    }
}

C’est une nuance importante à avoir en tête avant de supprimer des await en masse lors d’un refactoring.

 


Conclusion

Après plus de dix ans dans l’écosystème .NET, async/await s’est imposé comme un pilier incontournable du développement C#, et il serait dommage de s’en priver par crainte de ses subtilités. Mais comme tout outil puissant, il demande un minimum de rigueur, d’autant plus que ses pièges ont une caractéristique commune particulièrement traître : ils ne font pas planter le compilateur, ils ne déclenchent pas toujours une exception visible, et ils peuvent rester tapis dans un codebase pendant des mois avant de se manifester au moment le moins opportun.

Les cinq pièges abordés dans cet article : async void, les deadlocks provoqués par .Result et .Wait(), le fire and forget mal maîtrisé, l’absence de CancellationToken, et le await inutile en pass-through, ne sont pas des cas limites exotiques réservés aux applications complexes. Ce sont des erreurs du quotidien, que l’on retrouve dans des projets de toutes tailles, et que même des développeurs expérimentés laissent parfois passer lors d’une relecture rapide.

La bonne nouvelle, c’est qu’une fois qu’on les connaît, ils deviennent relativement faciles à repérer et à corriger. Intégrer ces patterns dans ses habitudes de code review, configurer les règles d’analyse statique adaptées dans son projet, ou simplement garder cette liste en tête lors d’une session de refactoring suffit souvent à éviter l’essentiel des problèmes.

 

Article rédigé par Carole CHEVALIER