Avant Go 1.3, le runtime utilisait des piles segmentées qui se divisaient en morceaux liés aux frontières des appels de fonction. Cette conception entraînait des chutes de performance sévères appelées "hot split" lorsque la frontière de la pile était franchie fréquemment lors de boucles serrées. Go 1.3 a remplacé cela par des piles contiguës qui sont copiées dans des régions contiguës plus grandes lors de la croissance. Cependant, les premières implémentations des piles contiguës n'ont jamais libéré de mémoire vers le tas, entraînant une croissance permanente de l'utilisation mémoire pour les goroutines nécessitant temporairement des piles d'appels profondes pendant l'initialisation ou le traitement par lots. Go 1.5 a introduit le rétrécissement automatique des piles pour récupérer la mémoire de pile inutilisée lors des cycles de collecte des déchets, complétant ainsi le cycle de gestion de la mémoire pour les piles de goroutines.
Sans mécanisme de rétrécissement, une goroutine qui entre temporairement en récursion profonde (par exemple, le traitement d'un document JSON profondément imbriqué ou le parcours d'un arbre de dépendance complexe) conserverait son allocation de pile maximale indéfiniment même après être revenue à une boucle d'événements inactive. Cela entraîne un gonflement de la mémoire dans les applications à long terme, en particulier celles utilisant des pools de travailleurs où les goroutines alternent entre des tâches à haute pile et des états inactifs. Le défi réside dans l'identification en toute sécurité du moment où une pile est réellement sous-utilisée et le déplacement de cadres actifs vers une région mémoire plus petite sans corrompre les calculs en cours, les pointeurs alloués sur la pile ou violer les exigences ABI pour les conventions d'appel.
Le runtime de Go rétrécit les piles pendant la phase de marquage de la collecte des déchets lors du scan des ensembles racines. Il examine l'utilisation de la pile de chaque goroutine ; si la marque haute de la portion utilisée tombe en dessous d'un quart (25 %) de la taille de la pile actuellement allouée, le runtime alloue une nouvelle pile d'une taille moitié de l'actuelle (mais jamais plus petite que le minimum de 2 Ko). Le runtime arrête ensuite de manière asynchrone la goroutine cible à un point sûr, copie les cadres de pile actifs vers la nouvelle région plus petite, utilise des cartes de pointeurs générées par le compilateur pour mettre à jour tous les pointeurs intérieurs faisant référence aux adresses de pile et libère la mémoire de l'ancienne pile vers l'allocateur mheap du runtime.
Nous avons géré un service de traitement de logs à haut débit où chaque goroutine s'occupait de l'analyse de charges JSON potentiellement profondément imbriquées (jusqu'à 10 000 niveaux de profondeur lors d'attaques d'entrées mal formées). Après le traitement, ces goroutines retournaient à un sync.Pool pour attendre de nouvelles connexions. Nous avons observé que la mémoire RSS du service augmentait linéairement avec le nombre de goroutines en pool, ne libérant jamais de mémoire même pendant les périodes d'inactivité, déclenchant finalement des kills OOM sur des conteneurs avec des limites de 4 Go malgré le fait que l'ensemble de travail réel n'était que de 200 Mo.
Nous avons envisagé de tuer de force les goroutines mises en pool après un nombre défini de requêtes traitées et de faire apparaître de nouvelles remplacements. Cela garantirait la libération de la mémoire de pile puisque les nouvelles goroutines commencent avec des piles minimales de 2 Ko. Cependant, cette approche introduisait une surcharge CPU significative due à la création et à la destruction constantes de goroutines, perturbait les optimisations de mise en pool des connexions TCP et causait une latence plus élevée en raison des démarrages à froid du cache.
La mise en œuvre d'une limite stricte sur la croissance de la pile via debug.SetMaxStack empêcherait une allocation excessive pendant les événements de récursion profonde. Bien que cela protégeait contre OOM, cela provoquait des tâches de parsing légitimes mais profondes de panic avec runtime: goroutine stack exceeds 1000000000-byte limit. Cela entraînait des données client perdues et des erreurs de service qui violaient nos SLA de fiabilité, rendant cela inacceptable pour la production.
Nous avons évalué l'appel périodique à runtime.GC() suivi de debug.FreeOSMemory() toutes les 30 secondes pour forcer la vérification et le rétrécissement de la pile. Cela a réussi à réduire la RSS mais a introduit des pauses stop-the-world de 5 à 10 ms à chaque invocation, ce qui violait nos exigences de latence p99 de <2 ms pour le niveau API et augmentait l'utilisation CPU de 15 % en raison des collections complètes forcées.
Nous avons finalement compté sur le mécanisme natif de rétrécissement des piles de Go en nous assurant d'exécuter Go 1.20+ et en réglant GOGC pour déclencher des collectes des déchets plus fréquentes (en le fixant à 50 au lieu de 100). Cela a augmenté la fréquence des opportunités de rétrécissement de la pile sans intervention manuelle. Nous avons également restructuré le parseur pour utiliser une approche itérative avec une pile explicitement allouée sur le tas pour le suivi des chemins, réduisant la profondeur de récursion maximale de 10 000 à 100. La combinaison a permis un rétrécissement naturel suffisamment fréquent pour maintenir la mémoire contenue.
La RSS du service s'est stabilisée à environ 800 Mo sous charge, contre 3,8 Go précédemment. Les profils de piles de goroutines ont montré que 95 % des travailleurs en pool ont maintenu la taille minimale de pile de 2 Ko entre les requêtes, avec des pics n'apparaissant que pendant l'analyse active. Les kills OOM ont cessé complètement, et la latence p99 est restée inférieure à 1,5 ms puisque nous avons évité les pauses manuelles de collecte des déchets et le changement de goroutines.
Le rétrécissement de la pile se produit-il immédiatement lorsqu'une fonction retourne et que le pointeur de pile diminue ?
Non, le runtime ne surveille pas les diminutions du pointeur de pile en temps réel pour déclencher une désallocation immédiate. Le rétrécissement est exclusivement effectué pendant la phase de marquage de la collecte des déchets lorsque le planificateur scanne toutes les piles de goroutines. Le runtime vérifie la marque haute de l'utilisation de la pile depuis la dernière collecte des déchets. Si cette marque haute est inférieure à 25 % de l'allocation physique actuelle, seulement alors la logique de rétrécissement s'exécute. Cette évaluation paresseuse amorte le coût de copie des piles entre toutes les goroutines pendant une période où le monde est déjà interrompu pour le marquage, bien que la copie réelle nécessite l'arrêt de la goroutine individuelle.
Quel est le rapport de rétrécissement exact et la taille minimale, et le runtime libère-t-il jamais de mémoire vers le système d'exploitation ?
Lorsque une pile est éligible pour le rétrécissement, le runtime alloue une nouvelle pile de la moitié de la taille de l'actuelle. Cette réduction géométrique empêche le thrash où une goroutine oscillant légèrement au-dessus et en dessous d'un seuil rejoindrait constamment croître et rétrécir. La nouvelle taille est limitée par la taille minimale de pile de la plateforme, généralement 2 Ko sur les systèmes 64 bits. La mémoire de l'ancienne pile est retournée à l'mheap du runtime, et non directement au système d'exploitation. Le système d'exploitation ne récupère cette mémoire physique que si le collecte de déchets détermine que le tas a été inactif et dépasse l'objectif, ou si debug.FreeOSMemory() est invoqué.
La goroutine est-elle arrêtée lors du rétrécissement de la pile, et comment les pointeurs sont-ils mis à jour ?
Oui, le rétrécissement nécessite d'arrêter la goroutine cible à un point sûr, similaire à la croissance de la pile. Le runtime doit copier les cadres actifs vers un nouvel emplacement mémoire et mettre à jour tous les pointeurs qui font référence aux variables allouées sur la pile. Le compilateur génère des cartes de pointeurs qui identifient quels mots dans chaque cadre sont des pointeurs. Pendant le rétrécissement, le runtime utilise ces cartes pour trouver et ajuster les pointeurs intérieurs afin qu'ils pointent vers les nouvelles adresses de pile. Cette opération n'est pas concurrente ; la goroutine ne peut pas s'exécuter pendant la copie, mais d'autres goroutines continuent à s'exécuter.