Aller au contenu
Développement medium

Les channels en Go : comprendre et bien les utiliser

29 min de lecture

Go

Un channel est une file d'attente typée qui sert de point de rendez-vous entre goroutines : celle qui envoie et celle qui reçoit se synchronisent au passage de la valeur. C'est ce blocage mutuel, et non le transport de données, qui explique tout le reste. Ce guide répond à deux questions : comment un channel se comporte dans chaque situation (vide, plein, fermé, nil), et ce qu'il permet de construire concrètement, à savoir un arrêt propre, un pipeline, un rassemblement de sources et une limitation de concurrence. Public visé : vous savez lancer une goroutine. Exemples testés sur Go 1.26.

  • Prédire si une opération bloque, panique ou passe, dans chaque état du channel.
  • Choisir entre un channel non bufferisé et un channel bufferisé.
  • Attendre sur plusieurs channels avec select, avec un timeout.
  • Construire les quatre motifs qui couvrent la majorité des besoins réels.

Un channel est une file d'attente typée partagée par plusieurs goroutines. On le crée avec make(chan T), on y dépose une valeur avec ch <- valeur et on la retire avec valeur := <-ch. La flèche pointe toujours dans le sens du déplacement de la donnée, ce qui rend le code lisible sans commentaire.

Là où l'intuition se trompe, c'est sur le blocage. Un channel créé par make(chan T), sans second argument, est non bufferisé : il n'a aucune place de stockage. L'envoi ne dépose donc rien, il attend qu'un receveur se présente, et la réception attend symétriquement un émetteur. Les deux goroutines se donnent rendez-vous et repartent ensemble. C'est cette propriété qui remplace les verrous : quand main reçoit une valeur, il a la garantie que la goroutine émettrice a bien atteint sa ligne d'envoi.

package main
import (
"fmt"
"time"
)
func main() {
debut := time.Now()
ch := make(chan string)
go func() {
time.Sleep(300 * time.Millisecond)
ch <- "café prêt"
}()
fmt.Printf("[%.0f ms] j'attends sur le channel\n", time.Since(debut).Seconds()*1000)
message := <-ch
fmt.Printf("[%.0f ms] reçu: %s\n", time.Since(debut).Seconds()*1000, message)
}

L'horodatage rend le rendez-vous visible :

[0 ms] j'attends sur le channel
[301 ms] reçu: café prêt

main n'a pas fait de sondage ni de boucle d'attente : il a dormi gratuitement pendant 301 ms, réveillé par l'ordonnanceur Go à l'instant précis de l'envoi. Retenez cette formule : un channel non bufferisé transporte une valeur et une information temporelle, celle du moment où l'autre goroutine en est arrivée là.

Channel bufferisé ou non bufferisé : lequel choisir ?

Section intitulée « Channel bufferisé ou non bufferisé : lequel choisir ? »

Le second argument de make donne au channel une capacité de stockage : make(chan int, 3) accepte trois valeurs sans lecteur avant de bloquer. L'émetteur ne s'arrête donc plus à chaque envoi, il continue tant que la file n'est pas pleine. La synchronisation stricte disparaît, ce qui est parfois exactement ce qu'on veut, et parfois le début des ennuis.

ch := make(chan int, 3) // capacité 3
ch <- 1
ch <- 2
fmt.Printf("après 2 envois : len=%d cap=%d\n", len(ch), cap(ch))
fmt.Println("reçu:", <-ch)
fmt.Printf("après 1 réception : len=%d cap=%d\n", len(ch), cap(ch))

Sortie :

après 2 envois : len=2 cap=3
reçu: 1
après 1 réception : len=1 cap=3

len(ch) donne le nombre de valeurs en attente et cap(ch) la capacité totale. Sur un channel non bufferisé, les deux valent 0 : il n'y a littéralement aucune place où poser quoi que ce soit. Le tableau ci-dessous résume le critère de choix, qui tient en une question : voulez-vous que l'émetteur attende le receveur ?

SituationChoixPourquoi
Garantir que l'autre goroutine a bien reçuNon bufferiséLe rendez-vous est la garantie
Signaler un arrêt, une fin, un événementNon bufferisé ou closeAucune donnée à stocker
Absorber des rafales de productionBufferisé, capacité mesuréeÉvite de bloquer le producteur
Éviter une fuite quand le receveur peut abandonnerBufferisé de 1L'envoi passe même sans lecteur
Limiter le nombre de tâches simultanéesBufferisé, capacité = limiteLa capacité devient un quota

Un piège classique consiste à augmenter la capacité pour faire disparaître un deadlock. Cela ne corrige rien, cela déplace le blocage un peu plus loin, jusqu'à ce que la file se remplisse en production. La capacité doit correspondre à une réalité mesurée, par exemple la taille d'une rafale d'événements, pas servir de rustine.

Voici le tableau qui débloque la compréhension. La plupart des bugs de concurrence viennent d'une case mal anticipée. Chaque ligne a été vérifiée par exécution sur Go 1.26.5, avec un délai de 200 ms au-delà duquel l'opération est déclarée bloquante.

État du channelEnvoi ch <- vRéception <-chclose(ch)
nil (déclaré, jamais make)Bloque pour toujoursBloque pour toujoursPanique : close of nil channel
Ouvert, vide, sans lecteurBloqueBloqueFerme
Ouvert, bufferisé pleinBloqueRetourne une valeurFerme
Fermé, videPanique : send on closed channelRetourne immédiatement la valeur zéro, ok=falsePanique : close of closed channel
Fermé, contenant encore des valeursPaniqueRetourne les valeurs restantes, ok=truePanique

Trois conséquences pratiques découlent de ce tableau. D'abord, un nil channel bloque au lieu de paniquer, ce qui est le comportement le plus déroutant de Go pour un débutant : une variable oubliée sans make ne provoque pas d'erreur claire, elle fige la goroutine en silence. Ensuite, la réception ne panique jamais, quel que soit l'état ; seuls l'envoi et close peuvent faire tomber le programme. Enfin, un channel fermé devient une source infinie de valeurs zéro : un for mal écrit sur un channel fermé tourne en boucle sans jamais bloquer, et sature un cœur.

Fermer un channel avec close(ch) ne libère aucune ressource : c'est un message diffusé à tous les receveurs, signifiant qu'aucune nouvelle valeur n'arrivera. C'est le seul mécanisme de Go pour prévenir un nombre quelconque de goroutines en une seule opération, ce qui en fait bien plus qu'un simple nettoyage.

Deux formes de réception exploitent ce signal. La première, valeur, ok := <-ch, met ok à false quand le channel est fermé et vidé. La seconde, for valeur := range ch, s'arrête toute seule à la fermeture, ce qui en fait la manière idiomatique de consommer un channel.

ch := make(chan int, 2)
ch <- 10
ch <- 20
close(ch)
v1, ok1 := <-ch
fmt.Printf("v=%d ok=%t\n", v1, ok1)
v2, ok2 := <-ch
fmt.Printf("v=%d ok=%t\n", v2, ok2)
v3, ok3 := <-ch // channel vidé ET fermé
fmt.Printf("v=%d ok=%t\n", v3, ok3)

Sortie :

v=10 ok=true
v=20 ok=true
v=0 ok=false

Les valeurs déjà déposées restent lisibles après la fermeture : close interdit d'écrire, pas de finir de lire. La troisième réception, elle, rend 0 avec ok=false sans bloquer. Deux règles évitent les paniques du tableau précédent :

  • Seul l'émetteur ferme, jamais le receveur. Le receveur ne peut pas savoir si un autre envoi est en route.
  • Un seul émetteur ferme. Avec plusieurs émetteurs, on les attend avec une sync.WaitGroup et on ferme après le Wait.

Un for range sur un channel jamais fermé attend indéfiniment une valeur qui ne viendra pas : oublier close est la cause numéro un des boucles de consommation qui ne rendent jamais la main.

Dans une signature de fonction, un channel peut être restreint à un seul sens : chan<- int n'autorise que l'envoi, <-chan int que la réception. Ce n'est pas une coquetterie de style, c'est un contrôle appliqué à la compilation qui documente le rôle de chaque fonction et interdit qu'un consommateur ferme un channel qu'il ne produit pas.

func consommer(ch <-chan int) {
ch <- 1 // ❌ interdit : ch est en réception seule
}

Le compilateur refuse le programme avec un message sans ambiguïté :

