Pool de workers en Go vs goroutine par tâche pour le traitement en arrière‑plan
Pools de workers en Go vs une goroutine par tâche : découvrez comment chaque modèle impacte le débit, l'utilisation mémoire et la rétropression pour le traitement en arrière-plan et les workflows longue durée.

Quel problème essayons‑nous de résoudre ?
La plupart des services Go font plus que répondre à des requêtes HTTP. Ils exécutent aussi du travail en arrière-plan : envoyer des e-mails, redimensionner des images, générer des factures, synchroniser des données, traiter des événements ou reconstruire un index de recherche. Certains jobs sont rapides et indépendants. D'autres forment des workflows longs où chaque étape dépend de la précédente (débiter une carte, attendre la confirmation, puis notifier le client et mettre à jour les rapports).
Quand on compare « pool de workers en Go vs goroutine par tâche », on cherche généralement à résoudre un problème de production : comment exécuter beaucoup de travail en arrière-plan sans rendre le service lent, coûteux ou instable.
On ressent l'impact à plusieurs niveaux :
- Latence : le travail en arrière-plan vole du CPU, de la mémoire, des connexions DB et de la bande passante réseau aux requêtes orientées utilisateur.
- Coût : une concurrence incontrôlée vous pousse vers des machines plus grosses, plus de capacité DB, ou des factures de queue/API plus élevées.
- Stabilité : des rafales (imports, envois marketing, tempêtes de retry) peuvent déclencher des timeouts, des crashs OOM ou des pannes en cascade.
Le vrai compromis est simplicité vs contrôle. Lancer une goroutine par tâche est simple à écrire et souvent suffisant quand le volume est faible ou naturellement limité. Un pool de workers ajoute de la structure : concurrence fixe, limites claires et un endroit naturel pour placer timeouts, retries et métriques. Le coût est du code en plus et une décision sur ce qui arrive quand le système est occupé (les tâches attendent‑elles, sont‑elles rejetées ou stockées ailleurs ?).
Il s'agit du traitement quotidien en arrière-plan : débit, mémoire et rétropression (comment éviter la surcharge). Ce guide ne couvre pas toutes les technologies de queue, les moteurs de workflow distribués, ni la sémantique exactly‑once.
Si vous construisez des applis complètes avec logique en arrière‑plan via une plateforme comme AppMaster, les mêmes questions reviennent vite. Vos processus métier et intégrations ont toujours besoin de limites autour des bases de données, des API externes et des fournisseurs d'email/SMS pour qu'un workflow occupé ne ralentisse pas tout le reste.
Deux patterns communs en termes simples
Goroutine par tâche
C'est l'approche la plus simple : quand un job arrive, démarrez une goroutine pour le traiter. La « file » est souvent ce qui déclenche le travail, comme la réception d'un channel ou un appel direct depuis un handler HTTP.
La forme typique est : recevoir un job, puis go handle(job). Parfois un channel intervient encore, mais uniquement comme point de transfert, pas comme limite.
Ça fonctionne bien quand les jobs attendent surtout de l'I/O (appels HTTP, requêtes DB, uploads), que le volume est modeste et que les rafales sont petites ou prévisibles.
Le défaut est que la concurrence peut croître sans plafond clair. Cela peut faire monter la mémoire, ouvrir trop de connexions ou saturer un service en aval.
Pool de workers
Un pool de workers démarre un nombre fixe de goroutines worker et leur fournit des jobs depuis une queue, généralement un channel tamponné en mémoire. Chaque worker boucle : prendre un job, le traiter, recommencer.
La différence clé est le contrôle. Le nombre de workers est une limite de concurrence stricte. Si les jobs arrivent plus vite que les workers ne les finissent, les jobs attendent dans la file (ou sont rejetés si la file est pleine).
Les worker pools conviennent quand le travail est lourd en CPU (traitement d'images, génération de rapports), quand vous avez besoin d'une utilisation de ressources prévisible, ou quand vous devez protéger une base de données ou une API tierce contre les rafales.
Où vit la file
Les deux patterns peuvent utiliser un channel en mémoire, rapide mais perdu au redémarrage. Pour des jobs « à ne pas perdre » ou des workflows longs, la file se déplace souvent hors du processus (table DB, Redis ou broker de messages). Dans ce cas, vous choisissez encore entre goroutine par tâche et pool de workers, mais ils jouent le rôle de consommateurs de la file externe.
En exemple simple, si le système doit soudainement envoyer 10 000 e‑mails, la goroutine par tâche peut essayer de tous les lancer d'un coup. Un pool peut en envoyer 50 à la fois et garder le reste en attente de manière contrôlée.
Débit : ce qui change et ce qui ne change pas
On s'attend souvent à une grosse différence de débit entre pool et goroutine par tâche. La plupart du temps, le débit brut est limité par autre chose que la façon dont vous démarrez les goroutines.
Le débit atteint généralement un plafond à la ressource partagée la plus lente : base de données ou API externe, disque ou bande passante réseau, travail lourd CPU (JSON/PDF/redimensionnement d'images), verrous et état partagé, ou services en aval qui ralentissent sous charge.
Si une ressource partagée est le goulot, lancer plus de goroutines n'accélère pas le travail. Ça crée surtout plus d'attente à ce point de congestion.
La goroutine par tâche peut gagner quand les tâches sont courtes, majoritairement I/O bound et ne concourent pas sur des limites partagées. Le démarrage d'une goroutine est peu coûteux et Go orchestre très bien un grand nombre d'entre elles. Dans une boucle du type « fetch, parse, write one row », cela peut maintenir les CPU occupés et masquer la latence réseau.
Les pools gagnent quand il faut borner des ressources coûteuses. Si chaque job tient une connexion DB, ouvre des fichiers, alloue de gros buffers ou tape dans un quota d'API, une concurrence fixe maintient le service stable tout en atteignant le débit maximal sûr.
La latence (surtout le p99) est souvent là où la différence apparaît. La goroutine par tâche peut paraître excellente à faible charge, puis s'effondrer quand trop de tâches s'accumulent. Les pools introduisent un délai de mise en file (les jobs attendent un worker libre), mais le comportement est plus stable car vous évitez une ruée sur la même limite.
Un modèle mental simple :
- Si le travail est bon marché et indépendant, plus de concurrence peut augmenter le débit.
- Si le travail est freiné par une limite partagée, plus de concurrence augmente surtout l'attente.
- Si le p99 vous importe, mesurez le temps en file séparément du temps de traitement.
Mémoire et usage des ressources
Beaucoup du débat pool vs goroutine par tâche porte en réalité sur la mémoire. Le CPU peut souvent être mis à l'échelle. Les pannes mémoire sont plus soudaines et peuvent tombent tout le service.
Une goroutine est bon marché, mais pas gratuite. Chacune démarre avec une petite pile qui grandit si elle appelle des fonctions profondes ou retient de grosses variables locales. Il y a aussi du bookkeeping du scheduler et du runtime. 10 000 goroutines peuvent aller, 100 000 peuvent surprendre si chacune garde des références à de gros objets job.
Le coût caché est souvent ce que la goroutine garde vivante. Si les tâches arrivent plus vite qu'elles ne finissent, la goroutine par tâche crée un arriéré non borné. La « file » peut être implicite (goroutines en attente sur des verrous ou I/O) ou explicite (channel tamponné, slice, batch en mémoire). Dans tous les cas, la mémoire croît avec l'arriéré.
Les pools aident parce qu'ils imposent un plafond. Avec des workers fixes et une file bornée, vous obtenez une limite mémoire réelle et un mode d'échec clair : une fois la file pleine, vous bloquez, sheddez la charge ou renvoyez vers l'amont.
Un calcul rapide d'ordre de grandeur :
- Goroutines au pic = workers + jobs en vol + jobs « en attente » que vous avez créés
- Mémoire par job = payload (octets) + métadonnées + tout ce qui est référencé (requêtes, JSON décodé, lignes DB)
- Mémoire d'arriéré ≈ jobs en attente * mémoire par job
Exemple : si chaque job détient un payload de 200 KB (ou référence un graphe d'objets de 200 KB) et que vous laissez 5 000 jobs s'entasser, cela fait environ 1 GB rien que pour les payloads. Même si les goroutines étaient magiquement gratuites, l'arriéré ne l'est pas.
Rétropression : éviter que le système ne fonde
La rétropression est simple : quand le travail arrive plus vite que vous ne pouvez le terminer, le système renvoie une pression de manière contrôlée au lieu d'empiler silencieusement. Sans cela, vous n'avez pas seulement un ralentissement : vous avez des timeouts, une montée de mémoire et des pannes difficiles à reproduire.
On remarque l'absence de rétropression quand une rafale déclenche des schémas comme mémoire qui monte et ne redescend pas, temps en file qui grandit alors que le CPU reste occupé, pics de latence pour des requêtes non liées, retries qui s'accumulent, ou erreurs comme « too many open files » et exhaustion des pools de connexions.
Un outil pratique est un channel borné : limitez combien de jobs peuvent attendre. Les producteurs bloquent quand le channel est plein, ce qui ralentit la création de jobs à la source.
Bloquer n'est pas toujours la bonne option. Pour du travail optionnel, choisissez une politique explicite pour que la surcharge soit prévisible :
- Drop les tâches à faible valeur (par ex. notifications dupliquées)
- Batch plusieurs petites tâches en une seule écriture ou un seul appel API
- Delay le travail avec du jitter pour éviter les pics de retry
- Defer vers une queue persistante et retournez rapidement
- Shed load en renvoyant une erreur claire quand vous êtes déjà surchargé
Le rate limiting et les timeouts sont aussi des outils de rétropression. Le rate limiting borne la vitesse d'atteinte d'une dépendance (fournisseur d'e‑mail, DB, API tierce). Les timeouts bornent la durée pendant laquelle un worker peut rester bloqué. Ensemble, ils empêchent qu'une dépendance lente devienne une panne totale.
Exemple : génération d'états de compte en fin de mois. Si 10 000 requêtes arrivent d'un coup, des goroutines illimitées peuvent déclencher 10 000 rendus PDF et uploads. Avec une file bornée et des workers fixes, vous rendez et réessayez à un rythme sûr.
Comment construire un pool de workers étape par étape
Un pool de workers borne la concurrence en exécutant un nombre fixe de workers et en leur fournissant des jobs depuis une file.
1) Choisir une limite de concurrence sûre
Commencez par ce sur quoi vos jobs passent du temps.
- Pour le travail CPU-heavy, gardez les workers proches du nombre de cœurs CPU.
- Pour le travail I/O-heavy (DB, HTTP, stockage), vous pouvez monter plus haut, mais stoppez quand les dépendances commencent à timeout ou throttler.
- Pour du travail mixte, mesurez et ajustez. Une plage de départ raisonnable est souvent 2x à 10x le nombre de cœurs, puis affinez.
- Respectez les limites partagées. Si le pool DB est de 20 connexions, 200 workers ne feront que se battre pour ces 20.
2) Choisir la file et sa taille
Un channel tamponné est courant car intégré et simple à raisonner. Le buffer est votre amortisseur pour les rafales.
Les petits buffers exposent vite la surcharge (les producteurs bloquent plus tôt). Les grands buffers lissent les pics mais peuvent cacher les problèmes et augmenter mémoire et latence. Dimensionnez le buffer avec intention et décidez de l'action quand il se remplit.
3) Rendre chaque tâche annulable
Passez un context.Context dans chaque job et assurez-vous que le code du job l'utilise (DB, HTTP). C'est ainsi que vous arrêtez proprement lors des déploiements, shutdowns et timeouts.
func StartPool(ctx context.Context, workers, queueSize int, handle func(context.Context, Job) error) chan<- Job {
jobs := make(chan Job, queueSize)
for i := 0; i < workers; i++ {
go func() {
for {
select {
case <-ctx.Done():
return
case j := <-jobs:
_ = handle(ctx, j)
}
}
}()
}
return jobs
}
4) Ajouter les métriques que vous utiliserez vraiment
Si vous ne suivez que quelques chiffres, prenez ceux‑ci :
- Profondeur de la file (à quel point vous êtes en retard)
- Temps d'occupation des workers (saturation du pool)
- Durée des tâches (p50, p95, p99)
- Taux d'erreur (et comptes de retry si vous retentez)
C'est suffisant pour ajuster le nombre de workers et la taille de la file sur des bases mesurables, pas des suppositions.
Erreurs et pièges courants
La plupart des équipes ne sont pas blessées par le mauvais pattern en lui‑même. Elles sont blessées par des choix par défaut qui se transforment en incidents quand le trafic monte.
Quand les goroutines se multiplient
Le piège classique est de lancer une goroutine par job pendant une rafale. Quelques centaines passent. Quelques centaines de milliers peuvent inonder le scheduler, le heap, les logs et les sockets réseau. Même si chaque goroutine est petite, le coût total s'additionne, et la récupération prend du temps parce que le travail est déjà en vol.
Autre erreur : traiter un énorme channel tamponné comme de la « rétropression ». Un grand buffer n'est qu'une file cachée. Il peut acheter du temps, mais il cache aussi les problèmes jusqu'à ce que vous atteigniez un mur mémoire. Si vous avez besoin d'une queue, dimensionnez‑la délibérément et décidez de la stratégie quand elle est pleine (bloquer, drop, retry plus tard, ou persister).
Goulots cachés
Beaucoup de jobs en arrière‑plan ne sont pas CPU bound. Ils sont limités par quelque chose en aval. Si vous ignorez ces limites, un producteur rapide submerge un consommateur lent.
Pièges courants :
- Pas d'annulation ou timeout, donc les workers peuvent bloquer indéfiniment sur une API ou une requête DB
- Comptes de workers choisis sans vérifier les vraies limites comme les connexions DB, l'I/O disque ou les caps d'API tierces
- Retries qui amplifient la charge (retries immédiats sur 1 000 jobs échoués)
- Un verrou partagé ou une transaction unique qui sérialise tout, donc « plus de workers » n'ajoute que de l'overhead
- Manque de visibilité : pas de métriques pour profondeur de file, âge des jobs, compte de retry et utilisation des workers
Exemple : un export nocturne déclenche 20 000 tâches « envoyer notification ». Si chaque tâche touche la DB et un fournisseur d'email, il est facile de dépasser les pools de connexions ou les quotas. Un pool de 50 workers avec timeouts par tâche et une petite file rend la limite évidente. Une goroutine par tâche plus un énorme buffer fait paraître le système sain… jusqu'au premier mur.
Exemple : exports et notifications en rafale
Imaginez une équipe support qui a besoin de données pour un audit. Une personne clique sur « Export », puis quelques collègues font de même, et soudain vous avez 5 000 exports créés en une minute. Chaque export lit la DB, formate un CSV, stocke un fichier et envoie une notification (email ou Telegram) quand c'est prêt.
Avec la goroutine par tâche, le système paraît super un moment. Les 5 000 jobs démarrent presque instantanément et on a l'impression que la file se vide vite. Puis les coûts apparaissent : des milliers de requêtes DB concurrentes se battent pour les connexions, la mémoire monte car les jobs gardent des buffers, et les timeouts deviennent fréquents. Des jobs qui auraient pu finir vite se retrouvent bloqués derrière des retries et des requêtes lentes.
Avec un pool de workers, le départ est plus lent mais l'exécution est plus calme. Avec 50 workers, seulement 50 exports font du travail lourd en même temps. L'utilisation DB reste dans une plage prévisible, les buffers sont réutilisés plus souvent, et la latence est plus stable. Le temps total d'exécution est aussi plus facile à estimer : approximativement (jobs / workers) * durée moyenne du job, plus un peu d'overhead.
La différence clé n'est pas que les pools sont magiquement plus rapides. C'est qu'ils empêchent le système de s'autodétruire pendant les rafales. Un déroulé contrôlé à 50 à la fois finit souvent plus vite que 5 000 jobs qui se battent.
Où appliquer la rétropression dépend de ce que vous voulez protéger :
- À la couche API, rejeter ou retarder les nouvelles requêtes d'export quand le système est occupé.
- À la queue, accepter les requêtes mais enqueuer et drainer à un rythme sûr.
- Dans le pool de workers, borner la concurrence pour les parties coûteuses (lectures DB, génération de fichiers, envoi de notifications).
- Par ressource, séparer les limites (par ex. 40 workers pour les exports mais seulement 10 pour les notifications).
- Sur les appels externes, rate limiter e‑mail/SMS/Telegram pour éviter d'être bloqué.
Checklist rapide avant la mise en production
Avant d'exécuter des jobs en arrière‑plan en prod, vérifiez limites, visibilité et gestion des pannes. La plupart des incidents ne viennent pas de « code lent » mais de l'absence de garde‑fous quand la charge monte ou qu'une dépendance flanche.
- Fixez une concurrence maximale par dépendance. Ne choisissez pas un seul nombre global en espérant qu'il convienne à tout.
- Rendez la file bornée et observable. Mettez une vraie limite sur les jobs en attente et exposez quelques métriques : profondeur de file, âge du plus vieux job et taux de traitement.
- Ajoutez des retries avec jitter et un chemin dead‑letter. Retry sélectivement, étalez les retries et après N échecs déplacez le job en dead‑letter ou dans une table “failed” avec assez d'infos pour relire et rejouer.
- Vérifiez le comportement de shutdown : drainer, annuler, reprendre en sécurité. Décidez ce qui arrive au déploiement ou au crash. Rendez les jobs idempotents pour que le retraitement soit sûr, et stockez la progression pour les workflows longs.
- Protégez le système avec timeouts et circuit breakers. Chaque appel externe doit avoir un timeout. Si une dépendance est hors service, échouez vite (ou stoppez l'entrée) plutôt que d'empiler du travail.
Étapes pratiques suivantes
Choisissez le pattern qui correspond à ce que votre système voit un jour normal, pas un jour parfait. Si le travail arrive par rafales (uploads, exports, envois d'e‑mail), un pool de workers fixe avec une file bornée est généralement le choix par défaut le plus sûr. Si le travail est stable et que chaque tâche est petite, la goroutine par tâche peut convenir, tant que vous imposez des limites quelque part.
Le bon choix est souvent celui qui rend l'échec ennuyeux. Les pools rendent les limites visibles. La goroutine par tâche rend facile d'oublier des limites jusqu'au premier vrai pic.
Commencez simple, puis ajoutez bornes et visibilité
Démarrez avec quelque chose de simple, mais ajoutez deux contrôles tôt : un plafond de concurrence et un moyen de voir la mise en file et les échecs.
Plan de déploiement pratique :
- Définissez la forme de votre charge : en rafale, stable ou mixte (et à quoi ressemble le “pic”).
- Mettez un plafond sur le travail en vol (taille du pool, sémaphore ou channel borné).
- Décidez ce qui arrive quand le plafond est atteint : bloquer, drop, ou renvoyer une erreur claire.
- Ajoutez des métriques basiques : profondeur de file, temps en file, temps de traitement, retries et dead letters.
- Testez en charge avec un burst à 5x votre pic attendu et observez mémoire et latence.
Quand un pool ne suffit pas
Si des workflows peuvent tourner pendant des minutes à des jours, un simple pool peut peiner parce que le travail n'est pas juste “faire une fois”. Il faut de l'état, des retries et de la résumabilité. Cela implique généralement de persister la progression, d'avoir des étapes idempotentes et d'appliquer du backoff. Cela peut aussi signifier découper un gros job en étapes plus petites pour pouvoir reprendre après un crash.
Si vous voulez délivrer un backend complet avec workflows plus vite, AppMaster (appmaster.io) peut être une option pratique : vous modélisez données et logique métier visuellement, et il génère du vrai code Go pour le backend afin de garder la même discipline sur les limites de concurrence, la mise en file et la rétropression sans tout câbler à la main.
FAQ
Par défaut, préférez un pool de workers quand les jobs peuvent arriver en rafales ou touchent des limites partagées comme des connexions DB, le CPU ou des quotas d'API externes. Utilisez une goroutine par tâche quand le volume est modeste, les tâches sont courtes, et vous avez quand même une limite claire quelque part (par exemple un sémaphore ou un rate limiter).
Lancer une goroutine par tâche est rapide à écrire et peut offrir un excellent débit à faible charge, mais cela peut créer une file d'attente non bornée en cas de pic. Un pool de workers ajoute un plafond de concurrence et un endroit clair pour appliquer timeouts, retries et métriques, ce qui rend habituellement le comportement en production plus prévisible.
Habituellement, pas beaucoup. Dans la plupart des systèmes, le débit est limité par un goulot d'étranglement partagé (base de données, API externe, I/O disque, ou étapes lourdes en CPU). Augmenter le nombre de goroutines ne va pas dépasser cette limite ; cela augmente surtout l'attente et la contention.
La goroutine par tâche donne souvent une meilleure latence à faible charge, puis peut fortement se dégrader à haute charge car tout entre en compétition. Un pool ajoute du délai de mise en file, mais il tend à stabiliser le p99 en évitant une ruée massive sur les mêmes dépendances.
Le coût principal n'est pas la goroutine elle-même mais l'arriéré. Si les tâches s'accumulent et que chacune retient des payloads ou de gros objets, la mémoire peut grimper vite. Un pool avec une file bornée transforme cela en un plafond mémoire défini et un comportement d'échec prévisible.
La rétropression signifie que vous ralentissez ou arrêtez l'acceptation de nouveau travail quand le système est déjà occupé, au lieu de laisser le travail s'accumuler silencieusement. Une file bornée est une forme simple : quand elle est pleine, les producteurs bloquent ou renvoient une erreur, ce qui évite la saturation mémoire et des pools de connexions.
Commencez depuis la vraie limite. Pour les tâches CPU-bound, placez-vous près du nombre de cœurs CPU. Pour les tâches I/O-bound, vous pouvez monter plus haut, mais stoppez quand la DB, le réseau ou les API externes commencent à timeout ou throttler, et respectez les tailles de pool de connexions.
Choisissez une taille qui absorbe les rafales normales sans masquer les problèmes pendant des minutes. Les petits buffers exposent vite la surcharge ; les gros buffers peuvent augmenter l'utilisation mémoire et faire attendre les utilisateurs plus longtemps avant qu'une erreur n'apparaisse. Décidez à l'avance ce qui arrive quand la file est pleine : bloquer, rejeter, abandonner ou persister ailleurs.
Utilisez context.Context par job et assurez-vous que les appels DB et HTTP le respectent. Mettez des timeouts sur les appels externes et rendez explicite le comportement de shutdown pour que les workers s'arrêtent proprement sans laisser de goroutines pendantes ou de travail à moitié fini.
Surveillez la profondeur de la file, le temps passé dans la file, la durée des tâches (p50/p95/p99) et les comptes d'erreur/retry. Ces métriques indiquent si vous avez besoin de plus de workers, d'une file plus petite, de timeouts plus stricts ou d'un rate limiting vers une dépendance.


