Mémoire et ramasse-miettes en Go : comment ne pas perdre la tête avec le GC
\nAvez-vous déjà réfléchi à où va la mémoire dans votre application Go ? Surtout quand elle commence à engloutir des gigaoctets alors que vous ne faites que parser du JSON ? Ça vous parle ? Bienvenue dans le monde du ramasse-miettes (GC) en Go — ce mécanisme mystérieux qui soit sauve votre projet, soit le transforme en plateforme lente. Aujourd'hui, nous allons voir comment fonctionne la mémoire en Go, comment le GC décide qu'il est temps de nettoyer, et comment éviter le piège de "l'attente STW".
\nSi vous codez en Go depuis au moins quelques mois, vous avez déjà rencontré cela : des pauses soudaines, une augmentation de la consommation mémoire, des gc pause mystérieux dans les profils. Regardons sous le capot et comprenons comment gérer la mémoire en Go de manière consciente.
Comment est structurée la mémoire en Go : tas et pile
\nAvant de parler du ramasse-miettes, comprenons où vivent les données en Go. Contrairement à des langages comme le C, où vous êtes responsable du malloc/free, Go utilise une gestion automatique de la mémoire. Mais cela ne signifie pas que tout est jeté en vrac dans un seul tas.
\nEn Go, il y a deux régions mémoire principales :
\n- \n
- La pile (stack) — mémoire rapide et gérée automatiquement pour les variables locales. Chaque goroutine a sa propre pile (commence à 2 Ko et croît dynamiquement). \n
- Le tas (heap) — mémoire plus lente mais flexible pour les données qui "survivent" à l'appel d'une fonction. \n
La question principale : qu'est-ce qui va dans le tas, et qu'est-ce qui reste sur la pile ? C'est là qu'intervient l'escape analysis — un mécanisme intelligent du compilateur Go. Il analyse si une variable "s'échappe" en dehors de la fonction. Si oui, elle va dans le tas. Sinon, elle reste sur la pile, et le GC ne la verra même pas.
\nExemple :
\npackage main\n\nfunc createPointer() *int {\n x := 42\n return &x // x "s'échappe" — va dans le tas\n}\n\nfunc noEscape() int {\n x := 42\n return x // x reste sur la pile\n}\n\nDans le premier cas, le compilateur voit que le pointeur vers x est renvoyé à l'extérieur et place la variable dans le tas. Dans le second, tout est simple et rapide. C'est cela, la gestion de la mémoire en Go au niveau micro.
Le ramasse-miettes en Go : comment il fonctionne
\nParlons maintenant du personnage principal — le ramasse-miettes (GC). En Go, on utilise un ramasse-miettes parallèle, tricolore, non incrémental avec un algorithme "mark-and-sweep". Ça fait peur ? En réalité, tout est logique.
\nLe GC en Go passe par trois phases :
\n- \n
- Mark Setup (STW) — une courte pause pour activer le write barrier. Prend généralement 10 à 30 microsecondes. \n
- Marking (Concurrent) — le travail principal : le GC parcourt le graphe d'objets, marquant les vivants. S'exécute en parallèle avec votre code. \n
- Sweep (Concurrent + STW) — le nettoyage des objets morts. Partiellement fait en parallèle, mais la synchronisation finale peut provoquer une courte pause. \n
Une caractéristique clé du GC en Go est qu'il ne fait pas de défragmentation. Cela signifie qu'après le nettoyage, la mémoire peut se fragmenter, mais Go utilise des bitmaps pour suivre les blocs libres, ce qui rend l'allocation mémoire rapide.
\nOn peut voir le fonctionnement du GC dans les logs :
\nGODEBUG=gctrace=1 go run main.go\n\ngc 1 @0.013s 4%: 0.031+0.42+0.017 ms clock, 0.25+0.10/0.42/0.0+0.13 ms cpu, 4->4->2 MB, 5 MB goal, 8 P\n\nIci : 0.031+0.42+0.017 ms correspond à STW + Mark + STW. 4->4->2 MB est la taille du tas avant, après mark et après sweep.
Pourquoi le GC ralentit : STW et pauses
\nLe principal ennemi de la performance est le STW (Stop The World). Bien que Go essaie de minimiser les pauses, elles arrivent quand même. Surtout si :
\n- \n
- Vous avez beaucoup de pointeurs (le GC doit parcourir tous les objets) \n
- Le tas est volumineux (plus d'objets = marking plus long) \n
- Vous créez beaucoup de déchets (high allocation rate) \n