L’année dernière, une API a été remontée comme très lente sur un environnement de non-production. En analysant le temps passé par tous les éléments dans la chaîne du code, l’envoi d’email est ressorti comme problématique.
Dans le contexte projet à ce moment, il n’y avait pas de temps pour changer le code, surtout qu’on était en pleine migration en plusieurs étapes de .NET Framework vers .NET.
Les solutions recommandées ci-dessous ne pouvaient être implémentées dans les temps. Toutefois, dans une requête de tirage qui tentait de solutionner l’anomalie, nous avons discuté d’utiliser ou pas la technique « Fire & Forget » : lancer l’envoi de l’email avec Task.Run sans attendre la fin de la tâche, pour rendre la main tout de suite à l’appelant.
Dans une application ASP.NET, c’est une MAUVAISE idée.
Voici pourquoi et les alternatives appropriées.
Pourquoi est-ce problématique
Une tâche fire-and-forget continue à s’exécuter après la fin de la requête HTTP, sans que personne ne l’attende. Cela pose trois problèmes.
1. Aucune garantie d’exécution
Cette solution est dangereuse, car elle n’est pas fiable ; il n’y a aucune garantie que le travail en arrière-plan sera réellement effectué. Si votre application ASP.NET redémarre pendant l’envoi (déploiement, recyclage du pool d’applications), l’email ne sera jamais envoyé.
| |
2. Problèmes avec les objets liés à la requête
Quand la tâche s’exécute, la requête est peut-être déjà terminée. Les objets qui lui sont liés (HttpContext.Current, un DbContext créé pour la durée de la requête) ne sont alors plus disponibles.
| |
3. Les exceptions sont perdues
Personne n’attend la tâche lancée par Task.Run, donc personne n’observe son exception. Si le serveur SMTP est injoignable, l’appelant reçoit quand même une réponse en succès, rien n’est loggé et personne ne sait que l’email n’est jamais parti.
| |
Depuis .NET Framework 4.5, une exception non observée ne fait plus planter le processus. Sauf si vous écoutez l’événement TaskScheduler.UnobservedTaskException, elle disparaît en silence.
Quelles solutions sont recommandées
Le code d’origine était synchrone, et les exemples ci-dessous le reflètent. Les options 2 et 4 se contentent d’enregistrer le travail à faire : elles gardent donc une signature synchrone. Les options 1 et 3 attendent un appel réseau avec await : la méthode devient async, et ce changement remonte toute la chaîne d’appels jusqu’au contrôleur.
Sur du code existant, c’est un coût à prendre en compte. Bloquer avec .Result ou .Wait() pour l’éviter est risqué sur ASP.NET classique, car cela peut provoquer un interblocage avec le contexte de synchronisation de la requête.
Option 1 : Queue fiable + Worker (Recommandé pour la production)
| |
Puis un worker indépendant (service Windows, Azure Function, Hangfire) traite la queue.
Alors, oui, il existe d’autres problématiques quand on commence à travailler avec des processus distribués, mais cela sera sûrement le sujet d’un autre article. En attendant, voici une vidéo par une personne plus compétente que moi pour expliquer les problématiques que j’ai en tête.
Option 2 : HostingEnvironment.QueueBackgroundWorkItem
Pour des emails non critiques dans une application qui ne redémarre pas souvent, .NET Framework 4.5.2 a introduit HostingEnvironment.QueueBackgroundWorkItem :
| |
Contrairement à un simple Task.Run, ASP.NET sait que la tâche tourne. Quand l’application s’arrête, il signale le CancellationToken et tente de retarder l’arrêt jusqu’à la fin de la tâche. Ce n’est qu’une tentative, pas une garantie : si le processus plante ou si l’arrêt n’attend pas assez longtemps, l’email est perdu.
Option 3 : Attendre simplement l’envoi via une API dédiée
Les services d’envoi d’email (SendGrid, Mailjet, Amazon SES, etc.) exposent une API qui empile la demande d’envoi et répond rapidement. La livraison réelle se fait plus tard, de leur côté. On peut donc simplement attendre cet appel dans la requête.
| |
Attention : ce n’est rapide que si l’API l’est. Si l’envoi passe par un composant lent, attendre l’appel déplace la lenteur dans la requête. C’est ce qui nous est arrivé, comme expliqué en conclusion.
Option 4 : Hangfire (Si vous avez besoin d’un système de jobs)
| |
Hangfire enregistre le job dans son stockage (SQL Server par exemple), puis obtient un IEmailService au moment de l’exécuter, via son activateur de jobs branché sur votre conteneur d’injection. Aucun objet de la requête n’est donc capturé.
Résumé
| Approche | Fiabilité | Complexité | Cas d’usage |
|---|---|---|---|
| Task.Run | ❌ Très faible | Faible | Jamais |
| Await direct | ✅ Haute | Très faible | Email non-critique, API rapide |
| Queue + Worker | ✅ Très haute | Haute | Production, emails critiques |
| Hangfire | ✅ Haute | Moyenne | Jobs avec retry |
| QueueBackgroundWorkItem | ⚠️ Moyenne | Faible | Tâches internes non critiques |
Sources
- Microsoft Community Hub—Fire-and-Forget Methods in C#
- Stephen Cleary—AspNetBackgroundTasks
- Microsoft Learn—HostingEnvironment.QueueBackgroundWorkItem
Conclusion
Donc, n’utilisez jamais le fire-and-forget avec Task.Run dans ASP.NET. Utilisez une queue fiable ou attendez simplement l’opération si elle est rapide.
Dans le cas présenté ci-dessus, on a décidé de partir avec l’option 3 dans la phase du projet dans laquelle on se trouvait, sacrifiant la performance dans un environnement de non-production.
En effet, le serveur SMTP était configuré pour déposer les emails dans un dossier dont le chemin était un chemin UNC. Les appels prenaient ~25 s par appel quand on enchaînait rapidement ceux-ci, le premier appel étant toujours rapide (500 ms).
En attendant 10 s entre chaque appel, on avait systématiquement un temps d’exécution de l’appel API en 500 ms environ. Le pourquoi est une histoire pour un autre article… Mais à ce jour, je n’ai pas trouvé la cause de ce comportement.
Suivez-moi !
Merci d’avoir lu cet article. Assurez-vous de me suivre sur X, de vous abonner à ma publication Substack et d’ajouter mon blog à vos favoris pour ne pas manquer les prochains articles.
Crédit : Photo by Joshua Woroniecki (https://www.pexels.com/photo/clear-glass-bottle-on-brown-sand-4724049/).