invalid operation: cannot send to receive-only channel <-chan int ch (variable of type <-chan int)

La conversion est automatique et à sens unique : un chan int complet se passe à une fonction attendant <-chan int, l'inverse est impossible. En pratique, une fonction qui produit retourne <-chan T, ce qui garantit à l'appelant qu'il ne peut que lire. Vous verrez cette signature dans le pipeline et le fan-in plus bas ; c'est la marque des API de concurrence bien conçues en Go.

select : comment attendre sur plusieurs channels à la fois

Section intitulée « select : comment attendre sur plusieurs channels à la fois »

L'instruction select attend simultanément sur plusieurs channels et exécute le cas du premier qui devient prêt. Sans elle, un channel ne sait faire qu'une chose à la fois, et tout ce qui suit (timeout, arrêt propre, multiplexage) serait impossible. Si plusieurs cas sont prêts en même temps, Go en choisit un au hasard, ce qui évite qu'un channel très actif affame les autres.

api := make(chan string)
cache := make(chan string)
go func() {
time.Sleep(200 * time.Millisecond)
api <- "réponse de l'API"
}()
go func() {
time.Sleep(20 * time.Millisecond)
cache <- "réponse du cache"
}()
select {
case reponse := <-api:
fmt.Println("gagnant:", reponse)
case reponse := <-cache:
fmt.Println("gagnant:", reponse)
}

Le cache répond en 20 ms, il gagne la course :

gagnant: réponse du cache

Notez que select traite un seul événement puis rend la main. Pour en traiter plusieurs, il faut l'englober dans une boucle for, structure omniprésente dans les serveurs Go. Deux variantes changent radicalement son comportement.

Ajouter une clause default rend le select non bloquant : si aucun channel n'est prêt sur le champ, default s'exécute immédiatement. C'est le moyen de tenter un envoi sans risquer de figer l'appelant, typiquement pour une métrique qu'on préfère perdre plutôt que ralentir le traitement.

metriques := make(chan string, 1)
for i := 1; i <= 2; i++ {
select {
case metriques <- fmt.Sprintf("cpu=%d", i):
fmt.Printf("envoi %d : accepté\n", i)
default:
fmt.Printf("envoi %d : file pleine, métrique abandonnée\n", i)
}
}

Le buffer de 1 accepte le premier envoi et refuse le second :

envoi 1 : accepté
envoi 2 : file pleine, métrique abandonnée

Le piège : un select avec default dans une boucle sans pause consomme 100 % d'un cœur à tourner à vide. Le default sert à abandonner une opération, pas à sonder en continu.

Quatre motifs couvrent la grande majorité des usages réels. Ils se combinent et se déclinent, mais leur logique reste celle du rendez-vous vue plus haut. Les voici du plus simple au plus structurant.

Un channel de type chan struct{} ne transporte aucune donnée : struct{} est un type vide qui occupe zéro octet. Seul compte l'événement, et c'est close qui le diffuse. Puisqu'une réception sur un channel fermé retourne immédiatement, fermer réveille tous les receveurs d'un coup, quel que soit leur nombre.

arret := make(chan struct{})
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for {
select {
case <-arret:
fmt.Printf("worker %d : arrêt demandé\n", id)
return
default:
time.Sleep(50 * time.Millisecond)
}
}
}(i)
}
time.Sleep(150 * time.Millisecond)
close(arret) // un seul close réveille les trois workers
wg.Wait()
fmt.Println("tous les workers sont arrêtés")

Les trois workers sortent, dans un ordre qui varie d'une exécution à l'autre :

worker 3 : arrêt demandé
worker 2 : arrêt demandé
worker 1 : arrêt demandé
tous les workers sont arrêtés

C'est la base de tout arrêt propre sur un signal système. Envoyer trois valeurs aurait exigé de connaître le nombre de workers ; close s'en dispense, et c'est précisément pour cela qu'on ferme au lieu d'envoyer.

Un pipeline chaîne des étapes où chacune lit sur un channel d'entrée et écrit sur un channel de sortie. Chaque étape tourne dans sa propre goroutine et démarre son travail dès la première valeur reçue, sans attendre que la précédente ait fini son lot. Sur un flux long ou infini, comme un journal applicatif, la mémoire consommée reste constante.

