PythonProgrammationDéveloppeur Python

Dans quelles circonstances le collecteur de déchets cyclique de **Python** refuse-t-il de détruire des objets qui se réfèrent mutuellement de manière circulaire, malgré le fait qu'il les détecte comme inaccessibles ?

Réussissez les entretiens avec l'assistant IA Hintsage

Réponse à la question

Historique de la question

Ce sujet provient de l'évolution de Python d'un comptage de références pur à un modèle de collecte des déchets hybride introduit dans Python 2.0. Le problème principal est apparu lorsque les développeurs ont utilisé des méthodes de finalisation (__del__) pour gérer des ressources externes comme des poignées de fichiers ou des sockets réseau. Lorsque des objets avec des finalisateurs ont formé des références circulaires, Python n'a pas pu déterminer un ordre de destruction sûr, ce qui a potentiellement causé des plantages ou des fuites de ressources. Cette limitation a conduit à la mise en œuvre du module de collecteur de déchets cyclique (gc) et au traitement spécial des déchets "non collectables".

Le problème

Lorsqu'un groupe d'objets forme un cycle de référence et qu'au moins un définit une méthode __del__ personnalisée, Python est confronté à un dilemme de destruction déterministe. L'interpréteur ne peut pas décider quel objet finaliser en premier parce que le cycle implique une dépendance mutuelle, et détruire l'un pourrait laisser les autres dans un état invalide. Par conséquent, Python déplace ces objets dans la liste gc.garbage plutôt que de libérer leur mémoire. Ce comportement persiste dans les versions modernes lorsque les finalisateurs empêchent une collecte sûre, entraînant des fuites de mémoire progressives dans les applications de longue durée.

La solution

La solution définitive consiste à éviter complètement les méthodes __del__ en faveur des gestionnaires de contexte (with statements) ou des rappels weakref pour le nettoyage des ressources. Si les finalisateurs sont inévitables, il faut briser explicitement les cycles de référence avant que les objets ne deviennent inaccessibles en définissant les variables d'instance sur None dans les méthodes de nettoyage. À partir de Python 3.4, le collecteur de déchets peut collecter des cycles avec des finalisateurs dans de nombreux cas en ordonnant soigneusement la finalisation, mais la gestion explicite des ressources reste le modèle le plus fiable.

import gc class Resource: def __init__(self, name): self.name = name self.peer = None def __del__(self): print(f"Nettoyage de {self.name}") # Création d'un cycle avec des finalisateurs a = Resource("A") b = Resource("B") a.peer = b b.peer = a # Supprimer les références externes del a, b gc.collect() print(f"Non collectable : {gc.garbage}") # Peut contenir des objets dans des scénarios complexes

Situation de la vie réelle

Nous avons maintenu un pipeline de traitement de données à haut débit où des objets Node représentaient des étapes de calcul dans un graphique. Chaque nœud détenait des références à ses voisins et contenait une méthode __del__ pour libérer des poignées de mémoire GPU. Lors de charges de travail intensives, nous avons observé une croissance monotone de la mémoire malgré l'absence apparente de fuites de mémoire dans le profilage. Une enquête a révélé que des topologies de graphe complexes avaient créé des cycles de référence entre les nœuds, et la présence de méthodes __del__ empêchait le GC cyclique de récupérer ces objets, provoquant leur accumulation dans gc.garbage jusqu'à la fin du processus.

Solution 1 : Refactoriser vers des gestionnaires de contexte

Nous avons envisagé de remplacer __del__ par des méthodes explicites acquire() et release() appelées via des gestionnaires de contexte. Cette approche éliminerait complètement la barrière des finalisateurs à la collecte des déchets et fournirait un nettoyage des ressources déterministe. Cependant, cela aurait nécessité de modifier des milliers de lignes de code de construction de graphe et risquait des fuites de ressources si les développeurs oubliaient d'encapsuler l'utilisation des nœuds dans des blocs with, surtout dans les composants basés sur des rappels hérités.

Solution 2 : Mettre en œuvre des références faibles pour les arêtes du graphe

