Histoire Avant Go 1.19, le runtime n'offrait que GOGC pour contrôler la collecte des déchets, qui ajuste le déclenchement du tas par rapport à la mémoire vive. Cela s'est avéré insuffisant pour les déploiements en conteneurs où les cgroups imposent des limites de mémoire absolues. Les développeurs faisaient face à des OOM kills car le runtime n'avait pas de notion de plafond.
Problème Lorsqu'un processus Go s'exécute à l'intérieur d'un conteneur avec une limite de mémoire stricte (par exemple, 512 MiB via Docker ou Kubernetes), le GOGC=100 par défaut permet au tas de doubler avant de déclencher le GC. Sans connaissance de la limite du conteneur, le runtime alloue jusqu'à ce que le noyau invoque le tueur OOM, faisant planter le processus plutôt que de privilégier la survie.
Solution Go 1.19 a introduit GOMEMLIMIT, une limite de mémoire douce imposée par le runtime. Contrairement à un plafond strict, cela n'arrête pas les allocations mais modifie le rythme du GC. Lorsque la taille du tas (y compris les piles, les données globales et les frais généraux du runtime) approche la limite, le runtime calcule un nouveau point de déclenchement du GC plus agressif que ne le suggérerait GOGC. Il utilise la formule : si le prochain cycle de GC dépasserait la limite, déclencher immédiatement. Cela peut faire passer les cycles de GC à 100 % de CPU si nécessaire, échangeant le débit contre la stabilité.
import "runtime/debug" // Définir la limite douce à 400 MiB // La valeur est en octets ; 0 désactive la limite debug.SetMemoryLimit(400 << 20) // Alternativement via la variable d'environnement GOMEMLIMIT=400MiB
La crise Notre pipeline de traitement de données consommait de grands fichiers CSV, faisant grimper la mémoire à 600 MiB pendant l'analyse. Déployés sur Kubernetes avec une limite de 512 MiB, les pods mourraient avec le statut OOMKilled chaque heure. Le GOGC par défaut maintenait le ratio de tas trop élevé pour l'environnement contraint.
Solution 1 : Ajustement agressif de GOGC Nous avons envisagé de régler GOGC=20 pour forcer des collectes plus précoces. Cela a réduit la mémoire maximale à environ 480 MiB. Cependant, l'utilisation du CPU a grimpé de 10 % à 40 % en permanence, même pendant les périodes d'inactivité lorsque la pression de la mémoire était faible. Cela a gaspillé des ressources et dégradé la latence inutilement.
Solution 2 : Déclenchement manuel du GC Nous avons mis en œuvre un watchdog de mémoire qui appelait runtime.GC() chaque fois que runtime.ReadMemStats() rapportait des allocations élevées. Cela était fragile ; cela nécessitait des surcharges de sondage et souvent se déclenchait trop tard lors de pics soudains, ou trop tôt provoquant des thrashing. Cela ignorait également le rythme nuancé que le runtime pouvait fournir.
Solution 3 : Intégration de GOMEMLIMIT Nous avons défini GOMEMLIMIT=400MiB (laissant de la marge pour les pics de pile) via le manifeste de déploiement. Le runtime a automatiquement augmenté la fréquence du GC à mesure que la mémoire augmentait. Pendant les périodes d'inactivité, le GC restait peu fréquent ; pendant l'analyse CSV, la collecte se déroulait presque en continu mais maintenait la mémoire à 400 MiB. Nous avons accepté le compromis CPU uniquement sous pression.
Décision et résultat Nous avons choisi Solution 3 car elle respectait le contrat du conteneur sans instrumentation manuelle. Le service s'est stabilisé : zéro OOM kills pendant 30 jours. L'utilisation du CPU par le GC a atteint en moyenne 8 % (contre 40 % avec un GOGC statique) et a grimpé à 25 % uniquement pendant les analyses intensives, ce qui était acceptable pour la fiabilité gagnée.
Comment GOMEMLIMIT tient-il compte de la mémoire de pile des goroutines dans ses calculs ?
Beaucoup supposent que GOMEMLIMIT ne suit que les objets du tas. En réalité, la limite englobe toute la mémoire mappée par le runtime Go : le tas, les piles de goroutines, les métadonnées du runtime et les allocations CGO. Le runtime met périodiquement à jour son estimation de la mémoire utilisée via la métrique sys. Si des milliers de goroutines augmentent simultanément leurs piles, cela compte pour la limite et peut déclencher le GC même si le tas est faible. Les candidats oublient souvent qu'il s'agit d'une limite de "mémoire totale", pas seulement de tas.
Que se passe-t-il avec la latence d'allocation lorsque le tas en direct dépasse en permanence GOMEMLIMIT ?
Les candidats croient souvent que GOMEMLIMIT agit comme un plafond strict qui bloque l'allocation. C'est en réalité une cible douce. Si le tas en direct après un cycle de GC est déjà supérieur à la limite (par exemple, chargement d'un ensemble de données massif et inéluctable), le runtime définit le prochain point de déclenchement du GC égal à la taille actuelle du tas, provoquant le GC à chaque allocation. Ce "GC thrashing" privilégie la vivacité par rapport au débit. Le programme ralentit de manière spectaculaire mais ne panique pas ou ne plante pas à cause de la limite elle-même ; il peut toujours OOM si la limite OS est atteinte, mais GOMEMLIMIT essaie d'éviter cela en maximisant l'effort de récupération.
Pourquoi GOMEMLIMIT pourrait-il causer une dégradation des performances même lorsque l'utilisation de la mémoire semble bien en dessous de la limite ?
Cela concerne le scavenger et les heuristiques de pacing. Lorsqu'il est proche de la limite, le runtime non seulement exécute le GC plus souvent mais retourne également la mémoire physique à l'OS plus agressivement via MADV_DONTNEED. Si l'application a un motif d'allocation en dent de scie (pic puis inactivité), le scavenger pourrait libérer des pages, juste pour que le prochain pic les appelle à nouveau. Cette "tempête de fautes de pages" apparaît comme des pics de latence. Les candidats ratent que GOMEMLIMIT interagit avec GOGC via un calcul de déclenchement minimum : la limite fixe effectivement un seuil sur la fréquence du GC, ce qui peut remplacer GOGC même lorsque la mémoire semble sûre si le runtime prédit que la croissance dépassera la limite.