ITSkillsCenter
Développement Web

Goroutines et channels en pratique : concurrence Go pas-à-pas

14 دقائق للقراءة

📍 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

Pour aller plus loin

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.

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité