L'ordonnanceur de Go adopte un modèle hybride de multitâche coopératif et préemptif pour éviter la starvation sans intervention du système d'exploitation. Depuis la version 1.14, le runtime injecte des points de préemption asynchrones en envoyant des signaux SIGURG aux threads exécutant des goroutines qui dépassent leur tranche de temps (généralement 10 ms). Lorsque le gestionnaire de signaux détecte un point sûr - par exemple lorsque le goroutine est sur le point d'appeler une fonction ou d'accéder à la pile - l'ordonnanceur sauvegarde le contexte et passe à une autre goroutine exécutable. Ce mécanisme garantit que même les boucles CPU-centrées étroites sans appels de fonction ne peuvent pas monopoliser un Processeur (P) indéfiniment.
Notre plateforme de trading haute fréquence a connu des pics de latence catastrophiques lors de la volatilité du marché, où un seul goroutine d'analyse effectuant des simulations de Monte Carlo complexes gelait les pipelines de traitement des commandes pendant des centaines de millisecondes. Le problème provenait du goroutine exécutant une boucle mathématique étroite sans appels de fonction, empêchant l'ordonnanceur de le préempter avant Go 1.14.
Nous avons évalué trois approches distinctes pour résoudre cette contention. La première option consistait à insérer manuellement des appels runtime.Gosched() dans les boucles de simulation. Cette approche offrait une atténuation immédiate mais introduisait une charge de maintenance significative et exigeait des développeurs qu'ils possèdent une connaissance approfondie de l'ordonnanceur, créant un code fragile qui pourrait régresser s'il était refactorisé.
La deuxième solution proposait d'isoler la charge de travail d'analyse dans un microservice séparé avec des limites de CPU. Bien que cela fournisse une isolation stricte et une mise à l'échelle indépendante, la surcharge de sérialisation réseau et la latence supplémentaire de la communication inter-processus violaient nos exigences de latence inférieure à une milliseconde pour les calculs de risque.
Nous avons finalement opté pour la mise à niveau du runtime vers Go 1.20 et le réglage explicite de GOMAXPROCS pour correspondre aux cœurs physiques du CPU. Cette mise à niveau a fourni une préemption asynchrone via des signaux, permettant à l'ordonnanceur de céder de force le goroutine lié au CPU toutes les 10 ms sans modifications du code. Les métriques post-déploiement ont montré une latence P99 stabilisée à 8 ms pendant les pics de charge, éliminant les cascades de timeout et préservant la simplicité architecturale d'un seul processus.
Pourquoi une boucle étroite sans appels de fonction cause-t-elle des problèmes de planification dans les anciennes versions de Go mais pas dans les nouvelles ?
Avant Go 1.14, l'ordonnanceur s'appuyait exclusivement sur la préemption coopérative, ce qui signifie que les goroutines cédaient volontairement uniquement lors des appels de fonction, des opérations sur les canaux ou de la contention de mutex. Une boucle étroite effectuant des opérations arithmétiques pures n'atteignait jamais un point sûr, monopoliser son Processeur (P) jusqu'à l'achèvement. La Go moderne utilise la préemption asynchrone en envoyant des signaux SIGURG au thread, déclenchant un changement de contexte au prochain point sûr peu importe qu'un appel de fonction se produise ou non.
Comment l'ordonnanceur de Go décide-t-il quelle goroutine s'exécute ensuite lorsqu'un Processeur (P) devient disponible ?
L'ordonnanceur met en œuvre un algorithme de vol de travail qui vérifie d'abord la file d'exécution locale du P actuel, puis tente de voler la moitié des goroutines d'une autre file locale de P en utilisant un index de départ aléatoire pour réduire la contention. Si les files locales sont vides, il vérifie la file d'exécution globale toutes les 61 ticks d'ordonnanceur pour éviter la starvation des nouvelles goroutines créées. Cette sélection hiérarchique minimise les coûts de synchronisation tout en garantissant un équilibrage de charge entre tous les threads Machine (M) disponibles.
Que se passe-t-il pour le Processeur (P) lorsqu'un goroutine exécute un appel système bloquant tel que l'E/S de fichiers ?
Lorsqu'un goroutine se bloque sur un appel système, le runtime Go détache immédiatement le thread Machine (M) de son P et assigne ce P à un M nouveau ou inactif, permettant à d'autres goroutines de continuer à s'exécuter sur la même abstraction de thread OS. Le M original entre dans l'appel système et attend que le noyau termine l'opération ; à son retour, il tente de récupérer son P original ou se gare s'il est maintenant attribué à un thread différent. Ce multiplexage M:N empêche les threads OS de rester inactifs pendant les E/S, maintenant une haute utilisation du CPU à travers des milliers de goroutines.