Accueil / Articles / La concurrence en Go
La concurrence en Go
Publié le 8 Avril 2025
La concurrence en Go est l’un des piliers fondamentaux du langage qui repose principalement sur deux mécanismes : les goroutines et les channels. Dans cet article, nous allons présenter de manière détaillée le mécanisme de concurrence en Go, ses concepts clés et les différentes manières de l’utiliser et de gérer l’accès aux ressources partagées.
Concurrence vs parallélisme
La concurrence
La concurrence signifie que plusieurs tâches peuvent progresser de manière “entrelacée” mais pas forcément en même temps. Ce mécanisme est particulièrement utile quand plusieurs opérations doivent être exécutées de manière indépendante mais pas de manière simultanée.
Dans le code ci-dessous, chaque tâche progresse indépendamment, mais ne s’exécutent pas forcément en même temps car elles peuvent être effectuées sur un seul CPU.
package main
import (
"fmt"
"time"
)
func task(name string) {
for i := 0; i < 3; i++ {
fmt.Println(name, "étape", i)
time.Sleep(time.Millisecond * 500) // Simulation d'un travail
}
}
func main() {
go task("Tâche 1")
go task("Tâche 2")
go task("Tâche 3")
time.Sleep(time.Second * 2) // Attente pour que les goroutines finissent
fmt.Println("Programme terminé")
}
Le parallélisme
Le parallélisme signifie que plusieurs tâches s’exécutent en même temps sur plusieurs cœurs. Ce mécanisme est particulièrement utile pour des traitements intensifs de données.
Ici, si notre CPU possède plusieurs coeurs, plusieurs tâches pourront s’exécuter simultanément. A noter que l’ordre d’exécution n’est pas forcément celui dans lequel on pourrait s’attendre. La tâche 1 ne sera pas forcément terminée avant la 2 ou la 5. Nous aborderons plus loins les différents mécanismes proposés par Go.
package main
import (
"fmt"
"runtime"
"sync"
)
func task(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("Tâche", id, "exécutée par", runtime.GOMAXPROCS(0), "CPU(s)")
}
func main() {
runtime.GOMAXPROCS(4) // Forcer l'utilisation de 4 CPU si disponibles
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go task(i, &wg)
}
wg.Wait()
fmt.Println("Programme terminé")
}
Go et la gestion de la concurrence / parallélisme
Go est concurrent par nature grâce au mécanisme des goroutines et des channels, mais il peut également exploiter le parallélisme si plusieurs cœurs sont disponibles sur un CPU.
Pour résumer, la concurrence améliore la réactivité et la gestion des tâches indépendantes tandis que le parallélisme accélère l'exécution en utilisant plusieurs cœurs de CPU.
Rentrons maintenant un peu plus dans le détail des mécanismes exploités par Go.
Les goroutines : des threads légers
En Go, un goroutine est une fonction qui s’exécute indépendamment et de manière concurrente avec d’autres goroutines. Ce qui rend les goroutines encore plus intéressantes, c’est qu’elles sont très légères comparées aux threads d’un système d'exploitation (OS).
Différences entre les goroutines et les threads d’un OS
Go n’utilise pas directement les threads de l’OS pour gérer la concurrence. Il utilise à la place un système de planification M:N Scheduling, où un nombre arbitraire de goroutines (M) vont être appliquées sur un nombre fixe de threads (N).
En termes de coût mémoire : Un thread OS possède sa propre pile mémoire de 1 MB ou plus et qui reste fixe. Avec 10 000 threads, on peut facilement dépasser les 10 GB de RAM. Une goroutine commence avec une pile mémoire de quelques kilobytes. Cette pile peut s’adapter dynamiquement. On peut lancer des millions de goroutines sans saturer la RAM.
Temps de création : Un thread OS nécessite un appel système qui va permettre au kernel (noyau du système) d’allouer une pile mémoire mais qui est coûteux en ressources et demande des milliers de cycles CPU. La création d’une goroutine est très rapide car gérée directement par le runtime (moteur) de Go. Pas d’appel système et la création prend quelques dizaines de cycles CPU. On peut donc créer des milliers de goroutines en quelques millisecondes alors que créer des milliers de threads va ralentir considérablement un OS.
Changement de contexte : Un thread est géré par le kernel, et doit être mis en pause pour qu’un autre thread prenne le relais demandant un changement de contexte par le kernel. Le runtime de Go gère lui-même les changements de contexte sans passer par le kernel. Ce qui rend ces changements bien plus rapides.
Nombre maximal : Le nombre de threads est limité par la RAM et les ressources du système (le kernel ne peut gérer qu’un nombre limité de threads). Sur un système “classique” on sera limité à quelques milliers de threads. Une goroutine est bien plus légère. On peut en exécuter des millions sans problème. Go optimise automatiquement l’exécution des Goroutines sur un nombre réduit de threads OS. Par exemple, un serveur HTTP en Go peut gérer des centaines de milliers de requêtes concurrentes avec très peu de threads.
Planification : Les threads sont gérés par le planificateur de l’OS qui décide quel thread doit s'exécuter à quel moment. Ce planificateur n’est pas optimisé pour exécuter des millions de petites tâches. Le runtime de Go inclut son propre planificateur optimisé pour gérer des millions de goroutines.
Création et exécution d’une goroutine
Créer une goroutine est très facile. Il suffit d’ajouter le mot-clé go avant l’appel de la fonction.
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("Hello, World!")
}
func main() {
go sayHello() // Lance la fonction dans une nouvelle goroutine
time.Sleep(time.Second) // Attente pour que la goroutine ait le temps de s'exécuter
fmt.Println("Programme terminé")
}
Détaillons un peu ce qui se passe dans l’exemple ci-dessus. go sayHello() démarre la fonction sayHello() dans une goroutine. Le programme principal continue sans attendre la fin de l’exécution de sayHello(). time.Sleep(time.Second) évite que le programme se termine avant que la goroutine ne s’exécute. Si on retire time.Sleep, il y a une chance que sayHello() ne s’exécute jamais car main() aura déjà terminé son exécution. A noter qu’ici l’utilisation time.Sleep() est une mauvaise pratique. Il faudrait utiliser d’autres mécanismes comme sync.Waitgroup que nous aborderons un peu plus loin.
Problèmes courants avec les Goroutines
Un des pièges classiques avec les goroutines est que le programme principal se termine avant que les goroutines n’aient fini leur travail.
main() se termine immédiatement sans que la goroutine n’ait eu le temps de s’exécuter.
package main
import "fmt"
func main() {
go fmt.Println("Hello Goroutine!")
}
Comme mentionné plus haut, on ne peut pas se contenter simplement d’ajouter un time.Sleep(). Il va falloir utiliser d’autres mécanismes pour exploiter correctement les goroutines.
Un autre problème courant avec les goroutines intervient lorsque celles-ci sont créées dans une boucle par exemple et que plusieurs goroutines capturent la même variable. Dans la même logique, les goroutines accédant à des variables partagées peuvent provoquer un phénomène de “race condition”.
Les Channels : Communication sécurisée entre goroutines
En Go, les channels permettent aux goroutines de communiquer entre elles de manière sûre et synchronisée. Ils sont au cœur du modèle de concurrence de Go et suivent comme principe de ne pas partager la mémoire mais de partager des messages.
Les channels sont là pour répondre aux problématiques de concurrence liés au partage de mémoire comme les “race conditions” et les “deadlock” (quand une goroutine veut interagir avec un channel en lecture ou écriture et qu’il n’y a personne de l’autre côté pour recevoir ou émettre - nous allons voir cela plus en détails).
Un channel correspond à un type spécifique en Go et va permettre d’envoyer et recevoir des valeurs d’un certain type.
Déclaration d’un channel
var ch chan int // Déclare un channel qui transmet des int
Si ce channel n’est pas initialisé il vaut nil :
ch = make(chan int) // Création d'un channel d'entiers
Voici la version raccourcie :
ch := make(chan int) // Déclaration et allocation
Envoi et réception de données
On utilise la notation <-
ch <- 42 // La flèche est à droite de notre variable ch - On envoie la valeur 42 dans le channel
val := <-ch // La flèche est à gauche de notre variable ch - On récupère la valeur envoyée (42)
Un exemple avec deux goroutines
package main
import (
"fmt"
"time"
)
func worker(ch chan string) {
time.Sleep(2 * time.Second)
ch <- "Message reçu !"
}
func main() {
ch := make(chan string)
go worker(ch) // Lance une goroutine
msg := <-ch // Attend la réponse
fmt.Println(msg) // Affiche "Message reçu !"
}
Dans cet exemple, le programme ne se termine que lorsqu’un message est reçu. On dit que l’opération <- est bloquante tant qu’aucune donnée n’est envoyé dans ch.
Channels bloquants et non-bloquants
Un channel par défaut est bloquant : une goroutine qui envoie une valeur dans un channel attend qu’une autre goroutine reçoive cette valeur. Inversement, une goroutine qui reçoit une valeur attend qu’une autre goroutine envoie une valeur. Ceci permet une synchronisation naturelle entre les goroutines.
Dans cet exemple, le programme va ce qu’on appelle “deadlock”. C’est à dire qu'une goroutine cherche à interagir avec un channel en lecture ou écriture mais que personne ne se trouve de l’autre côté pour recevoir ou émettre.
package main
func main() {
ch := make(chan int)
ch <- 10 // Bloque ici car personne ne reçoit !
}
Pour avoir un channel non-bloquant, nous devons créer un channel dit bufferisé (buffered channel). Un channel bloquant lui est dit non bufferisé (unbuffered channel).
Dans notre déclaration ci-dessous, jusqu’à 3 valeurs peuvent être envoyées sans bloquer le processus.
ch := make(chan int, 3) // Buffer de taille 3
Voici un exemple plus complet : Ici, les deux valeurs sont stockées avant d’être lues. Si on tente d’envoyer plus de 2 valeurs, cela bloque jusqu’à ce qu’une place se libère.
package main
import "fmt"
func main() {
ch := make(chan int, 2) // Buffer de 2 valeurs
ch <- 1 // Ne bloque pas
ch <- 2 // Ne bloque pas
fmt.Println(<-ch) // Récupère 1
fmt.Println(<-ch) // Récupère 2
}
Fermeture d’un channel
Il est possible de fermer un channel pour signaler qu’aucune nouvelle données ne sera envoyée.
Lorsqu’un channel est fermé, on ne peut plus envoyer de nouvelles valeurs dans le channel. Si on essaie, Go déclenche un “panic” (erreur fatale) pour éviter des comportements imprévisibles.
package main
func main() {
ch := make(chan int, 2)
close(ch)
ch <- 42 // ERREUR : panic: send on closed channel
}
Les valeurs restantes dans le channel peuvent être encore lues. Quand toutes les valeurs sont lues, toute nouvelle tentative de lecture renvoie la valeur zéro du type du channel.
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
ch <- 20
close(ch) // Fermeture du channel
fmt.Println(<-ch) // Affiche 10
fmt.Println(<-ch) // Affiche 20
fmt.Println(<-ch) // Channel vide et fermé -> Renvoie 0 (valeur zéro pour int)
}
Il existe un autre mécanisme pour lire toutes les valeurs d’un channel. Dans ce cas on utilisera la boucle for … range.
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
ch <- 20
close(ch) // Fermeture du channel
for val := range ch {
fmt.Println(val) // Affiche 10 puis 20
}
}
Une autre méthode mais sans for … range serait de combiner une simple boucle for et close().
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 10
ch <- 20
close(ch)
}()
for {
val, open := <-ch
if !open {
break // Évite le blocage
}
fmt.Println(val)
}
}
Le Select
Le mot-clé select permet d’attendre plusieurs opérations de communication sur des channels et d’en exécuter une seule, celle qui sera prête en premier.
Voici un exemple d’utilisation d’un select :
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "message de ch1"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "message de ch2"
}()
select {
case msg1 := <-ch1:
fmt.Println("Reçu :", msg1)
case msg2 := <-ch2:
fmt.Println("Reçu :", msg2)
default:
fmt.Println("Rien reçu")
}
}
Ici, chaque case attend une opération sur un channel (envoi ou réception). Dés qu’une opération peut se faire, elle est exécutée. Si plusieurs cases sont prếtes en même temps, Go en choisira une au hasard. Si aucun channel n’est prêt et qu’il y a un default en place, celui-ci est exécuté. Dans le cas contraire, le select bloque en attendant que l’un des channels devienne actif.
Un cas d’utilisation très utile du select est de l’associer avec une boucle for :
for {
select {
case msg := <-ch1:
fmt.Println("ch1 :", msg)
case msg := <-ch2:
fmt.Println("ch2 :", msg)
case <-quit:
fmt.Println("Fermeture")
return
}
}
Le waitgroup
Un waitgroup est un outil fourni par le package sync de Go. Il permet de synchroniser l’exécution de plusieurs goroutines. Ce mécanisme est très utile quand on lance plusieurs tâches concurrentes et que l’on veut s’assurer qu’elles sont toutes terminées avant de poursuivre l’exécution du programme.
Concrètement, on va incrémenter un compteur pour le waitgroup pour chaque goroutine à suivre. Une fois qu’une goroutine sera terminée, elle décrémente ce compteur. Le programme principal attendra que ce compteur soit à zéro pour continuer l’exécution du programme.
Si on regarde l’exemple ci-dessous, Add() incrémente notre compteur, Done() le décrémente et wait() attend que le compteur soit à zéro.
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // Signale que la goroutine est terminée
fmt.Printf("Goroutine %d démarre son travail\n", id)
time.Sleep(1 * time.Second)
fmt.Printf("Goroutine %d a terminé\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // On attend une goroutine de plus
go worker(i, &wg)
}
wg.Wait() // On bloque ici jusqu’à ce que les 3 goroutines aient appelé Done()
fmt.Println("Toutes les goroutines ont terminé")
}
Mutex et variables atomiques
Lorsqu’on travaille avec des goroutines, un enjeu clé est la gestion de l’accès concurrent aux ressources partagées. C’est là que les mutex et les variables atomiques entrent en jeu. Ces deux approches vont nous aider à garantir la sécurité des données en milieu concurrent.
Le Mutex
Le mutual exclusion ou Mutex est un verrou qui protège l’accès à une section critique, c'est-à-dire une portion de code ou une variable partagée qui ne doit être manipulée que par une seule goroutine à la fois.
Nous avons deux interfaces à disposition dans le mécanisme des mutex :
Lock() : qui bloque l’accès à la ressource. Si la ressource est déjà verrouillée par une goroutine, la goroutine suivante est mise en attente.
Unlock() : libère la ressource, permettant aux autres goroutines d’accéder à la ressource.
Dans l’exemple ci-dessous, sans un mutex, plusieurs goroutines pourraient modifier la variable counter en même temps, entraînant des résultats imprévisibles (au lieu d’avoir un compteur à 1000, on pourrait avoir un résultat erroné à 950 par exemple).
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
var counter int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("Résultat final :", counter)
}
Les variables atomiques
Les opérations atomiques permettent de manipuler des variables partagées de manière indivisibles, sans utiliser de mutex. C’est une solution plus légère et rapide que les mutex, mais aussi plus limitée en termes de types et d’opérations. Les variables atomiques ne fonctionnent que sur des types de base (int32, int64, uint32, etc…).
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1)
}()
}
wg.Wait()
fmt.Println("Résultat final :", counter)
}
Conclusion
Go a été conçu avec la concurrence au cœur du langage avec une approche simple, lisible et performante grâce à ensemble d’outils natifs parfaitement intégré au moteur de Go.
Le modèle de concurrence de Go est minimaliste mais extrêmement efficace. Il permet de raisonner plus facilement sur la logique concurrente, d’éviter des pièges classiques (comme les deadlocks et les data races) et de maintenir un code lisible et maintenable même dans des contextes complexes.
Le mot de la fin
J’espère que cet article vous a plu et que vous y voyez plus clair sur le concept de concurrence en Go. Vous pouvez aussi retrouver mon article sur la présentation de Go pour le développeur PHP.