Avant Go 1.14, le compilateur allouait une structure _defer sur le tas pour chaque instruction defer, la reliant à une liste liée par goroutine. Cela imposait une pression significative sur le GC et entraînait une surcharge de O(n) pour des différés profondément imbriqués.
Go 1.14 a introduit les differés alloués sur la pile, permettant au compilateur de placer les structures _defer directement sur la trame de la pile de la fonction lorsque l'analyse des fuites prouve qu'elles ne survivent pas à la fonction. Les versions ultérieures ont ajouté des differés codés en dur (Go 1.17+), où le compilateur insère le code de nettoyage directement dans l'épilogue de la fonction plutôt que d'utiliser des appels d'exécution.
Lors de la récupération après une panique, le runtime défait la pile trame par trame. Il exécute tous les différés alloués sur la pile trouvés dans les trames actives, suivis de tous les différés alloués sur le tas restants de la liste liée. Cette approche hybride préserve un ordre LIFO strict tout en éliminant le coût d'allocation dans le cas commun.
Un wrapper API de trading haute fréquence écrit en Go subissait des pauses de GC de 200 millisecondes lors de la volatilité du marché.
L'équipe a retracé le problème à une allocation excessive sur le tas. Chaque gestionnaire de requêtes HTTP avait utilisé plusieurs instructions defer pour tx.Rollback() et le nettoyage des connexions. Sous charge, cela générait des millions de structures _defer par seconde, déclenchant des cycles fréquents de collecte de déchets.
Solution A : Gestion manuelle des ressources. L'équipe a envisagé de supprimer tous les appels defer et d'utiliser explicitement Close() et Rollback() à chaque point de retour. Avantages : Surcharge d'allocation nulle et performances prévisibles. Inconvénients : Le code est devenu fragile et sujet aux erreurs, avec une logique de nettoyage dupliquée dans des dizaines de chemins de sortie.
Solution B : Pooling d'objets. Ils ont tenté de mettre en pool les objets de transaction de base de données eux-mêmes. Avantages : Réduction des allocations dans le code utilisateur. Inconvénients : Cela n'a pas résolu les allocations de structures _defer, car celles-ci sont internes au runtime et ne peuvent pas être mises en pool par le code utilisateur.
Solution C : Mise à niveau du compilateur et refactorisation. L'équipe a mis à niveau de Go 1.13 à 1.18 et a refactorisé les closures pour éviter de capturer des variables qui fuyaient vers le tas. Avantages : Allocation automatique sur la pile et codage ouvert des différés avec un coût d'exécution nul dans la plupart des cas. Inconvénients : A nécessité des tests de régression approfondis pour vérifier que le comportement de récupération après une panique restait correct.
Ils ont choisi la Solution C. Après déploiement, les temps de pause du GC ont chuté à moins d'une milliseconde, et le débit des requêtes a augmenté de 40 % sans aucun changement de logique commerciale.
Pourquoi le fait de différer une fonction qui modifie un paramètre de retour nommé affecte-t-il la valeur finale retournée, et quand ce modèle échoue-t-il avec des retours non nommés ?
Lorsqu'une fonction Go utilise des valeurs de retour nommées (par exemple, func f() (err error)), la fonction différée se ferme sur l'emplacement de la pile réel de ce paramètre de retour. Toute affectation à ce nom à l'intérieur du defer modifie la valeur qui sera retournée à l'appelant. Avec des retours non nommés, la valeur de retour est copiée dans un registre temporaire ou un emplacement de pile avant que les fonctions différées ne s'exécutent, rendant les modifications à l'intérieur du defer invisibles pour l'appelant. Les candidats manquent souvent que defer voit la valeur finale des résultats nommés au moment de la sortie réelle de la fonction, pas au moment de l'enregistrement du defer.
Qu'est-ce qui cause aux fonctions différées à l'intérieur d'une boucle serrée d'afficher des caractéristiques de performance O(n²) dans les anciennes versions de Go, et pourquoi l'allocation sur la pile n'élimine-t-elle pas entièrement ce coût ?
Dans les versions de Go antérieures à 1.14, placer defer à l'intérieur d'une boucle for allouait un nouvel objet sur le tas par itération, l'ajoutant à une liste liée. Cela créait une complexité quadratique à mesure que la liste grandissait linéairement avec les itérations. Bien que Go 1.14+ alloue ceux-ci sur la pile, le runtime doit toujours défait et exécuter ces différés dans l'ordre inverse lors de la sortie de la fonction. Si une fonction différée n opérations, le chemin de sortie nécessite un temps O(n) pour les traiter. Les candidats manquent souvent que différer à l'intérieur de boucles reste un antipattern même avec l'allocation sur la pile ; le nettoyage manuel fournit une surcharge O(1) par itération plutôt qu'une agrégation O(n) à l'échelle de la fonction.
Comment l'interaction entre la récupération après une panique et les fonctions différées empêche un appel différé d'être repris s'il lui-même panique, et ce qui distingue cela de l'exécution séquentielle ?
Lorsqu'une fonction Go panique, le runtime défait la pile, invoquant les fonctions différées séquentiellement. Si une fonction différée panique elle-même sans un recover() correspondant, cette nouvelle panique remplace la valeur de panique originale. Il est crucial qu'une fois qu'une panique remonte d'une fonction différée, le runtime cesse d'exécuter tous les différés restants dans ce cadre spécifique et continue de défait vers le haut. Les candidats manquent souvent que les différés ne sont pas transactionnels ; ils ne déroulent pas les effets si un différé ultérieur panique, et une panique à l'intérieur d'un defer annule le reste de la chaîne de defer pour ce cadre, potentiellement fuite de ressources si les différés ultérieurs étaient censés effectuer un nettoyage critique.