📍 Guide principal : Go pour microservices : la stack pratique 2026
Cet article approfondit la concurrence Go vue dans le guide principal. Pour la vue d’ensemble du langage en production, lire d’abord le guide.
Introduction
Le mot « goroutine » figure dans tous les supports marketing de Go, à un point tel qu’on en oublie parfois ce qu’on en attend vraiment. Une goroutine, prise isolément, n’a guère plus de valeur qu’un thread vert. Ce qui change la donne, c’est l’écosystème conceptuel autour : channels, select, context, sync.WaitGroup, errgroup. Ces briques transforment des problèmes apparemment compliqués (paralléliser des appels, annuler proprement, propager les erreurs) en code lisible que vous comprendrez encore dans deux ans. Ce tutoriel vous fait écrire pas à pas un module qui télécharge en parallèle plusieurs URLs avec contrôle du nombre de workers, timeout global, annulation par contexte, et propagation correcte des erreurs.
Le code que vous allez produire n’a rien d’académique. C’est exactement le pattern que vous écrirez quand vous devrez paralléliser des appels API en aval, traiter des fichiers en lot, ou consommer un message broker à plusieurs workers. À la fin du tutoriel, vous aurez des réflexes solides sur les pièges classiques de la concurrence Go : fuites de goroutines, partages involontaires, deadlocks et timers oubliés.
Prérequis
- Go 1.26 ou plus récent — le run loop scheduler a été significativement amélioré
- Un terminal et un éditeur configuré
- Niveau attendu : intermédiaire — vous devez être à l’aise avec les fonctions de première classe et les types interfaces
- Temps estimé : 60 à 90 minutes de codage attentif
Étape 1 — Initialiser le projet et comprendre le squelette
On commence par un module minimal et un point d’entrée vide. Cela vous force à voir le squelette avant de l’habiller. Le projet final consommera une seule dépendance externe — golang.org/x/sync/errgroup — qui est dans le périmètre semi-officiel de l’équipe Go et offre un WaitGroup qui propage la première erreur rencontrée.
mkdir parallele && cd parallele
go mod init github.com/votreorg/parallele
go get golang.org/x/sync@latest
mkdir -p cmd/dl internal/fetch
La commande crée la structure et installe x/sync. Vérifiez avec cat go.mod que la dépendance figure bien. À ce stade rien ne tourne encore, mais vous avez un module Go vide compilable : un go build ./... doit terminer sans output.
Étape 2 — Une fonction de fetch séquentielle pour comparer
Avant d’introduire la concurrence, on écrit la version séquentielle. Cela permet ensuite d’apprécier ce que la concurrence apporte concrètement, et de tester que la logique métier de base est correcte avant d’ajouter de la complexité de synchronisation.
// internal/fetch/sequentiel.go
package fetch
import (
"context"
"io"
"net/http"
)
type Resultat struct {
URL string
Statut int
Octets int
Erreur error
}
func FetchSequentiel(ctx context.Context, urls []string) []Resultat {
out := make([]Resultat, len(urls))
client := &http.Client{}
for i, u := range urls {
out[i] = fetchUn(ctx, client, u)
}
return out
}
func fetchUn(ctx context.Context, c *http.Client, url string) Resultat {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { return Resultat{URL: url, Erreur: err} }
resp, err := c.Do(req)
if err != nil { return Resultat{URL: url, Erreur: err} }
defer resp.Body.Close()
n, err := io.Copy(io.Discard, resp.Body)
return Resultat{URL: url, Statut: resp.StatusCode, Octets: int(n), Erreur: err}
}
Plusieurs points à intégrer dès maintenant. http.NewRequestWithContext attache le contexte à la requête, ce qui signifie qu’une annulation se propage jusqu’à la couche réseau et coupe la connexion TCP. defer resp.Body.Close() libère la connexion vers le pool — l’oublier crée des fuites de connexions visibles sous forte charge. io.Copy(io.Discard, resp.Body) consomme et jette le corps : sans cette lecture, la connexion ne peut pas être réutilisée par le pool HTTP. Compilez avec go vet ./internal/fetch/ ; le verifier doit valider sans aucun warning.
Étape 3 — Premier passage parallèle naïf avec WaitGroup
Maintenant, on parallélise. La version naïve qu’on présente partout repose sur sync.WaitGroup. C’est correct pour comprendre, mais elle a deux limitations qu’on corrigera ensuite : pas de limite sur le nombre de goroutines, et pas de propagation d’erreur. Pour autant, lire ce code-là est indispensable pour saisir ce que la solution finale améliore.
// internal/fetch/parallele_naif.go
package fetch
import (
"context"
"net/http"
"sync"
)
func FetchParalleleNaif(ctx context.Context, urls []string) []Resultat {
out := make([]Resultat, len(urls))
client := &http.Client{}
var wg sync.WaitGroup
for i, u := range urls {
wg.Add(1)
go func(idx int, url string) {
defer wg.Done()
out[idx] = fetchUn(ctx, client, url)
}(i, u)
}
wg.Wait()
return out
}
Trois subtilités. D’abord, on capture i et u en paramètres de la fonction anonyme — c’est l’habitude sûre, même si depuis Go 1.22 chaque itération crée une nouvelle variable de boucle. Cela protège contre des refactorings ultérieurs qui réintroduiraient le piège. Ensuite, l’écriture out[idx] = ... n’a pas besoin de mutex parce que chaque goroutine écrit à un index différent, sans collision. Enfin, on partage le http.Client entre toutes les goroutines : c’est correct et désirable, le client gère son propre pool de connexions concurrentes. Lancez go test -race ./... ; aucun signalement de race ne doit apparaître.
Étape 4 — Limiter le nombre de workers avec un pool
Si vous lancez la version naïve avec dix mille URLs, vous lancez dix mille goroutines simultanées qui ouvrent autant de connexions TCP et saturent le serveur cible. Aucun service en production ne fait ça. Le pattern correct est un pool de N workers qui consomment les URLs depuis un channel d’entrée et publient les résultats dans un channel de sortie.
// internal/fetch/pool.go
package fetch
import (
"context"
"net/http"
"sync"
)
func FetchPool(ctx context.Context, urls []string, workers int) []Resultat {
if workers <= 0 { workers = 1 }
client := &http.Client{}
in := make(chan int, len(urls))
out := make([]Resultat, len(urls))
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for idx := range in {
out[idx] = fetchUn(ctx, client, urls[idx])
}
}()
}
for i := range urls { in <- i }
close(in)
wg.Wait()
return out
}
L’élégance du pattern réside dans close(in). Quand le channel est fermé, la boucle for idx := range in de chaque worker se termine naturellement après avoir épuisé la file. wg.Wait() bloque jusqu’à ce que tous les workers aient terminé, garantissant que out est complet avant de retourner. Lancez go run ./cmd/dl avec un pool de 8 workers et 100 URLs ; vous verrez le débit grimper d’environ 8x par rapport à la version séquentielle (selon la latence des cibles), tout en gardant un usage de connexions raisonnable.
Étape 5 — Propager les erreurs proprement avec errgroup
La version pool fonctionne mais toutes les erreurs sont noyées dans le slice de résultats. Souvent, ce qu’on veut, c’est : si une erreur survient, annuler tout le reste. C’est exactement ce que errgroup fait. Il combine un WaitGroup avec une propagation d’erreur et une annulation automatique du contexte. Cette bibliothèque golang.org/x/sync/errgroup est maintenue par l’équipe Go et stable depuis 2017.
// internal/fetch/errgroup.go
package fetch
import (
"context"
"net/http"
"golang.org/x/sync/errgroup"
)
func FetchErrGroup(ctx context.Context, urls []string, workers int) ([]Resultat, error) {
if workers <= 0 { workers = 1 }
client := &http.Client{}
out := make([]Resultat, len(urls))
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(workers)
for i, u := range urls {
idx, url := i, u
g.Go(func() error {
r := fetchUn(gctx, client, url)
out[idx] = r
return r.Erreur
})
}
if err := g.Wait(); err != nil { return out, err }
return out, nil
}
Trois choses à comprendre. g.SetLimit(workers), disponible dans errgroup depuis plusieurs versions du module golang.org/x/sync, limite la concurrence sans avoir à gérer un channel manuellement. errgroup.WithContext retourne un nouveau contexte qui est automatiquement annulé dès qu’une goroutine retourne une erreur. Enfin, on retourne out même en cas d’erreur, parce qu’on a peut-être des résultats partiels exploitables. Avec go test -race ./internal/fetch/, l’ensemble doit rester clean.
Étape 6 — Ajouter un timeout global et l’annulation manuelle
Un service de production ne doit jamais bloquer indéfiniment. Le timeout global se met sur le contexte parent et se propage à toutes les goroutines via le contexte d’errgroup. L’annulation manuelle est utile dans un service HTTP : si le client coupe sa connexion, on annule le travail en cours plutôt que de le laisser tourner.
// cmd/dl/main.go
package main
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/votreorg/parallele/internal/fetch"
)
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
urls := []string{
"https://example.com/",
"https://www.iana.org/",
"https://httpbin.org/get",
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
go func() { <-sig; cancel() }()
debut := time.Now()
res, err := fetch.FetchErrGroup(ctx, urls, 4)
log.Info("termine", "duree_ms", time.Since(debut).Milliseconds(), "err", err)
for _, r := range res {
fmt.Printf("%s -> %d (%d octets) %v\n", r.URL, r.Statut, r.Octets, r.Erreur)
}
}
L’astuce du signal.Notify couplé à cancel() permet d’arrêter proprement le téléchargement quand vous appuyez sur Ctrl+C. Sans ce code, votre programme attendrait la fin du timeout de 30 secondes ou l’achèvement de tous les téléchargements. Lancez go run ./cmd/dl ; vous devez voir les trois lignes de résultats apparaître en moins de deux secondes pour des URLs réactives, et un log JSON final résumant la durée totale.
Étape 7 — Le piège du timer fuyant
Une goroutine de production a souvent une boucle infinie qui doit s’exécuter périodiquement. Le réflexe naïf est d’utiliser time.After dans un select, mais cela crée une fuite de mémoire dans une boucle longue. La forme correcte utilise time.NewTimer avec Reset, ou plus simplement time.NewTicker pour une cadence régulière.
// internal/fetch/heartbeat.go
package fetch
import (
"context"
"log/slog"
"time"
)
func Heartbeat(ctx context.Context, log *slog.Logger, intervalle time.Duration) {
t := time.NewTicker(intervalle)
defer t.Stop()
for {
select {
case <-ctx.Done():
log.Info("heartbeat arrete", "raison", ctx.Err())
return
case now := <-t.C:
log.Info("vivant", "heure", now.Format(time.RFC3339))
}
}
}
Trois règles d’or pour les goroutines périodiques. Toujours defer t.Stop() sur un ticker pour libérer le timer interne. Toujours écouter ctx.Done() en première branche du select pour permettre l’arrêt rapide. Toujours nommer la goroutine dans le log de sortie pour faciliter le diagnostic d’une fuite. Lancez le service avec go run -race pendant quelques minutes en background et vérifiez via curl http://localhost:6060/debug/pprof/goroutine?debug=1 que le nombre de goroutines reste stable. Une croissance linéaire signe une fuite à corriger immédiatement.
Étape 8 — Détecter les fuites de goroutines en test
Un test Go peut vérifier qu’une fonction n’a pas laissé de goroutine orpheline derrière elle. La bibliothèque go.uber.org/goleak automatise cette vérification. C’est un filet de sécurité utile pour les fonctions qui lancent des goroutines internes — vous attrapez les fuites au plus tôt, en CI, plutôt qu’en prod.
// internal/fetch/heartbeat_test.go
package fetch
import (
"context"
"io"
"log/slog"
"testing"
"time"
"go.uber.org/goleak"
)
func TestHeartbeat_AucunFuite(t *testing.T) {
defer goleak.VerifyNone(t)
log := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx, cancel := context.WithCancel(context.Background())
go Heartbeat(ctx, log, 50*time.Millisecond)
time.Sleep(120 * time.Millisecond)
cancel()
time.Sleep(50 * time.Millisecond)
}
Le defer goleak.VerifyNone(t) en début de test installe une vérification post-test : si après cancel() et un court délai d’apaisement, des goroutines lancées par le test sont encore vivantes, le test échoue avec un dump des stacks. Lancez go test -race ./internal/fetch/ ; le test doit passer en quelques centaines de millisecondes. Si vous oubliez le cancel(), vous verrez goleak signaler la goroutine fuyante avec sa pile complète.
Étape 9 — Mesurer l’effet de la concurrence
Le dernier réflexe à ancrer est de mesurer plutôt que de supposer. Un benchmark Go simple compare les trois variantes que vous avez écrites. La conclusion qui en sort est souvent contre-intuitive : au-delà d’un certain nombre de workers, la performance plafonne, voire diminue. Connaître ce point d’inflexion pour votre charge réelle est précieux.
// internal/fetch/bench_test.go
package fetch
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func benchURLs(n int, srv *httptest.Server) []string {
urls := make([]string, n)
for i := range urls { urls[i] = srv.URL }
return urls
}
func BenchmarkSequentiel(b *testing.B) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
defer srv.Close()
urls := benchURLs(50, srv)
b.ResetTimer()
for i := 0; i < b.N; i++ { _ = FetchSequentiel(context.Background(), urls) }
}
func BenchmarkPool8(b *testing.B) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
defer srv.Close()
urls := benchURLs(50, srv)
b.ResetTimer()
for i := 0; i < b.N; i++ { _ = FetchPool(context.Background(), urls, 8) }
}
Lancez go test -bench=. -benchmem ./internal/fetch/. Sur une machine de développement moderne, le pool à 8 workers tourne typiquement entre 5 et 7 fois plus vite que la version séquentielle pour 50 URLs locales. Au-delà de 16 workers contre un même serveur de test, le débit cesse de croître parce que la file d’attente côté serveur devient le facteur limitant. Cette pédagogie du benchmark est essentielle : elle vous apprend à choisir le nombre de workers en fonction du profil de la cible, pas d’une croyance.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
| Deadlock signalé par le runtime | Channel non-bufferisé sans lecteur, ou close() oublié |
Vérifier qu’il y a toujours un consommateur, et close côté producteur |
| Goroutines qui s’accumulent | Pas d’écoute de ctx.Done() dans la boucle |
Ajouter une branche dédiée dans le select |
| Variable de boucle partagée | Closure capturant une variable redéfinie à chaque itération avant Go 1.22 | Passer la valeur en argument à la goroutine |
| Trop de connexions ouvertes vers la cible | Pas de limite sur le pool de workers | Utiliser g.SetLimit() ou un channel borné |
| Erreur perdue dans le slice de résultats | Pattern WaitGroup sans propagation | Migrer vers errgroup avec contexte annulable |
Tutoriels frères
- Go pour microservices : la stack pratique 2026 — la vue d’ensemble du langage en production
- API REST en Go avec net/http — le complément naturel quand vos handlers parallélisent des appels en aval
Pour aller plus loin
- 🔝 Retour au guide principal : Go pour microservices : la stack pratique 2026
- Go Concurrency Patterns: Pipelines and cancellation — l’article fondateur sur le sujet par l’équipe Go
- Documentation errgroup — référence officielle
- go.uber.org/goleak — détecteur de fuites de goroutines en test
- Go Data Race Detector — comment lire un rapport
-race - Package sync — Mutex, WaitGroup, Once, Pool
FAQ
Quand utiliser un mutex plutôt qu’un channel ?
Le proverbe Go dit « partager la mémoire en communiquant », mais un mutex reste préférable pour protéger un accès concurrent à une variable simple (compteur, cache, état partagé). Le channel brille quand il y a un flux de données entre étapes ; le mutex brille quand il y a un état partagé.
Combien de goroutines peut-on lancer ?
Plusieurs centaines de milliers sur une machine moderne sans difficulté. La limite pratique est rarement le nombre de goroutines mais le nombre de connexions TCP ou de descripteurs de fichiers (vérifier avec ulimit -n).
Faut-il fermer tous les channels ?
Seul le producteur ferme un channel, et seulement quand on veut signaler aux consommateurs qu’il n’y aura plus de message. Si plusieurs producteurs écrivent sur un même channel, il faut un coordinateur (souvent un sync.WaitGroup qui ferme une fois que tous ont terminé).
Errgroup ou groupes manuels ?
Errgroup chaque fois qu’on veut « si une erreur, annuler tout ». Pour des cas où on veut collecter toutes les erreurs même partielles, un slice protégé par mutex avec WaitGroup classique reste plus adapté.
Les goroutines remplacent-elles les threads ?
Conceptuellement oui pour le code applicatif. Le runtime Go multiplexe les goroutines sur un nombre limité de threads OS (GOMAXPROCS). Vous écrivez du code en goroutines, le runtime le distribue sur les cœurs.
Comment déboguer un deadlock en prod ?
Si le service est figé, capturer un dump de goroutines avec curl http://service:port/debug/pprof/goroutine?debug=2. Chaque goroutine apparaît avec sa stack trace, ce qui permet d’identifier les channels qui bloquent et d’isoler le coupable en quelques minutes.