
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.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- 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, c'est quoi exactement ?
Section intitulée « Un channel, c'est quoi exactement ? »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êtmain 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 <- 1ch <- 2fmt.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=3reçu: 1après 1 réception : len=1 cap=3len(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 ?
| Situation | Choix | Pourquoi |
|---|---|---|
| Garantir que l'autre goroutine a bien reçu | Non bufferisé | Le rendez-vous est la garantie |
| Signaler un arrêt, une fin, un événement | Non bufferisé ou close | Aucune donnée à stocker |
| Absorber des rafales de production | Bufferisé, capacité mesurée | Évite de bloquer le producteur |
| Éviter une fuite quand le receveur peut abandonner | Bufferisé de 1 | L'envoi passe même sans lecteur |
| Limiter le nombre de tâches simultanées | Bufferisé, capacité = limite | La 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.
Que fait un channel selon son état ?
Section intitulée « Que fait un channel selon son état ? »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 channel | Envoi ch <- v | Réception <-ch | close(ch) |
|---|---|---|---|
nil (déclaré, jamais make) | Bloque pour toujours | Bloque pour toujours | Panique : close of nil channel |
| Ouvert, vide, sans lecteur | Bloque | Bloque | Ferme |
| Ouvert, bufferisé plein | Bloque | Retourne une valeur | Ferme |
| Fermé, vide | Panique : send on closed channel | Retourne immédiatement la valeur zéro, ok=false | Panique : close of closed channel |
| Fermé, contenant encore des valeurs | Panique | Retourne les valeurs restantes, ok=true | Panique |
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.
À quoi sert la fermeture d'un channel ?
Section intitulée « À quoi sert la fermeture d'un channel ? »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 <- 10ch <- 20close(ch)
v1, ok1 := <-chfmt.Printf("v=%d ok=%t\n", v1, ok1)v2, ok2 := <-chfmt.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=truev=20 ok=truev=0 ok=falseLes 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.WaitGroupet on ferme après leWait.
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.
Pourquoi déclarer la direction d'un channel ?
Section intitulée « Pourquoi déclarer la direction d'un channel ? »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 cacheNotez 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éeLe 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.
time.After(d) retourne un channel qui délivre une valeur après la durée d. En cas de select, il devient une limite de temps sur n'importe quelle attente, sans bibliothèque ni thread supplémentaire.
func appelLent() <-chan string { ch := make(chan string, 1) // bufferisé : l'envoi n'est jamais bloqué go func() { time.Sleep(2 * time.Second) ch <- "réponse tardive" }() return ch}
select {case reponse := <-appelLent(): fmt.Println("reçu:", reponse)case <-time.After(500 * time.Millisecond): fmt.Println("timeout après 500 ms : on abandonne")}Résultat :
timeout après 500 ms : on abandonneLe channel de retour est bufferisé à 1 délibérément. Sans ce buffer, la goroutine lente resterait bloquée pour toujours sur son envoi puisque plus personne n'écoute : ce serait une fuite. Pour une annulation qui se propage à travers plusieurs appels, préférez context.Context, qui est bâti sur ce mécanisme.
À quoi servent vraiment les channels ?
Section intitulée « À quoi servent vraiment les channels ? »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.
Signaler l'arrêt à plusieurs goroutines
Section intitulée « Signaler l'arrêt à plusieurs goroutines »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 workerswg.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ésC'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.
Enchaîner des étapes : le pipeline
Section intitulée « Enchaîner des étapes : le pipeline »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 5432alerte: ERROR timeout upstreamDeux 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.
Rassembler plusieurs sources : le fan-in
Section intitulée « Rassembler plusieurs sources : le fan-in »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-1reçu: bdd-1reçu: web-2reçu: web-3reçu: bdd-2Toute 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.
Limiter la concurrence : le sémaphore
Section intitulée « Limiter la concurrence : le sémaphore »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 mstâche 5 terminée à 100 mstâche 2 terminée à 201 mstâche 6 terminée à 201 mstâche 4 terminée à 301 mstâche 3 terminée à 301 ms6 tâches, 2 en parallèle, durée totale 301 msSix 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.
Quand faut-il éviter les channels ?
Section intitulée « Quand faut-il éviter les channels ? »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.
| Besoin | Bon outil | Pourquoi pas un channel |
|---|---|---|
| Protéger une variable partagée | sync.Mutex | Un channel ajoute une goroutine et de la latence |
| Attendre la fin de N tâches | sync.WaitGroup | Compter des fins n'est pas transporter des valeurs |
| Initialiser une ressource une fois | sync.Once | Cas prévu par la bibliothèque standard |
| Incrémenter un compteur | sync/atomic | Une instruction processeur suffit |
| Annuler une arborescence d'appels | context.Context | Il 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.
Dépannage : symptôme, cause, solution
Section intitulée « Dépannage : symptôme, cause, solution »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ôme | Cause probable | Solution |
|---|---|---|
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 channel | Plusieurs émetteurs, l'un ferme pendant que l'autre écrit | Fermer après wg.Wait(), dans une goroutine dédiée |
panic: close of closed channel | Deux close sur le même channel | Un seul propriétaire ferme, ou utiliser sync.Once |
| Le programme se fige sans message | Channel nil (pas de make), ou une seule goroutine bloquée | Vérifier le make ; le détecteur ne voit pas les blocages partiels |
| La mémoire monte sans plainte | Fuite : goroutines bloquées sur un envoi que plus personne ne lit | Bufferiser à 1, ou passer un context d'annulation |
| Un cœur à 100 % sans travail | select avec default dans une boucle sans pause | Retirer 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.
Exercice : agréger deux services avec un timeout
Section intitulée « Exercice : agréger deux services avec un timeout »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.
La boucle compte les réponses jusqu'à en avoir deux, ou jusqu'au déclenchement du timeout.
package main
import ( "fmt" "time")
func interroger(nom string, latence time.Duration, resultats chan<- string) { time.Sleep(latence) resultats <- fmt.Sprintf("%s: ok", nom)}
func main() { // Bufferisé à 2 : les envois passent même après le timeout, // donc aucune goroutine ne reste bloquée. resultats := make(chan string, 2)
go interroger("facturation", 80*time.Millisecond, resultats) go interroger("stock", 400*time.Millisecond, resultats)
delai := time.After(200 * time.Millisecond) recus := 0
for recus < 2 { select { case reponse := <-resultats: fmt.Println("reçu:", reponse) recus++ case <-delai: fmt.Printf("timeout : %d service(s) hors délai\n", 2-recus) return } }}Sortie :
reçu: facturation: oktimeout : 1 service(s) hors délaiLe point clé est le buffer de 2. Avec un channel non bufferisé, la goroutine « stock » resterait bloquée pour toujours sur son envoi après le retour de main, puisque plus personne ne lit : une fuite parfaitement silencieuse. Le second point est que time.After est appelé une seule fois, avant la boucle. L'écrire dans le select créerait un nouveau délai de 200 ms à chaque tour, et le timeout ne se déclencherait jamais.
À retenir
Section intitulée « À retenir »- 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 ;closepanique. C'est le piège le plus silencieux. - La réception ne panique jamais ; seuls l'envoi sur un channel fermé et le double
closefont tomber le programme. closeest une diffusion : il réveille tous les receveurs d'un coup, d'oùchan struct{}pour signaler un arrêt.selectattend sur plusieurs channels ;defaultle rend non bloquant,time.Afterlui 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.