Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python Enhancement Proposals

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

Inhaltsverzeichnis

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 eine Thread-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_thread zurü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_mode sollte 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


Quelle: https://github.com/python/peps/blob/main/peps/pep-0556.rst

Zuletzt geändert: 2025-02-01 08:55:40 GMT