Geschichte der Frage
Dieses Thema stammt aus der Evolution von Python von einer reinen Referenzzählung zu einem hybriden Garbage-Collection-Modell, das in Python 2.0 eingeführt wurde. Das Kernproblem trat auf, als Entwickler finalizer Methoden (__del__) zur Verwaltung externer Ressourcen wie Datei-Handles oder Netzwerk-Sockets verwendeten. Wenn Objekte mit Finalizern zirkuläre Referenzen bildeten, konnte Python nicht bestimmen, in welcher Reihenfolge eine sichere Zerstörung stattfinden sollte, was zu Abstürzen oder Ressourcenlecks führen konnte. Diese Einschränkung führte zur Implementierung des zyklischen Garbage-Collector-Moduls (gc) und zur speziellen Handhabung von „nicht sammelbarem“ Müll.
Das Problem
Wenn eine Gruppe von Objekten einen Referenzzyklus bildet und mindestens eines eine benutzerdefinierte __del__-Methode definiert, steht Python vor einem deterministischen Zerstörungsdilemma. Der Interpreter kann nicht entscheiden, welches Objekt zuerst finalisiert werden soll, da der Zyklus eine gegenseitige Abhängigkeit impliziert und die Zerstörung eines Objekts andere in einen ungültigen Zustand versetzen könnte. Folglich verschiebt Python diese Objekte in die Liste gc.garbage, anstatt ihren Speicher freizugeben. Dieses Verhalten bleibt in modernen Versionen bestehen, wenn Finalizer eine sichere Sammlung verhindern und zu allmählichen Speicherlecks in langlaufenden Anwendungen führen.
Die Lösung
Die definitive Lösung besteht darin, __del__-Methoden ganz zu vermeiden und stattdessen Kontextmanager (with-Anweisungen) oder weakref-Callbacks zur Bereinigung von Ressourcen zu verwenden. Wenn Finalizer unvermeidlich sind, brechen Sie ausdrücklich Referenzzyklen, bevor Objekte unerreichbar werden, indem Sie Instanzvariablen in Bereinigungsmethoden auf None setzen. Beginnen mit Python 3.4 kann der Garbage Collector in vielen Fällen Zyklen mit Finalizern sammeln, indem er die Finalisierung sorgfältig anordnet, aber das explizite Ressourcenmanagement bleibt das zuverlässigste Muster.
import gc class Resource: def __init__(self, name): self.name = name self.peer = None def __del__(self): print(f"Aufräumen von {self.name}") # Erstellen eines Zyklus mit Finalizern a = Resource("A") b = Resource("B") a.peer = b b.peer = a # Entfernen externer Referenzen del a, b gc.collect() print(f"Nicht sammelbar: {gc.garbage}") # Kann Objekte in komplexen Szenarien enthalten
Wir verwalteten eine Hochdurchsatz-Datenverarbeitungspipeline, in der Node-Objekte Rechenschritte in einem Graphen darstellten. Jeder Knoten hielt Referenzen zu seinen Nachbarn und enthielt eine __del__-Methode zur Freigabe von GPU-Speicherhandles. Während intensiver Arbeitslasten beobachteten wir ein monotones Wachstum des Speichers, obwohl in der Profilerstellung keine offensichtlichen Speicherlecks erkennbar waren. Eine Untersuchung ergab, dass komplexe Graphtopologien Referenzzyklen zwischen Knoten schufen, und das Vorhandensein von __del__-Methoden verhinderte, dass der zyklische GC diese Objekte zurückgewinnen konnte, was dazu führte, dass sie sich in gc.garbage ansammelten, bis der Prozess beendet wurde.
Lösung 1: Refaktorisierung zu Kontextmanagern
Wir erwogen, __del__ durch explizite Methoden acquire() und release() zu ersetzen, die über Kontextmanager aufgerufen werden. Dieser Ansatz würde das Finalizer-Hindernis für die Garbage Collection vollständig beseitigen und eine deterministische Bereinigung von Ressourcen bieten. Dies erforderte jedoch die Modifikation von Tausenden von Zeilen Code zur Konstruktion des Graphen und riskiert Ressourcenlecks, wenn Entwickler vergaßen, die Knotennutzung in with-Blöcke zu wickeln, insbesondere in veralteten rückrufbasierten Komponenten.
Lösung 2: Implementierung schwacher Referenzen für Graphkanten
Wir untersuchten die Möglichkeit, alle Nachbarreferenzen in weakref.ref-Objekte zu ändern, die es den Knoten ermöglichen würden, sofort gesammelt zu werden, wenn keine externen Referenzen mehr vorhanden sind, unabhängig von der Graphverkettung. Obwohl elegant, führte dies zu erheblicher Komplexität, da die Graphdurchlaufalgorithmen ständig nach toten schwachen Referenzen überprüfen und „Geist“-Knoten während der Iteration behandeln mussten. Dieser Ansatz verringerte die Leistung erheblich für unseren Anwendungsfall und erforderte umfassende Umgestaltungen der Logik zum Durchlaufen des Graphen.
Lösung 3: Explizites Brechen von Zyklen via Aufräumprotokoll
Wir implementierten eine Methode destroy(), die ausdrücklich self.neighbors = [] und self.gpu_handle = None festlegte, bevor Knoten aus dem Graphen entfernt wurden. Dies brach Zyklen deterministisch, während die bestehende API-Oberfläche intakt blieb. Wir wählten diese Lösung, da sie Änderungen auf die Knotenentfernungslinie lokalisiert, anstatt Bedenken über den gesamten Code zu verteilen, und die Rückwärtskompatibilität mit bestehenden Graphalgorithmen aufrechterhielt.
Ergebnis
Nach der Implementierung des expliziten Aufräumprotokolls und der Hinzufügung von Assertionen zur Überprüfung, ob gc.garbage während der CI-Tests leer blieb, stabilisierte sich der Speicherverbrauch auf einem konstanten Basisniveau. Der Dienst lief wochenlang ohne das vorherige allmähliche Speicherwachstum. Wir dokumentierten auch das Muster, um sicherzustellen, dass zukünftige Entwickler die Interaktion zwischen Finalizern und zirkulären Referenzen verstanden.
Warum enthält gc.garbage unter Python 3.4+ immer noch Objekte, selbst wenn Finalizer in Zyklen vorhanden sind?
Obwohl Python 3.4 den zyklischen GC erheblich verbessert hat, um Finalizer zu handhaben, indem diese in sicherer Reihenfolge aufgerufen und die Referenzen danach gelöscht werden, können Objekte unter bestimmten Bedingungen immer noch in gc.garbage erscheinen. Wenn eine __del__-Methode das Objekt wiederbelebt, indem sie es in einer globalen Variablen speichert, kann der GC den Zyklus nicht sicher einsammeln und verschiebt ihn in gc.garbage, um endlose Schleifen zu vermeiden. Darüber hinaus können C-Erweiterungsobjekte mit benutzerdefinierten tp_dealloc-Slots, die das Protokoll der zyklischen GC nicht ordnungsgemäß unterstützen, als nicht sammelbar behandelt werden, um Abstürze im nativen Code zu vermeiden.
Wie interagiert weakref.ref mit einem Callback mit dem zyklischen Garbage Collector, wenn der Referent Teil eines nicht sammelbaren Zyklus ist?
Kandidaten nehmen oft fälschlicherweise an, dass schwache Referenz-Callbacks sofort ausgelöst werden, wenn ein Objekt unerreichbar wird. In Wirklichkeit tritt das Callback auf, wenn das Objekt tatsächlich zerstört und dessen Speicher freigegeben wird. Wenn ein Objekt an einem Referenzzyklus mit Finalizern beteiligt ist, die der GC nicht brechen kann, bleibt das Objekt in gc.garbage zugewiesen, und das schwache Referenz-Callback wird niemals ausgeführt. Diese Unterscheidung ist entscheidend für die Gestaltung von Systemen zur Bereinigung von Ressourcen, die auf schwachen Referenz-Callbacks zur Benachrichtigung über die Zerstörung von Objekten angewiesen sind.
Was ist das "Wiederbelebungsproblem" in __del__ Methoden und wie verhindert es die Garbage Collection von zirkulären Referenzen?
Wiederbelebung tritt auf, wenn eine Finalizer-Methode die sterbende Instanz einer globalen Variablen zuweist oder sie in einen persistenten Container einfügt, was sie effektiv wiederbelebt, nachdem der GC sie zur Zerstörung markiert hat. In einem Szenario mit zirkulären Referenzen, wenn die __del__-Methode eines Objekts irgendein Objekt im Zyklus wiederbelebt, wird der gesamte Zyklus wieder erreichbar. Der Garbage Collector von Python erkennt diese Anomalie und verschiebt den gesamten Zyklus in gc.garbage, anstatt zu versuchen, die potenziell endlose Schleife der Zerstörung und Wiederbelebung zu lösen, wodurch der Speicher bis zur Beendigung des Prozesses nicht freigegeben wird.