Concurrence et multithreading en Go : des goroutines aux canaux
Le langage Go (Golang) a été développé chez Google en tenant compte des exigences modernes de performance et d'évolutivité. Sa principale caractéristique est le support intégré de la concurrence, implémenté au niveau du langage et non via des bibliothèques externes. Contrairement à Python, où le multithreading est limité par le GIL (Global Interpreter Lock), Go propose des goroutines légères qui permettent d'exécuter efficacement des milliers de tâches simultanément. Dans cet article, nous allons analyser les concepts clés de la concurrence et du multithreading en Go, notamment les goroutines, les canaux, les mutex et les groupes d'attente, avec des exemples de code pratiques.
1. Goroutines : les threads légers de Go
Une goroutine est une fonction qui s'exécute de manière concurrente avec d'autres fonctions dans le même espace d'adressage. Elle fonctionne au-dessus des threads système, mais utilise son propre planificateur Go, ce qui la rend considérablement moins coûteuse qu'un thread OS classique. La création d'une goroutine se fait avec le mot-clé go.
package main
import ( "fmt" "time")
func say(s string) { for i := 0; i < 5; i++ { time.Sleep(100 * time.Millisecond) fmt.Println(s) }}
func main() { go say("Hello") // Lancement d'une goroutine say("World") // Appel normal dans le thread principal}Dans cet exemple, say("Hello") s'exécute dans une goroutine séparée, tandis que say("World") s'exécute dans le thread principal. La sortie sera mélangée car les deux fonctions travaillent de manière concurrente. Il est important de se rappeler : si la fonction principale main se termine, toutes les goroutines seront arrêtées de force.
2. Canaux : transfert sécurisé de données entre goroutines
Les canaux (channels) sont des "tuyaux" typés par lesquels les goroutines communiquent entre elles. Ils assurent la synchronisation sans utilisation explicite de mutex. Les canaux peuvent être tamponnés (avec une capacité) ou non tamponnés (synchrones).
Canal non tamponné
package main
import "fmt"
func sum(s []int, c chan int) { total := 0 for _, v := range s { total += v } c <- total // envoi du résultat dans le canal}
func main() { s := []int{7, 2, 8, -9, 4, 0} c := make(chan int)
go sum(s[:len(s)/2], c) go sum(s[len(s)/2:], c)
x, y := <-c, <-c // réception depuis le canal
fmt.Println(x, y, x+y)}Ici, deux goroutines calculent les sommes des moitiés du tableau et envoient les résultats dans le canal. La goroutine principale se bloque jusqu'à la réception des deux valeurs. Un canal non tamponné fonctionne comme un point de synchronisation : l'envoi est bloqué tant que le récepteur n'est pas prêt, et vice versa.
Canal tamponné
package main
import "fmt"
func main() { ch := make(chan int, 2) // tampon de 2 éléments ch <- 1 ch <- 2 // ch <- 3 // cela provoquerait un deadlock car le tampon est plein fmt.Println(<-ch) fmt.Println(<-ch)}Un canal tamponné permet d'envoyer des données sans blocage tant que le tampon n'est pas plein. C'est pratique lorsque la vitesse d'envoi est supérieure à la vitesse de traitement.
3. Synchronisation : WaitGroup et mutex
Bien que les canaux couvrent la plupart des scénarios, un contrôle plus fin de la synchronisation est parfois nécessaire. Pour cela, le package sync fournit WaitGroup (attente de la fin d'un groupe de goroutines) et Mutex (exclusion mutuelle pour protéger les données partagées).
WaitGroup : attendre toutes les goroutines
package main
import ( "fmt" "sync")
func worker(id int, wg *sync.WaitGroup) { defer wg.Done() // décrémente le compteur à la fin fmt.Printf("Worker %d starting", id) // simulation de travail fmt.Printf("Worker %d done", id)}
func main() { var wg sync.WaitGroup
for i := 1; i <= 3; i++ { wg.Add(1) // incrémente