PEP 556 – Threaded garbage collection
- Autor:
- Antoine Pitrou <solipsis at pitrou.net>
- Status:
- Verschoben
- Typ:
- Standards Track
- Erstellt:
- 08-Sep-2017
- Python-Version:
- 3.7
- Post-History:
- 08-Sep-2017
Aufschub-Hinweis
An diesem PEP wird derzeit nicht aktiv gearbeitet. Er könnte in Zukunft wiederbelebt werden. Die wichtigsten fehlenden Schritte sind:
- die Implementierung zu verfeinern und die Testsuite gegebenenfalls anzupassen;
- sicherzustellen, dass das Einstellen des threaded Garbage Collection bestehenden Code nicht unerwartet stört (erwartete Auswirkungen sind eine Verlängerung der Lebensdauer von Objekten in Referenzzyklen).
Zusammenfassung
Dieser PEP schlägt einen neuen optionalen Betriebsmodus für den zyklischen Garbage Collector (GC) von CPython vor, bei dem implizite (d.h. opportunistische) Sammlungen in einem dedizierten Thread statt synchron erfolgen.
Terminologie
Ein „impliziter“ GC-Lauf (oder „implizite“ Sammlung) ist einer, der opportunistisch basierend auf einer bestimmten Heuristik, die über Allokationsstatistiken berechnet wird, ausgelöst wird, sobald eine neue Allokation angefordert wird. Details der Heuristik sind für diesen PEP nicht relevant, da er sie nicht ändern möchte.
Ein „expliziter“ GC-Lauf (oder „explizite“ Sammlung) ist einer, der programmatisch durch einen API-Aufruf wie gc.collect angefordert wird.
„Threaded“ bezieht sich auf die Tatsache, dass GC-Läufe in einem dedizierten Thread separat von der sequenziellen Ausführung von Anwendungscode erfolgen. Es bedeutet nicht „concurrent“ (der Global Interpreter Lock, oder GIL, serialisiert immer noch die Ausführung zwischen Python-Threads, *einschließlich* des dedizierten GC-Threads) noch „parallel“ (der GC kann seine Arbeit nicht gleichzeitig auf mehrere Threads verteilen, um die Wandzeit von GC-Läufen zu reduzieren).
Begründung
Der Betriebsmodus für den GC war schon immer, implizite Sammlungen synchron durchzuführen. Das heißt, immer wenn die vorgenannte Heuristik aktiviert wird, wird die Ausführung von Anwendungscode im aktuellen Thread angehalten und der GC gestartet, um tote Referenzzyklen zurückzufordern.
Es gibt jedoch einen Haken. Während der Rückforderung toter Referenzzyklen (und jeglicher Nebenobjekte, die an diesen Zyklen hängen) kann der GC beliebigen Finalisierungscode in Form von __del__-Methoden und weakref-Callbacks ausführen. Im Laufe der Jahre wurde Python für immer anspruchsvollere Zwecke eingesetzt, und es ist zunehmend üblich, dass Finalisierungscode komplexe Aufgaben ausführt, z. B. in verteilten Systemen, wo der Verlust eines Objekts die Benachrichtigung anderer (logischer oder physischer) Knoten erfordern kann.
Das Unterbrechen von Anwendungscode zu beliebigen Zeitpunkten, um Finalisierungscode auszuführen, der auf einem konsistenten internen Zustand und/oder dem Erwerb von Synchronisationsprimitiven beruht, führt zu Reentrancy-Problemen, die selbst die erfahrensten Experten nur schwer richtig beheben können [1].
Dieser PEP basiert auf der Beobachtung, dass, trotz offensichtlicher Ähnlichkeiten, Same-Thread-Reentrancy ein grundlegend schwierigeres Problem ist als Multi-Thread-Synchronisation. Anstatt jeden Entwickler oder Bibliotheksautor einzeln mit extrem schwierigen Reentrancy-Problemen kämpfen zu lassen, schlägt dieser PEP vor, den GC in einem separaten Thread laufen zu lassen, wo bekannte Multi-Thread-Synchronisationspraktiken ausreichen.
Vorschlag
Unter diesem PEP hat der GC zwei Betriebsmodi:
- „serial“, dies ist der Standard- und Legacy-Modus, bei dem ein impliziter GC-Lauf sofort im Thread durchgeführt wird, der einen solchen impliziten Lauf erkennt (basierend auf der vorgenannten Allokationsheuristik).
- „threaded“, der zur Laufzeit pro Prozess explizit aktiviert werden kann, bei dem implizite GC-Läufe *geplant* werden, wenn die Allokationsheuristik ausgelöst wird, aber in einem dedizierten Hintergrundthread laufen.
Schwere Reentrancy-Probleme, die anspruchsvolle Verwendungen von Finalisierungs-Callbacks im „serial“-Modus plagen, werden im „threaded“-Betriebsmodus zu relativ einfachen Multi-Thread-Synchronisationsproblemen.
Der GC erlaubt traditionell auch explizite GC-Läufe über die Python-API gc.collect und die C-API PyGC_Collect. Die sichtbare Semantik dieser beiden APIs bleibt unverändert: Sie führen beim Aufruf sofort einen GC-Lauf durch und kehren erst zurück, wenn der GC-Lauf beendet ist.
Neue öffentliche APIs
Zwei neue Python-APIs werden dem gc-Modul hinzugefügt:
gc.set_mode(mode)setzt den aktuellen Betriebsmodus (entweder „serial“ oder „threaded“). Wenn auf „serial“ gesetzt wird und der aktuelle Modus „threaded“ ist, wartet die Funktion auch auf das Ende des GC-Threads.gc.get_mode()gibt den aktuellen Betriebsmodus zurück.
Es ist erlaubt, zwischen den Betriebsmodi hin und her zu wechseln.
Vorgesehene Verwendung
Angesichts der Pro-Prozess-Natur des Wechsels und seiner Auswirkungen auf die Semantik aller Finalisierungs-Callbacks wird empfohlen, dass er zu Beginn des Anwendungscodes (und/oder in Initialisierungen für Kindprozesse, z.B. bei Verwendung von multiprocessing) gesetzt wird. Bibliotheksfunktionen sollten diese Einstellung wahrscheinlich nicht verändern, genauso wenig wie sie gc.enable oder gc.disable aufrufen sollten, aber es gibt nichts, das sie daran hindert.
Non-goals
Dieser PEP befasst sich nicht mit Reentrancy-Problemen bei anderen Arten der asynchronen Codeausführung (z.B. Signal-Handler, die mit dem signal-Modul registriert sind). Der Autor glaubt, dass die überwiegende Mehrheit der schmerzhaften Reentrancy-Probleme bei Finalizern auftritt. Meistens können Signal-Handler ein einzelnes Flag setzen und/oder einen File Descriptor aufwecken, damit das Hauptprogramm dies bemerkt. Was Signal-Handler betrifft, die eine Ausnahme auslösen, müssen sie *in-Thread* ausgeführt werden.
Dieser PEP ändert auch nicht die Ausführung von Finalisierungs-Callbacks, wenn sie als Teil der regulären Referenzzählung aufgerufen werden, d.h. wenn die Freigabe einer sichtbaren Referenz den Referenzzähler eines Objekts auf Null reduziert. Da eine solche Ausführung zu deterministischen Punkten im Code erfolgt, ist sie normalerweise kein Problem.
Interne Details
TODO: Diesen Abschnitt aktualisieren, um der aktuellen Implementierung zu entsprechen.
gc-Modul
Ein internes Flag gc_is_threaded wird hinzugefügt, das angibt, ob der GC seriell oder threaded ist.
Eine interne Struktur gc_mutex wird hinzugefügt, um zwei GC-Läufe gleichzeitig zu vermeiden.
static struct {
PyThread_type_lock lock; /* taken when collecting */
PyThreadState *owner; /* whichever thread is currently collecting
(NULL if no collection is taking place) */
} gc_mutex;
Eine interne Struktur gc_thread wird hinzugefügt, um die Synchronisation mit dem GC-Thread zu handhaben.
static struct {
PyThread_type_lock wakeup; /* acts as an event
to wake up the GC thread */
int collection_requested; /* non-zero if collection requested */
PyThread_type_lock done; /* acts as an event signaling
the GC thread has exited */
} gc_thread;
threading-Modul
Zwei private Funktionen werden dem threading-Modul hinzugefügt:
threading._ensure_dummy_thread(name)erstellt und registriert eineThread-Instanz für den aktuellen Thread mit dem angegebenen *Namen* und gibt sie zurück.threading._remove_dummy_thread(thread)entfernt den angegebenen *Thread* (wie von_ensure_dummy_threadzurückgegeben) aus dem internen Zustand des Threading-Moduls.
Der Zweck dieser beiden Funktionen ist die Verbesserung von Debugging und Introspektion, indem threading.current_thread() ein aussagekräftiger benanntes Objekt zurückgibt, wenn es innerhalb eines Finalisierungs-Callbacks im GC-Thread aufgerufen wird.
Pseudocode
Hier ist ein vorgeschlagener Pseudo-Code für die wichtigsten Primitiven, öffentlich und intern, die für die Implementierung dieses PEP erforderlich sind. Alle werden in C implementiert und im gc-Modul leben, sofern nicht anders angegeben:
def collect_with_callback(generation):
"""
Collect up to the given *generation*.
"""
# Same code as currently (see collect_with_callback() in gcmodule.c)
def collect_generations():
"""
Collect as many generations as desired by the heuristic.
"""
# Same code as currently (see collect_generations() in gcmodule.c)
def lock_and_collect(generation=-1):
"""
Perform a collection with thread safety.
"""
me = PyThreadState_GET()
if gc_mutex.owner == me:
# reentrant GC collection request, bail out
return
Py_BEGIN_ALLOW_THREADS
gc_mutex.lock.acquire()
Py_END_ALLOW_THREADS
gc_mutex.owner = me
try:
if generation >= 0:
return collect_with_callback(generation)
else:
return collect_generations()
finally:
gc_mutex.owner = NULL
gc_mutex.lock.release()
def schedule_gc_request():
"""
Ask the GC thread to run an implicit collection.
"""
assert gc_is_threaded == True
# Note this is extremely fast if a collection is already requested
if gc_thread.collection_requested == False:
gc_thread.collection_requested = True
gc_thread.wakeup.release()
def is_implicit_gc_desired():
"""
Whether an implicit GC run is currently desired based on allocation
stats. Return a generation number, or -1 if none desired.
"""
# Same heuristic as currently (see _PyObject_GC_Alloc in gcmodule.c)
def PyGC_Malloc():
"""
Allocate a GC-enabled object.
"""
# Update allocation statistics (same code as currently, omitted for brevity)
if is_implicit_gc_desired():
if gc_is_threaded:
schedule_gc_request()
else:
lock_and_collect()
# Go ahead with allocation (same code as currently, omitted for brevity)
def gc_thread(interp_state):
"""
Dedicated loop for threaded GC.
"""
# Init Python thread state (omitted, see t_bootstrap in _threadmodule.c)
# Optional: init thread in Python threading module, for better introspection
me = threading._ensure_dummy_thread(name="GC thread")
while gc_is_threaded == True:
Py_BEGIN_ALLOW_THREADS
gc_thread.wakeup.acquire()
Py_END_ALLOW_THREADS
if gc_thread.collection_requested != 0:
gc_thread.collection_requested = 0
lock_and_collect(generation=-1)
threading._remove_dummy_thread(me)
# Signal we're exiting
gc_thread.done.release()
# Free Python thread state (omitted)
def gc.set_mode(mode):
"""
Set current GC mode. This is a process-global setting.
"""
if mode == "threaded":
if not gc_is_threaded == False:
# Launch thread
gc_thread.done.acquire(block=False) # should not fail
gc_is_threaded = True
PyThread_start_new_thread(gc_thread)
elif mode == "serial":
if gc_is_threaded == True:
# Wake up thread, asking it to end
gc_is_threaded = False
gc_thread.wakeup.release()
# Wait for thread exit
Py_BEGIN_ALLOW_THREADS
gc_thread.done.acquire()
Py_END_ALLOW_THREADS
gc_thread.done.release()
else:
raise ValueError("unsupported mode %r" % (mode,))
def gc.get_mode(mode):
"""
Get current GC mode.
"""
return "threaded" if gc_is_threaded else "serial"
def gc.collect(generation=2):
"""
Schedule collection of the given generation and wait for it to
finish.
"""
return lock_and_collect(generation)
Diskussion
Standardmodus
Man könnte sich fragen, ob der Standardmodus nicht einfach auf „threaded“ geändert werden sollte. Für Multi-Thread-Anwendungen wäre das wahrscheinlich kein Problem: Diese Anwendungen müssen ohnehin darauf vorbereitet sein, dass Finalisierungs-Handler in beliebigen Threads ausgeführt werden. In Single-Thread-Anwendungen ist jedoch garantiert, dass Finalizer immer im Hauptthread aufgerufen werden. Das Brechen dieser Eigenschaft kann zu subtilen Verhaltensänderungen oder Fehlern führen, z. B. wenn Finalizer von einigen Thread-lokalen Werten abhängen.
Ein weiteres Problem ist, wenn ein Programm fork() für die Nebenläufigkeit verwendet. Das Aufrufen von fork() aus einem Single-Thread-Programm ist sicher, aber es ist (gelinde gesagt) fragil, wenn das Programm Multi-Thread ist.
Explizite Sammlungen
Man könnte fragen, ob explizite Sammlungen auch an den Hintergrund-Thread delegiert werden sollten. Die Antwort ist, dass es keine große Rolle spielt: Da gc.collect und PyGC_Collect tatsächlich auf das Ende der Sammlung *warten* (dieses Verhalten zu brechen würde Kompatibilität brechen), würde die tatsächliche Arbeit an einen Hintergrund-Thread zu delegieren die Synchronisation mit dem Thread, der eine explizite Sammlung anfordert, nicht erleichtern.
Letztendlich wählt dieser PEP das Verhalten, das basierend auf dem obigen Pseudo-Code einfacher zu implementieren zu sein scheint.
Auswirkungen auf die Speichernutzung
Der „threaded“-Modus verursacht eine leichte Verzögerung bei impliziten Sammlungen im Vergleich zum Standardmodus „serial“. Dies kann offensichtlich das Speicherprofil bestimmter Anwendungen ändern. Wie stark dies sein wird, muss noch in der Praxis gemessen werden, aber wir erwarten, dass die Auswirkungen gering und tragbar bleiben. Erstens, weil implizite Sammlungen auf einer *Heuristik* basieren, deren Wirkung ohnehin kein deterministisches sichtbares Verhalten zur Folge hat. Zweitens, weil der GC Referenzzyklen behandelt, während viele Objekte sofort zurückgefordert werden, wenn ihre letzte sichtbare Referenz verschwindet.
Auswirkungen auf die CPU-Auslastung
Der obige Pseudo-Code fügt zwei Lock-Operationen für jede Anforderung einer impliziten Sammlung im „threaded“-Modus hinzu: eine im Thread, der die Anforderung stellt (ein release-Aufruf) und eine im GC-Thread (ein acquire-Aufruf). Er fügt außerdem zwei weitere Lock-Operationen hinzu, unabhängig vom aktuellen Modus, um jede tatsächliche Sammlung herum.
Wir erwarten, dass die Kosten dieser Lock-Operationen auf modernen Systemen sehr gering sind, verglichen mit den tatsächlichen Kosten des Durchlaufens der Zeigerketten während der Sammlung selbst („Pointer Chasing“ ist eine der schwierigsten Arbeitslasten für moderne CPUs, da sie sich schlecht für Spekulation und superskalare Ausführung eignet).
Tatsächliche Messungen an Worst-Case-Mini-Benchmarks können helfen, beruhigende obere Schranken zu liefern.
Auswirkungen auf GC-Pausen
Obwohl sich dieser PEP nicht mit GC-Pausen befasst, besteht eine praktische Chance, dass die Freigabe des GIL an einem Punkt während einer impliziten Sammlung (z. B. durch Ausführung eines reinen Python-Finalizers) die Ausführung von Anwendungscode dazwischen ermöglicht und so die *sichtbare* GC-Pausenzeit für einige Anwendungen reduziert.
Wenn dieser PEP angenommen wird, können zukünftige Arbeiten versuchen, dieses Potenzial besser auszuschöpfen, indem das GIL während Sammlungen spekulativ freigegeben wird, obwohl unklar ist, wie machbar dies ist.
Offene Themen
gc.set_modesollte wahrscheinlich gegen mehrere gleichzeitige Aufrufe geschützt werden. Außerdem sollte sie einen Fehler auslösen, wenn sie *innerhalb* eines GC-Laufs (d.h. aus einem Finalizer) aufgerufen wird.- Was passiert beim Herunterfahren? Läuft der GC-Thread, bis
_PyGC_Fini()aufgerufen wird?
Implementierung
Eine Entwurfsimplementierung ist im Branch threaded_gc [2] des Github-Forks des Autors [3] verfügbar.
Referenzen
Urheberrecht
Dieses Dokument wurde gemeinfrei erklärt.
Quelle: https://github.com/python/peps/blob/main/peps/pep-0556.rst
Zuletzt geändert: 2025-02-01 08:55:40 GMT