Comment Go gère mieux la concurrence que les modèles de threads traditionnels
Dans l’ingénierie logicielle moderne, créer des applications capables d’effectuer plusieurs tâches simultanément n’est plus un luxe : c’est une exigence fondamentale. Des serveurs Web à haut débit aux services de streaming en temps réel, la simultanéité est au cœur de la performance.
Pendant des décennies, les langages de programmation traditionnels comme C++, Java et Python se sont appuyés sur les modèles de threads natifs du système d’exploitation pour gérer les tâches simultanées. Cependant, lorsque Google a conçu Go (Golang) à la fin des années 2000, ils ont emprunté une voie radicalement différente. Au lieu d’exposer les threads bruts du système d’exploitation, Go a introduit Goroutines et un M:N Scheduler spécialisé.
Dans cet article, nous examinerons les limites architecturales des modèles de threads traditionnels et découvrirons pourquoi la conception simultanée de Go est nettement plus efficace, évolutive et conviviale pour les développeurs.
1. Les goulots d’étranglement du filetage traditionnel (le modèle 1:1)
La plupart des systèmes d’exécution traditionnels utilisent un modèle de thread 1:1. Dans ce modèle, chaque thread créé dans le code de l’espace utilisateur est directement mappé à un thread de l’espace noyau géré par le système d’exploitation (OS).
Bien que simple, cette cartographie 1:1 introduit trois goulots d’étranglement critiques :
A. Surcharge de mémoire (grandes tailles de pile)
Un thread de système d’exploitation est une ressource lourde. Par défaut, les systèmes d’exploitation allouent une taille de pile fixe et contiguë à chaque thread (généralement 1 Mo à 8 Mo).
- Si vous souhaitez gérer 10 000 connexions simultanées, l’allocation de 10 000 threads de système d’exploitation nécessiterait entre 10 Go et 80 Go de RAM uniquement pour la mémoire de la pile de threads.
- Cela rend la gestion d’une concurrence élevée avec un nombre élevé de connexions extrêmement coûteuse et pratiquement impossible sur le matériel serveur standard.
B. Coûts élevés de changement de contexte
Lorsque le système d’exploitation bascule l’exécution d’un thread à un autre, il effectue un changement de contexte. Étant donné que les threads du système d’exploitation sont gérés par le noyau, un changement de contexte nécessite de franchir la frontière utilisateur-noyau. Le processeur doit :
- Enregistrez l’état des registres actuels.
- Videz les lignes de cache du processeur et mettez à jour les tables de pages (tampons de traduction Lookaside).
- Passez en mode noyau, sélectionnez le thread suivant et chargez son état enregistré.
- Revenez au mode utilisateur.
L’intégralité de cet aller-retour prend environ 1 à 2 microsecondes, ce qui représente des centaines ou des milliers de cycles CPU consacrés uniquement à la surcharge administrative plutôt qu’à l’exécution d’une logique métier réelle.
C. Limites de planification du système d’exploitation
Le planificateur du système d’exploitation est à usage général. Il traite les threads de base de données, les threads d’interface utilisateur et les connexions réseau légères avec la même heuristique de planification. Parce qu’il ne comprend pas le contexte au niveau de l’application, il ne peut pas optimiser la planification selon qu’un thread est bloqué sur un socket réseau, en attente d’un verrou interne ou en train d’effectuer un calcul actif.
2. La solution de Go : le planificateur M:N (Goroutines)
Go contourne les limitations du threading du système d’exploitation en introduisant Goroutines et en implémentant son propre planificateur d’exécution. Plutôt que de mapper les threads 1:1, Go utilise un modèle de planification M:N dans lequel M goroutines légères sont multiplexées sur N threads physiques du système d’exploitation.
Cette architecture est régie par trois éléments structurels principaux, souvent appelés modèle GMP :
- G (Goroutine) : Représente une seule goroutine. Il comprend la pile d’exécution de la goroutine, le compteur de programme et l’état. Un
Gn’est pas un thread du système d’exploitation ; il s’agit d’une simple structure Go qui ne coûte qu’environ 2 Ko de mémoire à initialiser. - M (Machine) : représente un thread physique de système d’exploitation/noyau. Il est géré par le planificateur du système d’exploitation et est responsable de l’exécution des instructions du code machine des goroutines.
- P (Processeur) : Représente un processeur logique ou un contexte d’exécution. Le nombre d’instances
Pcorrespond par défaut au nombre de cœurs de processeur logiques sur la machine hôte (contrôlé parGOMAXPROCS). Une machineMdoit acquérir un processeur logiquePpour exécuter le code Go.
Pourquoi le modèle GMP est supérieur
Parce que les goroutines sont entièrement gérées dans l’espace utilisateur par le runtime Go :
- Dynamic Stacks : une goroutine commence avec une petite pile de 2 Ko. À mesure que l’exécution l’exige, la pile grandit dynamiquement (en allouant des segments de mémoire contigus plus grands dans le tas) et se réduit. Cela permet à Go d’exécuter des centaines de milliers de goroutines simultanément sur un seul ordinateur portable.
- Commutateurs de contexte rapides : la commutation entre les goroutines s’effectue entièrement dans l’espace utilisateur sans invoquer les commutateurs de contexte du noyau. Seuls le compteur de programme et quelques registres CPU sont sauvegardés. Ce changement de contexte dans l’espace utilisateur ne prend que 10 à 100 nanosecondes, soit environ 10 à 100 fois plus rapide qu’un changement de thread du système d’exploitation.
3. Vol de travail et E/S non bloquantes
Le runtime Go utilise deux mécanismes de planification avancés pour garantir que les cœurs de processeur physiques ne soient jamais sous-utilisés : Work Stealing et Syscall Hand-off.
A. L’algorithme de vol de travail
Chaque processeur logique P gère sa propre file d’attente d’exécution locale de goroutines. De plus, il existe une file d’attente d’exécution globale en cas de débordement.
Si un processeur P épuise toutes les goroutines de sa file d’attente d’exécution locale, il ne se met pas en veille. Au lieu de cela, il effectue un vol de travail : il vérifie les autres processeurs logiques et vole la moitié de leurs goroutines en file d’attente pour équilibrer la charge de travail sur tous les cœurs de processeur.
Local Queue P1: [ G1, G2, G3 ] ---> Running on Thread M1
Local Queue P2: [ ] ---> Running on Thread M2 (Idle)
P2 steals G3 and G2 from P1!
B. Interrogateur réseau et E/S non bloquantes
Lorsqu’une goroutine effectue des E/S réseau (par exemple, lire à partir d’une connexion à une base de données ou effectuer un appel HTTP), Go ne bloque pas le thread du système d’exploitation sous-jacent M.
Au lieu de cela, le runtime Go enregistre le bloc auprès d’un Network Poller dédié (qui utilise des API de multiplexage efficaces spécifiques au système d’exploitation telles que epoll sous Linux, kqueue sous macOS ou IOCP sous Windows). La goroutine bloquée G est détachée du thread M et garée dans l’interrogateur réseau.
Le thread M récupère immédiatement une autre goroutine exécutable dans la file d’attente. Une fois l’événement d’E/S terminé, l’observateur réseau déplace G vers une file d’attente d’exécution active pour reprendre l’exécution.
4. Canaux et mémoire partagée (le modèle CSP)
Les modèles de threads traditionnels coordonnent les tâches simultanées en partageant la mémoire (par exemple, en passant des pointeurs entre les threads). Pour éviter les courses aux données, les développeurs doivent gérer manuellement les verrous, les mutex et les variables de condition :
// Traditional Java Shared Memory Approach
synchronized(lock) {
sharedResource.updateState();
}
Ce modèle est notoirement sujet aux erreurs, entraînant fréquemment des blocages, des conditions de concurrence critique et des goulots d’étranglement au niveau de la cohérence du cache.
Go implémente le modèle formel Communicating Sequential Processes (CSP), résumé par le célèbre proverbe Golang :
“Ne communiquez pas en partageant la mémoire ; partagez plutôt la mémoire en communiquant.”
Go fournit des Channels comme primitives de première classe. Les canaux agissent comme des files d’attente de type sécurisé qui permettent aux goroutines d’envoyer et de recevoir des messages pour synchroniser l’exécution et transférer la propriété des données.
5. Mise en œuvre pratique : Goroutines et canaux en action
Voici un programme Go pratique démontrant comment plusieurs goroutines de travail peuvent traiter des tâches simultanément et renvoyer des résultats via un canal, gérant ainsi le flux de données en toute sécurité et sans verrous.
package main
import (
"fmt"
"time"
)
// Task represents a unit of work
type Task struct {
ID int
Duration time.Duration
}
// Result represents the outcome of a processed task
type Result struct {
TaskID int
Value string
}
// worker processes incoming tasks from the jobs channel and sends results
func worker(id int, jobs <-chan Task, results chan<- Result) {
for job := range jobs {
fmt.Printf("Worker %d: Started processing job %d\n", id, job.ID)
time.Sleep(job.Duration) // Simulate CPU/IO delay
results <- Result{
TaskID: job.ID,
Value: fmt.Sprintf("Processed by worker %d in %v", id, job.Duration),
}
}
}
func main() {
// Create tasks
tasks := []Task{
{ID: 101, Duration: 200 * time.Millisecond},
{ID: 102, Duration: 400 * time.Millisecond},
{ID: 103, Duration: 100 * time.Millisecond},
{ID: 104, Duration: 300 * time.Millisecond},
}
// Buffered channels prevent sender blocking
jobs := make(chan Task, len(tasks))
results := make(chan Result, len(tasks))
// Spawn 3 concurrent worker goroutines
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// Feed tasks into the job channel
for _, task := range tasks {
jobs <- task
}
close(jobs) // Closing tells workers no more jobs are coming
// Collect and aggregate results
for i := 0; i < len(tasks); i++ {
res := <-results
fmt.Printf("[RESULT] Job %d: %s\n", res.TaskID, res.Value)
}
close(results)
}
6. Comparaison d’architecture : threads du système d’exploitation et Goroutines
| Métrique/Fonctionnalité | Threads de système d’exploitation traditionnels (modèle 1:1) | Go Goroutines (modèle M:N) |
|---|---|---|
| Mémoire de démarrage | Fixe (généralement 1 Mo à 8 Mo) | Dynamique (commence à ~ 2 Ko) |
| Heure de changement de contexte | Lent (1 000 à 2 000 nanosecondes) | Rapide (10 à 100 nanosecondes) |
| Espace de changement | Espace noyau (changement de contexte important) | Espace utilisateur (planificateur d’exécution) |
| Coût de création | Cher (implique des appels système du système d’exploitation) | Extrêmement bon marché (allocation simple) |
| Communication | Mémoire partagée (Mutex, Sémaphore) | Canaux (transmission de messages CSP) |
| Risque d’impasse | Élevé (difficile à tracer manuellement) | Atténué par la conception des canaux et la détection de l’exécution de la compilation |
Conclusion
En dissociant la concurrence du modèle de thread brut du système d’exploitation, Go a résolu les problèmes fondamentaux de mise à l’échelle des architectures backend modernes.
Les Goroutines permettent une concurrence massive avec une surcharge de mémoire minimale, le planificateur M:N optimise l’utilisation du processeur via le vol de travail sans coûts de changement de contexte du noyau, et les canaux fournissent un paradigme de concurrence sûr et expressif.
Que vous construisiez des microservices ou de grands systèmes distribués, l’architecture de concurrence intégrée de Go garantit que votre application reste réactive, économe en ressources et facile à maintenir.