Nous avons exploré le changement de toutes les références de voisin à des objets weakref.ref, ce qui permettrait aux nœuds d'être collectés immédiatement lorsque plus aucune référence externe n'était présente, quelles que soient les connexions du graphe. Bien que cela soit élégant, cela a introduit une complexité significative car les algorithmes de parcours de graphe devaient constamment vérifier la présence de références faibles mortes et gérer les nœuds "fantômes" transitoires lors de l'itération. Cette approche a considérablement dégradé les performances pour notre cas d'utilisation et a nécessité un refactoring extensif de la logique de parcours de graphe.

Solution 3 : Briser explicitement le cycle via un protocole de nettoyage

Nous avons mis en œuvre une méthode destroy() qui définissait explicitement self.neighbors = [] et self.gpu_handle = None avant de retirer les nœuds du graphe. Cela a brisé les cycles de manière déterministe tout en conservant l'API existante intacte. Nous avons choisi cette solution parce qu'elle a localisé les changements dans la logique de suppression de nœuds plutôt que de répandre des préoccupations à travers l'ensemble de la base de code, et elle a maintenu la compatibilité ascendante avec les algorithmes de graphe existants.

Résultat

Après avoir mis en œuvre le protocole de nettoyage explicite et ajouté des assertions pour vérifier que gc.garbage restait vide pendant les tests CI, l'utilisation de la mémoire s'est stabilisée à un niveau constant. Le service a fonctionné pendant des semaines sans accumuler la mémoire précédente. Nous avons également documenté le modèle pour assurer que de futurs développeurs comprennent l'interaction entre les finalisateurs et les références cycliques.

Ce que les candidats oublient souvent

Pourquoi gc.garbage contient-il toujours des objets dans Python 3.4+ même lorsqu'il y a des finalisateurs dans les cycles ?

Bien que Python 3.4 ait considérablement amélioré le GC cyclique pour gérer les finalisateurs en les invoquant dans un ordre sûr et en nettoyant les références par la suite, des objets peuvent encore apparaître dans gc.garbage dans des conditions spécifiques. Si une méthode __del__ ressuscite l'objet en l'enregistrant dans une variable globale, le GC ne peut pas collecter en toute sécurité le cycle et le déplace dans gc.garbage pour éviter des boucles infinies. De plus, les objets d'extension C avec des slots tp_dealloc personnalisés qui ne prennent pas correctement en charge le protocole de GC cyclique peuvent être considérés comme non collectables pour éviter les plantages dans le code natif.

Comment weakref.ref avec un rappel interagit-il avec le collecteur de déchets cyclique lorsque le référent fait partie d'un cycle non collectable ?

Les candidats supposent souvent à tort que les rappels de référence faible se déclenchent immédiatement lorsqu'un objet devient inaccessible. En réalité, le rappel se déclenche lorsque l'objet est effectivement détruit et que sa mémoire est libérée. Si un objet participe à un cycle de référence contenant des finalisateurs que le GC ne peut pas briser, l'objet reste alloué dans gc.garbage et le rappel de référence faible ne s'exécute jamais. Cette distinction est cruciale pour concevoir des systèmes de nettoyage de ressources qui s'appuient sur des rappels de référence faible pour la notification de la destruction des objets.

Quel est le problème de "résurrection" dans les méthodes __del__ et comment empêche-t-il la collecte des déchets des références circulaires ?

La résurrection se produit lorsqu'une méthode de finalisation assigne l'instance mourante à une variable globale ou l'insère dans un conteneur persistant, la faisant effectivement revivre après que le GC l'ait marquée pour destruction. Dans un scénario de référence circulaire, si le __del__ d'un objet ressuscite un objet dans le cycle, l'ensemble du cycle redevient accessible. Le collecteur de déchets de Python détecte cette anomalie et déplace l'ensemble du cycle dans gc.garbage plutôt que d'essayer de résoudre la boucle potentiellement infinie de destruction et de résurrection, laissant la mémoire non récupérée jusqu'à la fin du processus.