// Étape 1 : produit les lignes du journal, une par une.
func produire(lignes []string) <-chan string {
sortie := make(chan string)
go func() {
defer close(sortie)
for _, ligne := range lignes {
sortie <- ligne
}
}()
return sortie
}
// Étape 2 : ne laisse passer que les lignes d'erreur.
func filtrerErreurs(entree <-chan string) <-chan string {
sortie := make(chan string)
go func() {
defer close(sortie)
for ligne := range entree {
if strings.HasPrefix(ligne, "ERROR") {
sortie <- ligne
}
}
}()
return sortie
}
func main() {
journal := []string{
"INFO démarrage du service",
"ERROR connexion refusée sur 5432",
"INFO requête traitée",
"ERROR timeout upstream",
}
for erreur := range filtrerErreurs(produire(journal)) {
fmt.Println("alerte:", erreur)
}
}

Sortie :

alerte: ERROR connexion refusée sur 5432
alerte: ERROR timeout upstream

Deux conventions rendent ce montage fiable. Chaque étape retourne <-chan T, donc l'appelant ne peut que lire. Et chaque étape ferme sa propre sortie avec defer close(sortie), ce qui propage la fin en cascade jusqu'au dernier range. Ajouter une étape revient à intercaler une fonction, sans toucher aux autres.

Le fan-in est l'opération inverse : plusieurs channels sont fusionnés en un seul, que le consommateur lit sans savoir d'où vient chaque valeur. C'est le motif d'un collecteur d'événements alimenté par des sources aux cadences différentes.

func fusionner(entrees ...<-chan string) <-chan string {
sortie := make(chan string)
var wg sync.WaitGroup
for _, entree := range entrees {
wg.Add(1)
go func(c <-chan string) {
defer wg.Done()
for v := range c {
sortie <- v
}
}(entree)
}
go func() {
wg.Wait()
close(sortie) // fermé quand toutes les sources sont taries
}()
return sortie
}

Avec une source « web » toutes les 30 ms et une source « bdd » toutes les 50 ms, les valeurs arrivent entrelacées selon leur cadence, pas source par source :

reçu: web-1
reçu: bdd-1
reçu: web-2
reçu: web-3
reçu: bdd-2

Toute la subtilité tient dans la goroutine de fermeture. Comme plusieurs émetteurs écrivent dans sortie, aucun ne peut la fermer seul sans risquer la panique send on closed channel. On délègue donc la fermeture à une goroutine dédiée qui attend le wg.Wait(), et on ne peut pas l'appeler directement dans fusionner sous peine de bloquer avant même le premier retour.

Un channel bufferisé fait un sémaphore en trois lignes : sa capacité devient le nombre de jetons disponibles. Prendre un jeton, c'est envoyer dans le channel, opération qui bloque quand tous sont pris ; le rendre, c'est recevoir. Indispensable dès qu'on lance des milliers de goroutines contre une API ou une base de données qui ne suivra pas.

// 2 jetons : au plus 2 téléchargements simultanés.
jetons := make(chan struct{}, 2)
var wg sync.WaitGroup
debut := time.Now()
for i := 1; i <= 6; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
jetons <- struct{}{} // prend un jeton, bloque si les 2 sont pris
defer func() { <-jetons }() // rend le jeton en sortant
time.Sleep(100 * time.Millisecond) // le travail réel
fmt.Printf("tâche %d terminée à %.0f ms\n", id, time.Since(debut).Seconds()*1000)
}(i)
}
wg.Wait()
fmt.Printf("6 tâches, 2 en parallèle, durée totale %.0f ms\n", time.Since(debut).Seconds()*1000)

Les mesures montrent trois vagues de deux tâches :

tâche 1 terminée à 100 ms
tâche 5 terminée à 100 ms
tâche 2 terminée à 201 ms
tâche 6 terminée à 201 ms
tâche 4 terminée à 301 ms
tâche 3 terminée à 301 ms
6 tâches, 2 en parallèle, durée totale 301 ms

Six tâches de 100 ms prennent 301 ms au lieu de 600 ms en séquentiel, sans jamais dépasser deux appels simultanés. Le defer qui rend le jeton est non négociable : si la fonction sort par une erreur ou une panique sans le rendre, le quota diminue à chaque passage jusqu'au blocage complet.

