Goroutine et channel : fondamentaux de la concurrence en Go

1. Présentation générale

Go a été conçu pour écrire des programmes concurrents de manière simple, sans callback hell ni thread pool complexe. Les deux éléments constitutifs fondamentaux sont goroutine (unité d’exécution légère) et channel (canal de communication entre goroutines). Après ce guide, vous saurez comment lancer des goroutines, les synchroniser avec sync.WaitGroup, communiquer de manière sûre via des channels, utiliser select pour traiter plusieurs channels à la fois, et éviter les erreurs courantes comme les fuites de goroutines ou les deadlocks.

Tous les exemples utilisent Go 1.22+, sans dépendances externes — vous n’avez besoin que de go run main.go pour les exécuter.

2. Qu’est-ce qu’une goroutine ?

Une goroutine est une fonction qui s’exécute indépendamment, gérée par le runtime Go plutôt que par le système d’exploitation. Le coût de création d’une goroutine est très faible (quelques KB de stack, automatiquement flexible), ce qui vous permet de créer des dizaines de milliers de goroutines sans craindre d’épuiser les ressources comme c’est le cas avec les threads système.

package main

import (
	"fmt"
	"time"
)

func sayHello(name string) {
	fmt.Println("Bonjour,", name)
}

func main() {
	go sayHello("Order Service")
	go sayHello("Payment Service")

	time.Sleep(100 * time.Millisecond) // attendre la fin des goroutines
	fmt.Println("Main terminé")
}

Le mot-clé go devant un appel de fonction va exécuter cette fonction dans une nouvelle goroutine, sans bloquer main. Puisque main peut se terminer avant que les deux autres goroutines n’aient eu le temps d’afficher, nous devons time.Sleep temporairement pour attendre — c’est une approche peu fiable, utilisée uniquement à titre d’illustration. La sortie pourrait être :

Bonjour, Payment Service
Bonjour, Order Service
Main terminé

L’ordre d’affichage n’est pas garanti car c’est le scheduler qui décide quelle goroutine s’exécute en premier. C’est précisément la nature de la concurrence : plusieurs tâches se déroulent « simultanément » mais sans garantie d’ordre.

Attention : Utiliser time.Sleep pour synchroniser les goroutines dans le code réel est un anti-pattern. La section 4 présentera sync.WaitGroup pour le remplacer.

3. Channel : canal de communication entre goroutines

Un channel est un tuyau type-safe pour que les goroutines envoient et reçoivent des données. La philosophie de Go est : « Don’t communicate by sharing memory; share memory by communicating. »

package main

import "fmt"

func calculateTotal(amount float64, result chan<- float64) {
	tax := amount * 0.1
	result <- amount + tax
}

func main() {
	result := make(chan float64)

	go calculateTotal(1_000_000, result)

	total := <-result
	fmt.Printf("Montant total de la commande : %.0f\n", total)
}
  • make(chan float64) crée un channel non bufférisé de type float64.
  • chan<- float64 dans le paramètre de fonction signifie que le channel est envoi uniquement, ce qui aide le compilateur à détecter les erreurs si vous essayez accidentellement de lire depuis celui-ci.
  • result <- amount + tax envoie la valeur dans le channel.
  • <-result dans main va bloquer jusqu’à ce qu’il y ait des données, servant de synchronisation naturelle — pas besoin de Sleep.
Montant total de la commande : 1100000

3.1. Channel non bufférisé vs bufférisé

Un channel non bufférisé (make(chan T)) exige que l’expéditeur et le destinataire « se rencontrent » en même temps — l’envoi va bloquer jusqu’à ce que quelqu’un le reçoive. Un channel bufférisé (make(chan T, n)) permet d’envoyer jusqu’à n valeurs sans qu’un destinataire soit prêt.

package main

import "fmt"

func main() {
	orders := make(chan string, 3)

	orders <- "Order-1"
	orders <- "Order-2"
	orders <- "Order-3"
	close(orders)

	for order := range orders {
		fmt.Println("Traiter :", order)
	}
}

Puisque le buffer a une capacité de 3, les trois envois ne bloquent pas. close(orders) signale qu’il n’y a plus de données, ce qui permet à la boucle range de se terminer naturellement au lieu de bloquer indéfiniment en attente de la prochaine valeur.

Traiter : Order-1
Traiter : Order-2
Traiter : Order-3

Attention : Seul le côté envoi peut close un channel. Envoyer dans un channel fermé provoque une panique send on closed channel ; lire depuis un channel fermé reste valide, retournant la valeur zéro et ok == false.

4. Synchroniser plusieurs goroutines avec WaitGroup

Quand vous devez attendre que plusieurs goroutines se terminent sans vous soucier de la valeur de retour, sync.WaitGroup est plus épuré qu’un channel.

package main

import (
	"fmt"
	"sync"
)

func processOrder(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Printf("Order-%d traitée\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go processOrder(i, &wg)
	}

	wg.Wait()
	fmt.Println("Tous les orders ont été traités")
}
  • wg.Add(1) incrémente le compteur avant que la goroutine ne s’exécute — doit être appelé avant go, pas à l’intérieur de la goroutine, pour éviter une condition de concurrence.
  • defer wg.Done() décrémente le compteur à la fin de la goroutine.
  • wg.Wait() bloque main jusqu’à ce que le compteur atteigne 0.
Order-2 traitée
Order-1 traitée
Order-3 traitée
Tous les orders ont été traités

L’ordre des trois premières lignes n’est pas garanti, mais la dernière s’affiche toujours en dernier — c’est ce que garantit WaitGroup.

4.1. Tester le comportement concurrent

Vous pouvez écrire un test pour vérifier que toutes les goroutines se terminent, en utilisant un channel de comptage plutôt que de dépendre de l’ordre d’affichage :

package orderpkg

import (
	"sync"
	"sync/atomic"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestProcessOrders_AllCompleted(t *testing.T) {
	var wg sync.WaitGroup
	var processed int32

	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			atomic.AddInt32(&processed, 1)
		}(i)
	}

	wg.Wait()

	assert.Equal(t, int32(5), processed)
}

atomic.AddInt32 évite une condition de concurrence quand plusieurs goroutines incrémentent le compteur — si vous utilisiez processed++ normalement, go test -race rapporterait une data race.

Dépendance pour le test (ajouter à go.mod) :

go get github.com/stretchr/testify

5. Select : traiter plusieurs channels à la fois

select est comme switch mais pour les channels — il choisit le cas qui est prêt en premier pour l’exécuter. C’est très utile quand vous devez attendre plusieurs sources de données ou mettre un timeout.

package main

import (
	"fmt"
	"time"
)

func fetchFromCache(ch chan<- string) {
	time.Sleep(50 * time.Millisecond)
	ch <- "données du cache"
}

func fetchFromDB(ch chan<- string) {
	time.Sleep(200 * time.Millisecond)
	ch <- "données de la base de données"
}

func main() {
	cacheCh := make(chan string)
	dbCh := make(chan string)

	go fetchFromCache(cacheCh)
	go fetchFromDB(dbCh)

	select {
	case res := <-cacheCh:
		fmt.Println("Reçu :", res)
	case res := <-dbCh:
		fmt.Println("Reçu :", res)
	case <-time.After(100 * time.Millisecond):
		fmt.Println("Timeout, utiliser les données par défaut")
	}
}

select bloque jusqu’à ce qu’un des cas ait des données prêtes. Puisque le cache retourne après 50ms tandis que la base de données retourne après 200ms, le cas cacheCh gagne :

Reçu : données du cache

time.After crée un channel qui reçoit une valeur après le délai spécifié, utilisé pour implémenter un timeout pour l’ensemble du select.

6. Annuler correctement les goroutines avec context

Une erreur courante est de lancer une goroutine mais de n’avoir aucun moyen de l’arrêter, entraînant une fuite de goroutine. Le package context résout ce problème en transmettant un signal d’annulation tout au long de la chaîne d’appels.

package main

import (
	"context"
	"fmt"
	"time"
)

func monitorPayment(ctx context.Context, orderID string) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("Arrêt du suivi de la commande :", orderID, "-", ctx.Err())
			return
		case <-time.After(500 * time.Millisecond):
			fmt.Println("Vérification du statut de la commande :", orderID)
		}
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
	defer cancel()

	go monitorPayment(ctx, "Order-99")

	<-ctx.Done()
	time.Sleep(50 * time.Millisecond) // attendre que la goroutine affiche la dernière ligne
}
  • context.WithTimeout crée un context qui Done() automatiquement après 1200ms.
  • Dans monitorPayment, select écoute à la fois ctx.Done() et un tick périodique. Quand le context expire, la goroutine se termine proprement au lieu de s’exécuter indéfiniment.
  • defer cancel() garantit que le context est libéré immédiatement même si la fonction se termine plus tôt.
Vérification du statut de la commande : Order-99
Vérification du statut de la commande : Order-99
Arrêt du suivi de la commande : Order-99 - context deadline exceeded

Attention : Toujours appeler cancel() (via defer) même si vous utilisez WithTimeout ou WithCancel. Oublier cette étape ne cause pas d’erreur immédiate mais provoque une fuite de context dans les boucles longue durée.

7. Erreurs courantes

Deadlock dû à un channel sans lecteur. Envoyer dans un channel non bufférisé sans qu’aucune goroutine ne le lise va geler le programme et Go runtime le détecte automatiquement :

func main() {
	ch := make(chan int)
	ch <- 1 // personne ne lit → deadlock
}
fatal error: all goroutines are asleep - deadlock!

Fuite de goroutine. Une goroutine attend de lire depuis un channel indéfiniment car personne n’envoie et il n’y a aucun mécanisme d’annulation — la mémoire est conservée jusqu’à la fin du programme. Toujours accompagner context ou un channel « done » au lancement d’une goroutine longue durée.

Closure capturant incorrectement la variable de boucle (avant Go 1.22). Si vous utilisez Go < 1.22, la variable de boucle i dans for est partagée entre les itérations, ce qui fait que toutes les goroutines peuvent voir la même valeur finale. À partir de Go 1.22, chaque itération crée une nouvelle variable i donc ce problème a disparu — mais si votre projet utilise une version plus ancienne, transmettez la variable en paramètre comme dans l’exemple de la section 4.1.

Condition de concurrence lors du partage de variables. Deux goroutines lisant/écrivant la même variable sans passer par un channel ou sync/atomic est une condition de concurrence classique. Toujours exécuter go test -race ou go run -race pendant le développement pour détecter ces problèmes rapidement.

Quand NE PAS utiliser les goroutines : pour les tâches courtes, le coût de création d’une goroutine et sa synchronisation peut être supérieur aux bénéfices. Si le travail ne prend que quelques microsecondes, l’exécution séquentielle est souvent plus rapide et plus lisible.

8. Conclusion

Les goroutines vous donnent un moyen de créer des threads d’exécution légers avec un coût extrêmement bas, tandis que les channels fournissent un mécanisme de communication sûr plutôt que de partager la mémoire directement. Combinées avec sync.WaitGroup pour la synchronisation, select pour gérer plusieurs sources, et context pour annuler au bon moment, vous avez tous les outils pour construire les patterns de concurrence courants en Go — du worker pool, au pipeline, en passant par fan-in/fan-out. L’étape suivante devrait être de pratiquer avec go test -race pour cultiver l’habitude de détecter les conditions de concurrence dès le départ.

golanggoroutinechannelconcurrency