Le parallélisme en Go — Partie 2 : channels, select et worker pool

Les goroutines savent tourner en parallèle. Mais comment elles se passent des données ? Comment une goroutine dit à une autre "j'ai fini, voilà le résultat" ? C'est le rôle des channels. Sans eux, vous avez des goroutines qui font des trucs dans leur coin, et vous n'en récupérez jamais rien — ou pire, vous partagez de la mémoire avec un mutex et vous passez votre vie à déboguer des race conditions.

Cet article est la partie 2 d'une série sur le parallélisme en Go :

C'est quoi un channel ?

Un channel, c'est un tuyau entre deux goroutines. On envoie une valeur d'un côté, quelqu'un d'autre la reçoit de l'autre. La syntaxe est volontairement simple : ch <- valeur pour envoyer, valeur := <-ch pour recevoir.

ch := make(chan int)

go func() {
    ch <- 42 // envoie 42 dans le channel
}()

résultat := <-ch // reçoit (bloque jusqu'à ce que la goroutine envoie)
fmt.Println(résultat) // 42

Ce qui est important : la réception bloque. La ligne résultat := <-ch attend que quelqu'un envoie quelque chose. Ce n'est pas du polling, ce n'est pas du sleep — Go suspend la goroutine et la réveille quand une valeur arrive. Élégant et gratuit.

On peut typer les channels dans les signatures de fonctions pour indiquer leur sens : chan<- int en écriture seule, <-chan int en lecture seule. Le compilateur vous engueule si vous vous trompez. Utilisez ça dès que vous passez un channel à une fonction — ça documente l'intention.

Channel bufférisé vs non bufférisé

Par défaut, make(chan int) crée un channel non bufférisé. L'envoi bloque jusqu'à ce que quelqu'un soit prêt à recevoir — c'est un rendez-vous, comme un appel téléphonique : les deux parties doivent être disponibles en même temps.

Un channel bufférisé, c'est la boîte aux lettres. On dépose un message, on s'en va. Le destinataire le lira quand il pourra. L'envoi ne bloque que si la boîte est pleine :

ch := make(chan string, 3) // buffer de 3 messages
ch <- "message 1" // ne bloque pas
ch <- "message 2" // ne bloque pas
ch <- "message 3" // ne bloque pas
ch <- "message 4" // BLOQUE — buffer plein, personne ne lit

La règle simple : commencez avec un channel non bufférisé. Ajoutez un buffer si vous avez mesuré un goulot d'étranglement ou si le producteur génère des bursts que le consumer ne peut pas absorber instantanément. Ne mettez pas un buffer de 1000 "pour être sûr" — ça cache des bugs de logique.

Fermer un channel et range

close(ch) signale aux récepteurs que plus aucune valeur ne viendra. Ça débloque tous ceux qui attendaient sur ce channel. La boucle for val := range ch lit les valeurs jusqu'à ce que le channel soit fermé et vide — c'est la façon idiomatique de consommer un channel :

ch := make(chan int)

go func() {
    defer close(ch) // toujours fermer avec defer
    for i := 0; i < 5; i++ {
        ch <- i * i
    }
}()

for carré := range ch { // lit jusqu'à close(ch)
    fmt.Println(carré) // 0, 1, 4, 9, 16
}

La règle absolue : c'est le producteur qui ferme, jamais le consumer. Écrire dans un channel fermé provoque une panic. Fermer deux fois aussi. Lire dans un channel fermé retourne la valeur zéro — détectez-le avec val, ok := <-chok vaut false si fermé et vide.

select — écouter plusieurs channels à la fois

select, c'est un switch pour les channels. Il bloque jusqu'à ce qu'un des cas soit prêt, puis l'exécute. Si plusieurs cas sont prêts en même temps, il en choisit un au hasard — comportement intentionnel pour éviter la famine.

select {
case résultat := <-ch1:
    fmt.Println("ch1 a répondu :", résultat)
case résultat := <-ch2:
    fmt.Println("ch2 a répondu :", résultat)
case <-time.After(2 * time.Second):
    fmt.Println("timeout — personne n'a répondu à temps")
}

time.After(d) retourne un channel qui reçoit une valeur après la durée spécifiée. Combiné à select, c'est le pattern de timeout standard en Go. En prod, on préférera context.WithTimeout (partie 3), mais pour du code simple c'est parfait.

Avec un default, select ne bloque plus — il tente les cases et si aucun n'est prêt, exécute default. Utile pour du polling non bloquant :

select {
case msg := <-ch:
    traiter(msg)
default:
    // personne n'envoie rien, on continue sans bloquer
}

Fan-out et Fan-in

Ce sont les deux patterns fondamentaux pour distribuer et collecter du travail parallèle.