Un channel n'est pas la bonne réponse à tout, et la doc Go elle-même le rappelle dans son guide de style : le mémo de la communauté recommande le verrou pour l'état partagé. Protéger un compteur ou une map partagée relève de sync.Mutex, pas d'un channel : la version channel est plus lente et bien plus verbeuse pour un simple incrément.

BesoinBon outilPourquoi pas un channel
Protéger une variable partagéesync.MutexUn channel ajoute une goroutine et de la latence
Attendre la fin de N tâchessync.WaitGroupCompter des fins n'est pas transporter des valeurs
Initialiser une ressource une foissync.OnceCas prévu par la bibliothèque standard
Incrémenter un compteursync/atomicUne instruction processeur suffit
Annuler une arborescence d'appelscontext.ContextIl propage l'annulation à travers les couches

Le critère de tri est simple : un channel sert à transférer la propriété d'une donnée d'une goroutine à une autre, ou à signaler un événement. Dès qu'il s'agit de protéger une donnée qui reste au même endroit, le verrou est le bon outil. Ces mécanismes sont détaillés dans le guide sur la synchronisation.

Les blocages de channels produisent peu de messages d'erreur, ce qui les rend difficiles à diagnostiquer sans grille de lecture. Ce tableau associe chaque symptôme à sa cause habituelle et à la correction correspondante.

SymptômeCause probableSolution
all goroutines are asleep - deadlock!Envoi sans receveur, ou range sur un channel jamais ferméAjouter le close, ou lancer le consommateur avant l'envoi
panic: send on closed channelPlusieurs émetteurs, l'un ferme pendant que l'autre écritFermer après wg.Wait(), dans une goroutine dédiée
panic: close of closed channelDeux close sur le même channelUn seul propriétaire ferme, ou utiliser sync.Once
Le programme se fige sans messageChannel nil (pas de make), ou une seule goroutine bloquéeVérifier le make ; le détecteur ne voit pas les blocages partiels
La mémoire monte sans plainteFuite : goroutines bloquées sur un envoi que plus personne ne litBufferiser à 1, ou passer un context d'annulation
Un cœur à 100 % sans travailselect avec default dans une boucle sans pauseRetirer le default et laisser le select bloquer

Le cas le plus sournois est la fuite de goroutine, invisible parce que le programme continue de fonctionner. runtime.NumGoroutine() la révèle en une ligne : cent appels à une fonction dont on ignore le résultat font passer le compteur de 1 à 101 goroutines, définitivement bloquées sur leur envoi. En production, on surveille cette valeur comme une métrique.

Cet exercice combine select, timeout et fermeture. Cherchez la solution avant d'ouvrir la réponse.

Écrivez une fonction qui interroge deux services en parallèle, un service de facturation qui répond en 80 ms et un service de stock qui répond en 400 ms, puis :

  • rassemble les deux réponses dans un seul channel ;
  • abandonne au bout de 200 ms au lieu d'attendre le plus lent ;
  • affiche les réponses obtenues et le nombre de services qui n'ont pas répondu à temps.

Le résultat attendu est 1 réponse reçue et 1 service hors délai.

Indice : utilisez un channel bufferisé à 2 pour que les goroutines lentes ne fuient pas, et une boucle for avec select sur le channel et sur time.After.

  • Un channel non bufferisé est un rendez-vous : l'envoi attend la réception, ce qui transmet une valeur et une garantie temporelle.
  • La capacité d'un channel bufferisé doit refléter une rafale mesurée, jamais servir à masquer un deadlock.
  • Sur un channel nil, envoi et réception bloquent pour toujours ; close panique. C'est le piège le plus silencieux.
  • La réception ne panique jamais ; seuls l'envoi sur un channel fermé et le double close font tomber le programme.
  • close est une diffusion : il réveille tous les receveurs d'un coup, d'où chan struct{} pour signaler un arrêt.
  • select attend sur plusieurs channels ; default le rend non bloquant, time.After lui donne un timeout.
  • Les quatre motifs utiles sont l'arrêt par close, le pipeline, le fan-in et le sémaphore par capacité.
  • Pour protéger une donnée partagée, le verrou reste préférable au channel, qui sert à transférer ou à signaler.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn