Concurrency Go : Goroutines et Channels - Guide Complet
Maîtrisez la concurrence en Go avec goroutines et channels. Patterns avancés, synchronisation, select, et bonnes pratiques avec exemples de code détaillés.

La concurrence représente l'un des points forts de Go. Contrairement à d'autres langages où le multithreading reste complexe, Go propose un modèle élégant basé sur les goroutines et channels qui simplifie considérablement le développement d'applications concurrentes.
"Ne communiquez pas en partageant la mémoire, partagez la mémoire en communiquant." Ce principe fondamental guide toute la conception de la concurrence en Go.
Comprendre les goroutines
Les goroutines sont des threads légers gérés par le runtime Go. Elles consomment environ 2 Ko de stack (contre plusieurs Mo pour un thread OS) et permettent d'exécuter des milliers de tâches concurrentes sans surcharge système.
Le lancement d'une goroutine s'effectue simplement avec le mot-clé go devant un appel de fonction. Le runtime se charge de la planification et de la distribution sur les threads disponibles.
package main
import (
"fmt"
"time"
)
// fetchData simule une requête réseau
func fetchData(id int) {
// Simule un délai réseau
time.Sleep(100 * time.Millisecond)
fmt.Printf("Données %d récupérées\n", id)
}
func main() {
// Lancement séquentiel - 500ms total
start := time.Now()
for i := 1; i <= 5; i++ {
fetchData(i)
}
fmt.Printf("Séquentiel: %v\n", time.Since(start))
// Lancement concurrent - ~100ms total
start = time.Now()
for i := 1; i <= 5; i++ {
go fetchData(i) // Exécution en goroutine
}
time.Sleep(150 * time.Millisecond) // Attend la fin
fmt.Printf("Concurrent: %v\n", time.Since(start))
}L'exécution concurrente réduit le temps total de 500ms à environ 100ms. Cependant, utiliser time.Sleep pour synchroniser les goroutines n'est pas une bonne pratique. Les channels offrent une solution élégante.
Channels : communication entre goroutines
Un channel est un conduit typé permettant l'envoi et la réception de valeurs entre goroutines. Les channels assurent la synchronisation : une goroutine qui envoie attend qu'une autre reçoive, et inversement.
La création d'un channel utilise la fonction make. L'opérateur <- permet d'envoyer et recevoir des données selon son placement par rapport au channel.
package main
import "fmt"
// worker effectue un calcul et renvoie le résultat via channel
func worker(id int, jobs <-chan int, results chan<- int) {
// Reçoit les jobs jusqu'à fermeture du channel
for job := range jobs {
result := job * 2 // Traitement
results <- result // Envoi du résultat
}
}
func main() {
// Création des channels
jobs := make(chan int, 10) // Channel bufferisé
results := make(chan int, 10)
// Démarrage de 3 workers
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// Envoi de 5 jobs
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs) // Signale la fin des jobs
// Collecte des résultats
for r := 1; r <= 5; r++ {
result := <-results
fmt.Printf("Résultat: %d\n", result)
}
}Les channels directionnels (<-chan pour réception, chan<- pour envoi) renforcent la sécurité du code en limitant les opérations possibles.
Channels bufferisés vs non-bufferisés
La distinction entre ces deux types de channels influence directement le comportement de synchronisation des goroutines.
Les channels non-bufferisés bloquent l'expéditeur jusqu'à ce qu'un récepteur soit prêt. Les channels bufferisés permettent d'envoyer jusqu'à N valeurs sans blocage, où N représente la capacité du buffer.
package main
import "fmt"
func main() {
// Channel non-bufferisé - synchronisation stricte
unbuffered := make(chan string)
go func() {
unbuffered <- "message" // Bloque jusqu'à réception
}()
msg := <-unbuffered // Débloque l'envoi
fmt.Println(msg)
// Channel bufferisé - capacité de 2
buffered := make(chan int, 2)
// Ces envois ne bloquent pas
buffered <- 1
buffered <- 2
// buffered <- 3 // Bloquerait car buffer plein
fmt.Println(<-buffered) // 1
fmt.Println(<-buffered) // 2
// Vérification de la capacité
fmt.Printf("Longueur: %d, Capacité: %d\n",
len(buffered), cap(buffered))
}Les channels bufferisés s'utilisent pour découpler producteurs et consommateurs, tandis que les non-bufferisés garantissent une synchronisation point à point.
Un deadlock survient quand toutes les goroutines sont bloquées en attente. Le runtime Go le détecte et termine le programme avec un message d'erreur explicite.
Select : multiplexage de channels
L'instruction select permet d'attendre sur plusieurs opérations de channel simultanément. Elle s'apparente à un switch pour les communications concurrentes.
Cette construction est essentielle pour gérer des timeouts, annulations et communications multiples sans bloquer indéfiniment sur un seul channel.
package main
import (
"fmt"
"time"
)
// fetchAPI simule un appel API avec délai variable
func fetchAPI(name string, delay time.Duration, ch chan<- string) {
time.Sleep(delay)
ch <- fmt.Sprintf("%s: données reçues", name)
}
func main() {
api1 := make(chan string)
api2 := make(chan string)
// Lancement de deux appels API en parallèle
go fetchAPI("API-1", 100*time.Millisecond, api1)
go fetchAPI("API-2", 200*time.Millisecond, api2)
// Timeout global de 150ms
timeout := time.After(150 * time.Millisecond)
// Collecte des résultats avec timeout
for i := 0; i < 2; i++ {
select {
case result := <-api1:
fmt.Println(result)
case result := <-api2:
fmt.Println(result)
case <-timeout:
fmt.Println("Timeout - opération annulée")
return
}
}
}Le select choisit le premier channel prêt. Si plusieurs sont prêts, le choix est pseudo-aléatoire pour éviter la famine.
Prêt à réussir tes entretiens Go ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
Pattern Worker Pool
Le pattern worker pool distribue des tâches entre plusieurs workers, limitant la concurrence et optimisant l'utilisation des ressources. Ce pattern s'avère indispensable pour traiter de grandes quantités de données.
L'implémentation repose sur un channel de jobs partagé entre workers et un channel de résultats pour la collecte.
package main
import (
"fmt"
"sync"
"time"
)
// Task représente une unité de travail
type Task struct {
ID int
Data string
}
// Result contient le résultat du traitement
type Result struct {
TaskID int
Output string
}
// worker traite les tâches reçues
func worker(id int, tasks <-chan Task, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for task := range tasks {
// Simulation du traitement
time.Sleep(50 * time.Millisecond)
results <- Result{
TaskID: task.ID,
Output: fmt.Sprintf("Worker %d a traité: %s", id, task.Data),
}
}
}
func main() {
const numWorkers = 3
const numTasks = 10
tasks := make(chan Task, numTasks)
results := make(chan Result, numTasks)
var wg sync.WaitGroup
// Démarrage des workers
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
go worker(w, tasks, results, &wg)
}
// Envoi des tâches
for i := 1; i <= numTasks; i++ {
tasks <- Task{ID: i, Data: fmt.Sprintf("tâche-%d", i)}
}
close(tasks)
// Fermeture du channel results après fin des workers
go func() {
wg.Wait()
close(results)
}()
// Collecte des résultats
for result := range results {
fmt.Printf("Tâche %d: %s\n", result.TaskID, result.Output)
}
}Le sync.WaitGroup coordonne l'attente de la fin de tous les workers avant de fermer le channel de résultats.
Pattern Fan-Out/Fan-In
Ce pattern distribue le travail entre plusieurs goroutines (fan-out) puis agrège les résultats (fan-in). Il maximise le parallélisme tout en simplifiant la collecte des résultats.
package main
import (
"fmt"
"sync"
)
// generate produit des nombres sur un channel
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
// square calcule le carré des nombres reçus
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
// merge combine plusieurs channels en un seul (fan-in)
func merge(channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
// Fonction de sortie pour chaque channel
output := func(c <-chan int) {
defer wg.Done()
for n := range c {
out <- n
}
}
// Lancement d'une goroutine par channel
wg.Add(len(channels))
for _, c := range channels {
go output(c)
}
// Fermeture après fin de toutes les goroutines
go func() {
wg.Wait()
close(out)
}()
return out
}
func main() {
// Génération des données
numbers := generate(1, 2, 3, 4, 5, 6, 7, 8)
// Fan-out: distribution sur 3 workers
sq1 := square(numbers)
sq2 := square(numbers)
sq3 := square(numbers)
// Fan-in: agrégation des résultats
for result := range merge(sq1, sq2, sq3) {
fmt.Println(result)
}
}Ce pattern brille pour les opérations CPU-bound distribuables et les pipelines de traitement de données.
Context pour l'annulation et les deadlines
Le package context standardise la gestion des annulations, deadlines et valeurs à travers les goroutines. Toute goroutine longue durée devrait accepter un context comme premier paramètre.
package main
import (
"context"
"fmt"
"time"
)
// fetchWithContext simule une requête annulable
func fetchWithContext(ctx context.Context, url string) (string, error) {
// Simule une opération longue
select {
case <-time.After(2 * time.Second):
return fmt.Sprintf("Données de %s", url), nil
case <-ctx.Done():
return "", ctx.Err() // context.Canceled ou context.DeadlineExceeded
}
}
func main() {
// Context avec timeout de 500ms
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel() // Libère les ressources
result, err := fetchWithContext(ctx, "https://api.example.com")
if err != nil {
fmt.Printf("Erreur: %v\n", err)
return
}
fmt.Println(result)
// Context avec annulation manuelle
ctx2, cancel2 := context.WithCancel(context.Background())
go func() {
time.Sleep(100 * time.Millisecond)
cancel2() // Annulation explicite
}()
result, err = fetchWithContext(ctx2, "https://api2.example.com")
if err != nil {
fmt.Printf("Requête annulée: %v\n", err)
}
}Toujours appeler defer cancel() immédiatement après création du context pour éviter les fuites de ressources.
Synchronisation avec sync.Mutex
Bien que les channels soient privilégiés pour la communication, le package sync reste nécessaire pour protéger l'accès concurrent aux structures de données partagées.
package main
import (
"fmt"
"sync"
)
// SafeCounter est un compteur thread-safe
type SafeCounter struct {
mu sync.Mutex
value map[string]int
}
// Increment incrémente la valeur pour une clé donnée
func (c *SafeCounter) Increment(key string) {
c.mu.Lock() // Verrouillage exclusif
defer c.mu.Unlock() // Déverrouillage garanti
c.value[key]++
}
// Value retourne la valeur actuelle
func (c *SafeCounter) Value(key string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value[key]
}
func main() {
counter := SafeCounter{value: make(map[string]int)}
var wg sync.WaitGroup
// 1000 incrémentations concurrentes
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Increment("visits")
}()
}
wg.Wait()
fmt.Printf("Total: %d\n", counter.Value("visits")) // 1000
}Le sync.RWMutex optimise les lectures concurrentes avec RLock()/RUnlock() pour les opérations de lecture uniquement.
Erreurs courantes et solutions
La concurrence en Go présente des pièges classiques. Voici les erreurs les plus fréquentes et comment les éviter.
package main
import (
"fmt"
"sync"
)
func main() {
// ERREUR: Capture de variable de boucle
// Toutes les goroutines afficheraient la même valeur
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i) // Capture par référence - BUG
}()
}
// SOLUTION: Passer la valeur en paramètre
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(n) // Copie locale - CORRECT
}(i)
}
wg.Wait()
// ERREUR: Envoi sur channel nil
var ch chan int
// ch <- 1 // Bloque indéfiniment
// SOLUTION: Toujours initialiser avec make
ch = make(chan int, 1)
ch <- 1
fmt.Println(<-ch)
// ERREUR: Envoi sur channel fermé
done := make(chan bool)
close(done)
// done <- true // Panique!
// SOLUTION: Vérifier avant envoi ou utiliser sync.Once
select {
case done <- true:
fmt.Println("Envoyé")
default:
fmt.Println("Channel fermé ou plein")
}
}La détection des data races s'effectue avec le flag -race lors de la compilation ou des tests : go test -race ./....
Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
Conclusion
La maîtrise de la concurrence en Go repose sur quelques concepts clés qui, bien compris, permettent de construire des applications hautement performantes.
Points essentiels à retenir :
✅ Les goroutines sont légères et bon marché - en créer des milliers reste acceptable
✅ Les channels synchronisent et transfèrent les données entre goroutines
✅ Le select gère les communications multiples et les timeouts
✅ Le pattern worker pool limite la concurrence et optimise les ressources
✅ Le package context standardise annulation et deadlines
✅ Les mutex protègent les données partagées quand les channels ne suffisent pas
✅ Le flag -race détecte les data races lors des tests
La philosophie "Share memory by communicating" guide vers des designs plus sûrs et maintenables que le multithreading traditionnel avec locks.
Tags
Partager
Articles similaires

Entretien technique Go : Goroutines, Channels et Concurrence
Questions d'entretien Go sur les goroutines, channels et patterns de concurrence. Exemples de code, pieges courants et reponses de niveau expert pour preparer les entretiens techniques Go en 2026.

Go : Les bases pour développeurs Java/Python en 2026
Apprenez Go rapidement en partant de vos acquis Java ou Python. Goroutines, channels, interfaces et patterns essentiels expliqués pour une transition fluide.

Top 25 questions d'entretien Go : Guide complet pour développeurs
Préparez vos entretiens Go avec les 25 questions les plus posées. Goroutines, channels, interfaces, concurrence et patterns avancés avec exemples de code.