Des millions de goroutines sur quelques threads OS. Le scheduler M:N de Go les fait danser — GOMAXPROCS contrôle combien peuvent danser en même temps.Millions of goroutines on a few OS threads. Go's M:N scheduler makes them dance — GOMAXPROCS controls how many can dance at once.
Le volume 1 a posé les goroutines comme unité de concurrence légère. Ce dernier numéro répond à la question laissée ouverte : comment des centaines de milliers de goroutines peuvent-elles coexister sur une poignée de threads OS ? La réponse est le scheduler M:N — M goroutines sur N threads OS — implémenté par le runtime Go. La structure du scheduler repose sur trois entités. Les G (goroutines) sont les unités de travail légères : chaque goroutine démarre avec environ 2 Ko de pile et peut atteindre 1 Go par croissance dynamique. Les M (machines) sont les threads OS : créés par le runtime, ils peuvent être bien plus nombreux que GOMAXPROCS car un M bloqué sur un syscall est remplacé par un nouveau M pour ne pas bloquer les autres goroutines. Les P (processeurs) sont les contextes d'exécution : il en existe exactement GOMAXPROCS, et chaque M qui veut exécuter des G doit en acquérir un. GOMAXPROCS est donc le vrai levier de parallélisme : il contrôle combien de goroutines peuvent s'exécuter simultanément sur des cœurs différents. Par défaut depuis Go 1.5, GOMAXPROCS vaut le nombre de CPUs logiques — le bon défaut dans la plupart des cas, sauf dans des environnements containerisés où les CPUs disponibles ne sont pas ceux de la machine hôte — un angle mort que Go 1.25 a enfin comblé en lisant le quota CPU du cgroup sous Linux.Volume 1 introduced goroutines as lightweight concurrency units. This last issue answers the question left open: how can hundreds of thousands of goroutines coexist on a handful of OS threads? The answer is the M:N scheduler — M goroutines on N OS threads — implemented by the Go runtime. The scheduler's structure rests on three entities. Gs (goroutines) are lightweight work units: each goroutine starts with about 2KB of stack and can reach 1GB through dynamic growth. Ms (machines) are OS threads: created by the runtime, they can be far more numerous than GOMAXPROCS because a blocked M (on a syscall) is replaced by a new M to not block other goroutines. Ps (processors) are execution contexts: there are exactly GOMAXPROCS of them, and each M that wants to run Gs must acquire one. GOMAXPROCS is therefore the real parallelism lever: it controls how many goroutines can run simultaneously on different cores. By default since Go 1.5, GOMAXPROCS equals the number of logical CPUs — the right default in most cases, except in containerized environments where available CPUs don't match the host machine's — a blind spot Go 1.25 finally closed by reading the cgroup CPU quota on Linux.
// Le scheduler Go : G sur M via P // // G (Goroutine) : unité de travail légère — ~2KB de pile initiale // M (Machine) : thread OS — créé par le runtime, bloque sur les syscalls // P (Processor) : contexte d'exécution — exactement GOMAXPROCS P existent // // Chaque P a une run queue locale de G à exécuter. // Un M doit acquérir un P pour exécuter des G. // Si un M bloque (syscall, cgo), un autre M prend le P et continue. // Voir l'état du scheduler en temps réel : GOMAXPROCS=4 GODEBUG=schedtrace=1000 go run ./server.go // toutes les 1000ms : goroutines en cours, en attente, threads, ... // SCHED 1000ms: gomaxprocs=4 idleprocs=2 threads=6 spinningthreads=1 runqueue=3 ...
Un M doit acquérir un P pour exécuter des G. GOMAXPROCS P → au plus GOMAXPROCS G en parallèle. Les G en attente sont dans les run queues — locales à chaque P, ou globale.An M must acquire a P to run Gs. GOMAXPROCS P → at most GOMAXPROCS Gs in parallel. Waiting Gs are in run queues — local to each P, or global.
« Mes goroutines ne progressent pas en parallèle — est-ce un bug du scheduler ? » Presque toujours non. La vraie question est : GOMAXPROCS est-il bien configuré pour l'environnement d'exécution ? Dans un container avec limite CPU de 0.5 core, GOMAXPROCS=runtime.NumCPU() lit le nombre de CPUs de l'hôte (32, 64...) et crée 32 P — dont 31 sont inutiles. Avant Go 1.25, la bibliothèque uber-go/automaxprocs corrige ça en lisant les CFS quotas Linux ; depuis Go 1.25, le runtime le fait lui-même."My goroutines don't progress in parallel — is it a scheduler bug?" Almost never. The real question is: is GOMAXPROCS properly configured for the execution environment? In a container with a 0.5 core CPU limit, GOMAXPROCS=runtime.NumCPU() reads the host's CPU count (32, 64...) and creates 32 P — 31 of which are useless. Before Go 1.25, the uber-go/automaxprocs library fixes this by reading Linux CFS quotas; since Go 1.25, the runtime does it itself.
Quand une goroutine fait un syscall bloquant (lecture fichier, appel réseau lent), le M qui l'exécute bloque avec elle. Pour ne pas laisser le P inutilisé, le runtime détache le P du M bloqué et le donne à un autre M (ou en crée un nouveau). Résultat : le nombre de threads OS (M) peut dépasser GOMAXPROCS quand beaucoup de goroutines font des I/O bloquants simultanément. La limite de M se règle avec debug.SetMaxThreads (défaut : 10 000). Les goroutines utilisant des I/O non-bloquants (net.Conn avec le network poller) ne créent pas de nouveaux M — elles se parquent dans le poller et le M est libéré.When a goroutine makes a blocking syscall (file read, slow network call), the M running it blocks with it. To not leave the P unused, the runtime detaches the P from the blocked M and gives it to another M (or creates a new one). Result: the number of OS threads (M) can exceed GOMAXPROCS when many goroutines make blocking I/O simultaneously. The M limit is set with debug.SetMaxThreads (default: 10,000). Goroutines using non-blocking I/O (net.Conn with the network poller) don't create new Ms — they park in the poller and the M is released.