Avant Go 1.6, les développeurs pouvaient passer librement des pointeurs entre Go et C, ce qui a entraîné des plantages intermittents lorsque le ramasse-miettes déplaçait des objets de tas pendant que le code C retenait des références. Pour prévenir ces violations de la sécurité de la mémoire, Go 1.6 a introduit des règles strictes de passage de pointeurs interdisant à C de stocker des pointeurs Go après le retour d'un appel. Le runtime implémente un système de vérification appelé cgocheck pour faire respecter ces contraintes pendant l'exécution du programme.
Le code C fonctionne en dehors de la gestion de mémoire du runtime Go, ce qui signifie que la mémoire allouée par C est invisible au ramasse-miettes précis. Si C stocke un pointeur vers un objet Go dans une variable globale ou une allocation de tas, et que cet objet est ensuite déplacé par le GC (dans les futures implémentations de GC mobile) ou devient inaccessibile depuis Go, la déréférenciation de ce pointeur entraîne des erreurs d'utilisation après libération ou une corruption de données. Sa détection nécessite de scanner la mémoire C pendant la collecte des ordures, ce qui est coûteux en calcul et pas faisable par défaut dans des environnements de production.
Le runtime fournit la variable d'environnement GODEBUG=cgocheck avec trois modes. Le mode 1 (par défaut) vérifie que les arguments passés aux fonctions C ne contiennent pas de pointeurs Go vers d'autres pointeurs Go. Le mode 2 permet un scan conservateur coûteux de la mémoire de pile et de tas C pendant le GC pour détecter tout pointeur Go retenu dans l'espace C, ce qui provoque une panique s'il est trouvé. Le mode 0 désactive toutes les vérifications. Le mode 2 est désactivé par défaut car il impose une surcharge de performance significative (jusqu'à 50 % de ralentissement) en traitant la mémoire C comme des racines potentielles de pointeurs à chaque cycle de GC.
Lors de la construction d'un adaptateur de file de messages à fort débit enveloppant une bibliothèque C (librdkafka), nous devions passer des charges utiles de messages sous forme de tranches d'octets de Go à C pour une transmission par lots asynchrone. La bibliothèque C a mis en file d'attente ces pointeurs dans une liste chaînée interne pour une transmission réseau ultérieure par des threads en arrière-plan, violant la règle CGO selon laquelle C ne peut pas retenir des pointeurs Go après le retour de l'appel initial. Lors des tests de charge, cela a causé des fautes de segmentation sporadiques lorsque le GC Go a récupéré les données de tableau sous-jacentes tandis que C avait encore des références.
Solution 1 - Copier vers le tas C: Nous avons envisagé de copier chaque charge utile de message dans la mémoire allouée par C en utilisant C.malloc avant de l'insérer dans la file, puis de la libérer dans le rappel de livraison. Avantages : Complètement sûr, pas de rétention de pointeur Go, fonctionne avec n'importe quelle version de Go. Inconvénients : Double allocation de mémoire (Go vers C), surcharge CPU de memcpy pour de grands messages (1 Mo+), et risque de fuites de mémoire si le rappel C ne libère pas le tampon lors des délais d'attente réseau.
Solution 2 - Utiliser cgo.Handle : Nous avons évalué la possibilité de stocker la tranche d'octets Go dans un cgo.Handle (un jeton entier) et de passer uniquement l'entier à C, nécessitant un rappel pour récupérer les données. Avantages : Zero-copy pour la charge utile, gestion référentielle sûre, et modèle idiomatique Go 1.17+ pour le stockage à long terme en C. Inconvénients : Nécessite la mise en œuvre d'un mécanisme de rappel dans le code C, augmente la latence en raison de la traversée de frontière CGO supplémentaire pour la récupération des données, et la table des handles devient illimitée si C ne signale jamais l'achèvement.
Solution 3 - Fixation à l'exécution (Go 1.21+) : Nous avons exploré l'utilisation de runtime.Pinner pour empêcher le GC de déplacer ou de collecter la tranche d'octets tant que C détient la référence. Avantages : Vraie zero-copy sans allocation de tas C, partage direct de mémoire, et surcoût API minimal. Inconvénients : Nécessite Go 1.21+, gestion manuelle du cycle de vie (risque de fuites de mémoire si Unpin n'est pas appelé sur tous les chemins d'erreur), et le débogage de la mémoire fixée est difficile car elle apparaît comme des objets de tas persistants dans les profils.
Nous avons sélectionné cgo.Handle (Solution 2) car l'architecture de l'adaptateur nécessitait déjà un rappel de confirmation de livraison. Cette approche a éliminé la copie de données pour notre exigence de débit de 100 Mo/s tout en maintenant la sécurité à travers les versions de Go. Nous avons ajouté la suppression explicite de l'handle dans les rappels de succès et d'erreur pour prévenir les fuites.
Le système a obtenu des latences stables au 99,9ème percentile sous 10 ms et a traité plus de 500k messages/seconde en production. Il a passé des tests de stress d'une semaine avec GODEBUG=cgocheck=2 activé pour vérifier qu'aucune violation de pointeur n'était présente. Les profils mémoire ont confirmé zéro fuite due à l'accumulation des handles grâce à un nettoyage approprié dans tous les chemins de code.
Pourquoi le mode par défaut cgocheck=1 ne parvient-il pas à détecter les pointeurs Go stockés dans des variables globales C après le retour de l'appel ?
Le mode par défaut ne valide que les arguments immédiats et les valeurs de retour traversant la frontière CGO pour les violations de pointeur vers pointeur ; il ne scanne pas la mémoire C (variables globales, tas ou pile) pour les pointeurs Go retenus. Seule GODEBUG=cgocheck=2 active le scan conservateur de la mémoire C pendant la collecte des ordures pour détecter de telles rétentions. Cette vérification coûteuse est désactivée par défaut car elle nécessite de traiter toute la mémoire C comme des racines potentielles pour le GC, ce qui augmente significativement les temps de pause et l'utilisation CPU pendant les cycles de collecte.
Comment cgo.Handle empêche le ramasse-miettes de récupérer la valeur Go référencée tant que le code C détient le jeton entier ?
cgo.Handle stocke la valeur Go dans une carte interne du runtime (dans le package runtime/cgo) en utilisant l'entier comme clé. Puisque la carte maintient une référence à la valeur, le ramasse-miettes la marque comme accessible pendant le scan des racines et ne récupérera pas la mémoire. Le jeton entier passé à C ne contient aucune métadonnée de pointeur, de sorte que C peut le stocker indéfiniment sans interférer avec la gestion de la mémoire de Go. Lorsque C invoque le rappel ou que Go supprime explicitement l'handle, l'entrée de la carte est supprimée, lâchant la référence et permettant une collecte normale.
Quelle panique spécifique indique une violation de passage de pointeur CGO pendant un appel de fonction, et quel drapeau d'exécution modifie sa sensibilité à la détection ?
Le runtime émet runtime error: cgo argument has Go pointer to Go pointer lorsque cgocheck=1 détecte un pointeur vers une mémoire Go à l'intérieur d'un argument passé à C. Pour une détection plus large incluant les pointeurs stockés dans la mémoire C, GODEBUG=cgocheck=2 doit être activé, ce qui peut produire runtime: cgo result contains Go pointer ou des erreurs fatales similaires lors du scan GC. Ces panics indiquent que le code C a violé le contrat en retenant ou en recevant des pointeurs vers une mémoire gérée par Go qui pourrait devenir invalide pendant la collecte des ordures.