Fan-out : un producteur alimente plusieurs consumers. On distribue le travail pour le traiter en parallèle.
Fan-in : plusieurs producteurs écrivent dans un seul channel. On collecte les résultats de traitements parallèles dans un flux unique.

func scrapeURLs(urls []string, nbWorkers int) []string {
    jobs := make(chan string, len(urls))
    résultats := make(chan string, len(urls))

    // Fan-out : nbWorkers goroutines lisent dans jobs
    var wg sync.WaitGroup
    for range nbWorkers {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for url := range jobs {
                contenu, err := fetch(url)
                if err != nil {
                    continue
                }
                résultats <- contenu // Fan-in : tous écrivent dans le même channel
            }
        }()
    }

    // Envoyer le travail
    for _, url := range urls {
        jobs <- url
    }
    close(jobs) // signale aux workers qu'il n'y a plus de travail

    // Fermer résultats quand tous les workers ont fini
    go func() {
        wg.Wait()
        close(résultats)
    }()

    // Collecter
    var contenus []string
    for contenu := range résultats {
        contenus = append(contenus, contenu)
    }
    return contenus
}

La goroutine qui ferme résultats est nécessaire : on ne peut pas appeler wg.Wait() et lire résultats dans la même goroutine — les workers bloqueraient sur l'écriture si le buffer est plein, et on ne lirait jamais. Deadlock garanti. La goroutine intermédiaire casse ce cycle.

Worker Pool — le pattern le plus utile en prod

Go peut lancer un million de goroutines. Mais si vous lancez 10 000 goroutines qui font chacune une requête HTTP, vous allez saturer votre pool de connexions, épuiser les file descriptors, et le serveur distant va blacklister votre IP.

Le worker pool règle ça : N goroutines fixes piochent dans une file de travail. Le nombre de tâches peut être illimité, le nombre d'opérations concurrentes reste contrôlé.

type Job struct {
    ID     int
    Chemin string
}

type Résultat struct {
    JobID int
    Err   error
}

func workerPool(nbWorkers int, jobs <-chan Job, résultats chan<- Résultat) {
    var wg sync.WaitGroup

    for range nbWorkers {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs { // chaque worker prend un job à la fois
                err := redimensionnerImage(job.Chemin)
                résultats <- Résultat{JobID: job.ID, Err: err}
            }
        }()
    }

    wg.Wait()
    close(résultats)
}

func main() {
    images := chargerListeImages() // 100 images

    jobs := make(chan Job, len(images))
    résultats := make(chan Résultat, len(images))

    go workerPool(5, jobs, résultats) // 5 workers maximum

    for i, img := range images {
        jobs <- Job{ID: i, Chemin: img}
    }
    close(jobs)

    succès, échecs := 0, 0
    for res := range résultats {
        if res.Err != nil {
            slog.Error("échec image", "job_id", res.JobID, "error", res.Err)
            échecs++
        } else {
            succès++
        }
    }
    slog.Info("terminé", "succès", succès, "échecs", échecs)
}

5 workers, 100 images, 0 saturation. Si vous avez un pool de 5 connexions DB, mettez 5 workers — la charge est naturellement limitée.

Les erreurs classiques avec les channels

Deadlock. Deux goroutines qui s'attendent mutuellement sur un channel. Go le détecte et affiche all goroutines are asleep - deadlock!. Cause fréquente : oublier de fermer un channel dont quelqu'un attend la fermeture avec range.

Channel nil. Un channel non initialisé vaut nil. Envoyer ou recevoir sur un channel nil bloque forever, sans erreur, sans panic. Votre goroutine disparaît en silence. Toujours initialiser avec make.

var ch chan int     // nil — DANGER
ch <- 42           // bloque forever, goroutine leak silencieux

ch = make(chan int) // OK

Fermer deux fois. Panic immédiate. Si vous avez plusieurs producteurs, coordonnez-les avec un sync.WaitGroup pour n'appeler close qu'une seule fois.

Récapitulatif

  • Channel non bufférisé = rendez-vous. Par défaut.
  • Channel bufférisé = boîte aux lettres. L'envoi ne bloque que si le buffer est plein.
  • Le producteur ferme le channel, jamais le consumer.
  • for val := range ch lit jusqu'à fermeture.
  • select écoute plusieurs channels. Timeout avec time.After.
  • Worker pool = N workers fixes. Contrôle la concurrence réelle.
  • Channel nil = goroutine leak silencieux. Toujours make.

La partie 3 aborde ce qu'on fait quand le programme doit s'arrêter proprement : context pour l'annulation, errgroup pour propager les erreurs, graceful shutdown sur SIGTERM. Les channels que vous venez d'apprendre sont les fondations — la partie 3 construit dessus.

Commentaires (0)