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

Python Enhancement Proposals

PEP 703 – Die globale Interpreter-Sperre optional in CPython machen

Autor:
Sam Gross <colesbury at gmail.com>
Sponsor:
Łukasz Langa <lukasz at python.org>
Discussions-To:
Discourse thread
Status:
Akzeptiert
Typ:
Standards Track
Erstellt:
09-Jan-2023
Python-Version:
3.13
Post-History:
09-Jan-2023, 04-Mai-2023
Resolution:
24-Okt-2023

Inhaltsverzeichnis

Hinweis

Der Steering Council akzeptiert PEP 703, aber mit klarer Auflage: dass die Einführung schrittweise erfolgt und so wenig wie möglich unterbricht und dass wir alle Änderungen, die sich als zu störend erweisen, zurücknehmen können – was gegebenenfalls sogar die vollständige Rücknahme von PEP 703 einschließt (wie unwahrscheinlich oder unerwünscht wir das auch einschätzen mögen).

Zusammenfassung

CPythons globale Interpreter-Sperre („GIL“) verhindert, dass mehrere Threads gleichzeitig Python-Code ausführen. Die GIL ist ein Hindernis für die effiziente Nutzung von Multi-Core-CPUs aus Python heraus. Dieses PEP schlägt die Hinzufügung einer Build-Konfiguration (--disable-gil) zu CPython vor, um es zu ermöglichen, Python-Code ohne die globale Interpreter-Sperre auszuführen und mit den notwendigen Änderungen, um den Interpreter Thread-sicher zu machen.

Motivation

Die GIL ist ein wesentliches Hindernis für Parallelität. Für wissenschaftliche Berechnungsaufgaben ist dieser Mangel an Parallelität oft ein größeres Problem als die Geschwindigkeit der Ausführung von Python-Code, da die meisten Prozessorkerne in optimierten CPU- oder GPU-Kernels verbracht werden. Die GIL führt zu einem globalen Flaschenhals, der andere Threads am Fortschritt hindern kann, wenn sie Python-Code aufrufen. Es gibt bereits Möglichkeiten, Parallelität in CPython zu ermöglichen, aber diese Techniken sind mit erheblichen Einschränkungen verbunden (siehe Alternativen).

Dieser Abschnitt konzentriert sich auf die Auswirkungen der GIL auf wissenschaftliche Berechnungen, insbesondere auf KI/ML-Workloads, da dies der Bereich ist, in dem dieser Autor die meiste Erfahrung hat, aber die GIL betrifft auch andere Benutzer von Python.

Die GIL erschwert den Ausdruck vieler Arten von Parallelität

Neuronale Netz-basierte KI-Modelle bieten mehrere Möglichkeiten zur Parallelisierung. Beispielsweise können einzelne Operationen intern parallelisiert werden („intra-operator“), mehrere Operationen können gleichzeitig ausgeführt werden („inter-operator“) und Anfragen (die mehrere Operationen umfassen) können ebenfalls parallelisiert werden. Eine effiziente Ausführung erfordert die Ausnutzung mehrerer Arten von Parallelität [1].

Die GIL erschwert die effiziente Formulierung von Inter-Operator-Parallelität sowie einiger Formen von Anfrage-Parallelität in Python. In anderen Programmiersprachen könnte ein System Threads verwenden, um verschiedene Teile eines neuronalen Netzes auf separaten CPU-Kernen auszuführen, aber dies ist in Python aufgrund der GIL ineffizient. Ebenso nutzen latenzempfindliche Inferenz-Workloads häufig Threads, um Anfragen zu parallelisieren, stoßen aber in Python auf dieselben Skalierungsschranken.

Die Herausforderungen, die die GIL für die Ausnutzung von Parallelität in Python darstellt, treten häufig im Bereich des Reinforcement Learning auf. Heinrich Kuttler, Autor der NetHack Learning Environment und Mitglied des Technical Staff bei Inflection AI, schreibt

Jüngste Durchbrüche im Reinforcement Learning, wie bei Dota 2, StarCraft und NetHack beruhen auf dem parallelen Ausführen mehrerer Umgebungen (simulierte Spiele) mittels asynchroner Actor-Critic-Methoden. Einfache multithreaded Implementierungen in Python skalieren aufgrund von GIL-Konflikten nicht über wenige parallele Umgebungen hinaus. Multiprocessing mit Kommunikation über Shared Memory oder UNIX-Sockets fügt viel Komplexität hinzu und schließt im Wesentlichen die Interaktion mit CUDA von verschiedenen Workern aus, was den Designraum stark einschränkt.

Manuel Kroiss, Softwareentwickler bei DeepMind im Reinforcement Learning Team, beschreibt, wie die von der GIL ausgehenden Engpässe dazu führen, Python-Codebasen in C++ neu zu schreiben, wodurch der Code weniger zugänglich wird.

Wir kämpfen bei DeepMind häufig mit Problemen der Python-GIL. In vielen unserer Anwendungen möchten wir etwa 50-100 Threads pro Prozess ausführen. Wir stellen jedoch oft fest, dass die GIL bereits bei weniger als 10 Threads zum Flaschenhals wird. Um dieses Problem zu umgehen, verwenden wir manchmal Subprozesse, aber in vielen Fällen wird die Interprozesskommunikation zu einem zu großen Overhead. Um mit der GIL umzugehen, übersetzen wir meist große Teile unserer Python-Codebasis in C++. Das ist unerwünscht, da es den Code für Forscher weniger zugänglich macht.

Projekte, die mit mehreren Hardwaregeräten interagieren, stehen vor ähnlichen Herausforderungen: Eine effiziente Kommunikation erfordert die Nutzung mehrerer CPU-Kerne. Das Dose-3D-Projekt zielt darauf ab, die Krebsstrahlentherapie mit präziser Dosisplanung zu verbessern. Es verwendet medizinische Phantome (Stellvertreter für menschliches Gewebe) zusammen mit kundenspezifischer Hardware und einer in Python geschriebenen Serveranwendung. Paweł Jurgielewicz, leitender Softwarearchitekt für das Datenerfassungssystem des Dose-3D-Projekts, beschreibt die Skalierungsprobleme, die durch die GIL verursacht werden, und wie die Verwendung eines Forks von Python ohne GIL das Projekt vereinfacht hat.

Im Dose-3D-Projekt war die Hauptaufgabe, eine stabile, nicht-triviale parallele Kommunikationsverbindung mit Hardwareeinheiten aufrechtzuerhalten und gleichzeitig eine 1 Gbit/s UDP/IP-Verbindung maximal zu nutzen. Natürlich haben wir mit dem multiprocessing-Paket begonnen, aber irgendwann wurde klar, dass die meiste CPU-Zeit für die Datenübertragungen zwischen den Datenverarbeitungsstufen verbraucht wurde, nicht für die Datenverarbeitung selbst. Auch die CPython-Multithreading-Implementierung basierend auf der GIL war eine Sackgasse. Als wir von dem "nogil"-Fork von Python erfuhren, brauchte eine einzelne Person weniger als einen halben Arbeitstag, um die Codebasis an die Verwendung dieses Forks anzupassen, und die Ergebnisse waren erstaunlich. Jetzt können wir uns auf die Entwicklung des Datenerfassungssystems konzentrieren, anstatt die Datenaustauschalgorithmen zu optimieren.

Allen Goodman, Autor von CellProfiler und Staff Engineer bei Prescient Design und Genentech, beschreibt, wie die GIL die Forschung an biologischen Methoden in Python erschwert.

Probleme mit der globalen Interpreter-Sperre von Python sind eine häufige Quelle der Frustration in der gesamten Forschung an biologischen Methoden.

Ich wollte die aktuelle Multithreading-Situation besser verstehen und habe Teile von HMMER, einer Standardmethode für die multiple Sequenzalignierung, neu implementiert. Ich habe diese Methode gewählt, weil sie sowohl die Ein-Thread-Leistung (Scoring) als auch die Multi-Thread-Leistung (Suche in einer Sequenzdatenbank) beansprucht. Die GIL wurde zum Flaschenhals, als ich nur acht Threads verwendete. Dies ist eine Methode, bei der die derzeit gängigen Implementierungen auf 64 oder sogar 128 Threads pro Prozess setzen. Ich habe versucht, auf Subprozesse umzusteigen, wurde aber durch die prohibitiven IPC-Kosten blockiert. HMMER ist eine relativ elementare Bioinformatik-Methode und neuere Methoden haben weitaus größere Multithreading-Anforderungen.

Methodenforscher flehen darum, Python zu nutzen (einschließlich meiner selbst), wegen seiner Benutzerfreundlichkeit, des Python-Ökosystems und weil „es das ist, was die Leute kennen“. Viele Biologen kennen nur wenig Programmierung (und das ist fast immer Python). Solange die Multithreading-Situation von Python nicht geklärt ist, werden C und C++ die Lingua Franca der biologischen Methoden-Forschungsgemeinschaft bleiben.

Die GIL beeinträchtigt die Benutzerfreundlichkeit von Python-Bibliotheken

Die GIL ist ein Implementierungsdetail von CPython, das die parallele Ausführung von Threads einschränkt, sodass es unintuitiv erscheinen mag, sie als Usability-Problem zu betrachten. Allerdings kümmern sich Bibliotheksautoren häufig sehr um die Leistung und entwerfen APIs, die die Umgehung der GIL unterstützen. Diese Workarounds führen oft zu APIs, die schwieriger zu verwenden sind. Folglich können Benutzer dieser APIs die GIL als Usability-Problem und nicht nur als Performance-Problem erfahren.

Zum Beispiel bietet PyTorch eine multiprocessing-basierte API namens DataLoader zum Erstellen von Dateneingabepipelines an. Sie verwendet fork() unter Linux, da dies im Allgemeinen schneller ist und weniger Speicher verbraucht als spawn(), was jedoch zu zusätzlichen Herausforderungen für Benutzer führt: Das Erstellen eines DataLoader nach dem Zugriff auf eine GPU kann zu verwirrenden CUDA-Fehlern führen. Der Zugriff auf GPUs innerhalb eines DataLoader-Workers führt schnell zu Out-of-Memory-Fehlern, da Prozesse keine CUDA-Kontexte teilen (im Gegensatz zu Threads innerhalb eines Prozesses).

Olivier Grisel, scikit-learn-Entwickler und Softwareingenieur bei Inria, beschreibt, wie die Notwendigkeit, die GIL in scikit-learn-bezogenen Bibliotheken zu umgehen, zu einer komplexeren und verwirrenderen Benutzererfahrung führt.

Im Laufe der Jahre haben scikit-learn-Entwickler Hilfsbibliotheken wie joblib und loky gepflegt, um einige der Einschränkungen von multiprocessing zu umgehen: zusätzlicher Speicherverbrauch, der teilweise durch halbautomatische Speicherabbildung großer Datenpuffer gemildert wird, langsame Worker-Starts durch transparente Wiederverwendung eines Pools von langlebigen Workern, Fork-Sicherheitsprobleme von Drittanbieter-Native-Runtime-Bibliotheken wie GNU OpenMP durch die ausschließliche Verwendung der nur-Fork-Startmethode, die Fähigkeit, parallele Aufrufe von interaktiv definierten Funktionen in Notebooks und REPLs plattformübergreifend über cloudpickle durchzuführen. Trotz unserer Bemühungen ist diese multiprocessing-basierte Lösung immer noch fragil, wartungsintensiv und für Data Scientists mit begrenztem Verständnis von Systembeschränkungen verwirrend. Darüber hinaus gibt es immer noch irreduzible Einschränkungen wie den Overhead, der durch die Pickle-basierten Serialisierungs-/Deserialisierungsschritte für die Interprozesskommunikation verursacht wird. Viel von dieser zusätzlichen Arbeit und Komplexität wäre nicht mehr nötig, wenn wir Threads ohne Konflikte auf Multi-Core-Hosts (manchmal mit 64 physischen Kernen oder mehr) verwenden könnten, um Data-Science-Pipelines auszuführen, die zwischen Python-Level-Operationen und Aufrufen nativer Bibliotheken wechseln.

Ralf Gommers, Co-Direktor von Quansight Labs und Maintainer von NumPy und SciPy, beschreibt, wie die GIL die Benutzererfahrung von NumPy und numerischen Python-Bibliotheken beeinflusst.

Ein Hauptproblem in NumPy und dem darauf aufbauenden Paketstapel ist, dass NumPy immer noch (größtenteils) Single-Threaded ist – und das hat signifikante Teile der Benutzererfahrung und der darum aufgebauten Projekte geprägt. NumPy gibt die GIL in seinen inneren Schleifen (die die Schwerstarbeit leisten) zwar frei, aber das reicht bei weitem nicht aus. NumPy bietet keine Lösung, um alle CPU-Kerne einer einzelnen Maschine gut zu nutzen, und überlässt dies stattdessen Dask und anderen multiprocessing-Lösungen. Diese sind nicht sehr effizient und auch umständlicher zu verwenden. Diese Umständlichkeit äußert sich hauptsächlich in den zusätzlichen Abstraktionen und Ebenen, mit denen sich die Benutzer auseinandersetzen müssen, wenn sie z.B. dask.array verwenden, das numpy.ndarray umschließt. Sie zeigt sich auch in Überschneidungsproblemen, die dem Benutzer bewusst sein und über Umgebungsvariablen oder ein drittes Paket, threadpoolctl, verwalten muss. Der Hauptgrund dafür ist, dass NumPy BLAS für lineare Algebra aufruft – und diese Aufrufe hat es nicht unter Kontrolle, sie nutzen standardmäßig alle Kerne über pthreads oder OpenMP.

Die Koordination von APIs und Designentscheidungen zur Steuerung der Parallelität ist immer noch eine erhebliche Menge Arbeit und eine der größten Herausforderungen im gesamten PyData-Ökosystem. Ohne eine GIL hätte das alles ganz anders (besser, einfacher) ausgesehen.

GPU-intensive Workloads erfordern Multi-Core-Verarbeitung

Viele High-Performance-Computing- (HPC) und KI-Workloads nutzen GPUs intensiv. Diese Anwendungen erfordern häufig eine effiziente Multi-Core-CPU-Ausführung, auch wenn der Großteil der Berechnung auf einer GPU ausgeführt wird.

Zachary DeVito, PyTorch Core Developer und Forscher bei FAIR (Meta AI), beschreibt, wie die GIL selbst dann, wenn der Großteil der Berechnung außerhalb von Python erfolgt, multithreaded Skalierung ineffizient macht.

In PyTorch wird Python häufig verwendet, um ca. 8 GPUs und ca. 64 CPU-Threads zu orchestrieren, was für große Modelle auf bis zu 4k GPUs und 32k CPU-Threads anwächst. Obwohl die Schwerstarbeit außerhalb von Python geleistet wird, macht die Geschwindigkeit von GPUs selbst die Orchestrierung in Python unskalierbar. Wir landen oft bei 72 Prozessen anstelle von einem wegen der GIL. Logging, Debugging und Performance-Tuning sind in diesem Regime um Größenordnungen schwieriger und führen kontinuierlich zu geringerer Entwicklerproduktivität.

Die Verwendung vieler Prozesse (anstatt Threads) erschwert allgemeine Aufgaben. Zachary DeVito fährt fort:

Bei drei separaten Gelegenheiten in den letzten Monaten (Reduzierung redundanter Berechnungen in Datenladern, asynchrones Schreiben von Modell-Checkpoints und Parallelisierung von Compiler-Optimierungen) habe ich eine um eine Größenordnung höhere Zeit damit verbracht, herauszufinden, wie man GIL-Beschränkungen umgehen kann, als das eigentliche Problem zu lösen.

Selbst GPU-intensive Workloads haben häufig eine CPU-intensive Komponente. Beispielsweise erfordern Computer-Vision-Aufgaben typischerweise mehrere „Pre-Processing“-Schritte in der Dateneingabepipeline, wie z.B. Bilddekodierung, Zuschneiden und Größenänderung. Diese Aufgaben werden üblicherweise auf der CPU ausgeführt und können Python-Bibliotheken wie Pillow oder Pillow-SIMD verwenden. Es ist notwendig, die Dateneingabepipeline auf mehreren CPU-Kernen auszuführen, um die GPU mit Daten „gefüttert“ zu halten.

Die Zunahme der GPU-Leistung im Vergleich zu einzelnen CPU-Kernen macht die Multi-Core-Leistung wichtiger. Es wird zunehmend schwieriger, die GPUs vollständig auszulasten. Um dies zu erreichen, ist eine effiziente Nutzung mehrerer CPU-Kerne erforderlich, insbesondere auf Multi-GPU-Systemen. Zum Beispiel verfügt NVIDIAs DGX-A100 über 8 GPUs und zwei 64-Kern-CPUs, um die GPUs mit Daten „gefüttert“ zu halten.

Die GIL erschwert die Bereitstellung von Python-KI-Modellen

Python wird häufig zur Entwicklung von neuronalen Netz-basierten KI-Modellen eingesetzt. In PyTorch werden Modelle häufig als Teil von Multi-Threaded, meist C++-basierten Umgebungen bereitgestellt. Python wird oft skeptisch betrachtet, da die GIL ein globaler Flaschenhals sein kann, der eine effiziente Skalierung verhindert, obwohl die überwiegende Mehrheit der Berechnungen „außerhalb“ von Python mit freigegebener GIL stattfindet. Das torchdeploy-Paper [2] zeigt experimentelle Beweise für diese Skalierungsprobleme in mehreren Modellarchitekturen.

PyTorch bietet eine Reihe von Mechanismen zur Bereitstellung von Python-KI-Modellen, die die GIL umgehen oder umgehen, aber sie alle sind mit erheblichen Einschränkungen verbunden. Zum Beispiel erfasst TorchScript eine Darstellung des Modells, die von C++ ohne Python-Abhängigkeiten ausgeführt werden kann, unterstützt aber nur eine begrenzte Teilmenge von Python und erfordert oft das Umschreiben eines Teils des Modellcodes. Die torch::deploy API ermöglicht mehrere Python-Interpreter, jeder mit seiner eigenen GIL, im selben Prozess (ähnlich wie PEP 684). torch::deploy hat jedoch nur begrenzte Unterstützung für Python-Module, die C-API-Erweiterungen verwenden.

Zusammenfassung der Motivation

CPythons globale Interpreter-Sperre macht es schwierig, moderne Multi-Core-CPUs für viele wissenschaftliche und numerische Computing-Anwendungen effizient zu nutzen. Heinrich Kuttler, Manuel Kroiss und Paweł Jurgielewicz stellten fest, dass multithreaded Implementierungen in Python für ihre Aufgaben nicht gut skalierten und dass die Verwendung mehrerer Prozesse keine geeignete Alternative war.

Die Skalierungsprobleme beschränken sich nicht nur auf numerische Kernaufgaben. Sowohl Zachary DeVito als auch Paweł Jurgielewicz beschrieben Herausforderungen bei der Koordination und Kommunikation in Python.

Olivier Grisel, Ralf Gommers und Zachary DeVito beschrieben, wie aktuelle Workarounds für die GIL „wartungsintensiv“ sind und zu einer „geringeren Entwicklerproduktivität“ führen. Die GIL erschwert die Entwicklung und Wartung von wissenschaftlichen und numerischen Computing-Bibliotheken sowie die Entwicklung von Bibliotheken, die schwieriger zu verwenden sind.

Spezifikation

Änderungen der Build-Konfiguration

Die globale Interpreter-Sperre bleibt der Standard für CPython-Builds und python.org-Downloads. Eine neue Build-Konfigurationsflagge, --disable-gil, wird dem Konfigurationsskript hinzugefügt, das CPython mit Unterstützung für die Ausführung ohne die globale Interpreter-Sperre baut.

Wenn mit --disable-gil kompiliert, definiert CPython das Makro Py_GIL_DISABLED in Python/patchlevel.h. Der ABI-Tag wird den Buchstaben „t“ (für „threading“) enthalten.

Die --disable-gil-Builds von CPython werden weiterhin optional mit aktivierter GIL zur Laufzeit unterstützt (siehe PYTHONGIL Umgebungsvariable und Py_mod_gil Slot).

Überblick über CPython-Änderungen

Das Entfernen der globalen Interpreter-Sperre erfordert erhebliche Änderungen an den CPython-Interna, aber relativ wenige Änderungen an den öffentlichen Python- und C-APIs. Dieser Abschnitt beschreibt die erforderlichen Änderungen an der CPython-Implementierung, gefolgt von den vorgeschlagenen API-Änderungen.

Die Implementierungsänderungen lassen sich in die folgenden vier Kategorien einteilen:

  • Referenzzählung
  • Speicherverwaltung
  • Thread-Sicherheit von Containern
  • Sperr- und atomare APIs

Referenzzählung

Das Entfernen der GIL erfordert Änderungen an CPythons Referenzzählungsimplementierung, um sie Thread-sicher zu machen. Außerdem muss sie einen geringen Ausführungs-Overhead haben und eine effiziente Skalierung mit mehreren Threads ermöglichen. Dieses PEP schlägt eine Kombination aus drei Techniken vor, um diese Einschränkungen zu bewältigen. Die erste ist ein Wechsel von der einfachen nicht-atomaren Referenzzählung zur voreingenommenen Referenzzählung, einer Thread-sicheren Referenzzählungstechnik mit geringerem Ausführungs-Overhead als die einfache atomare Referenzzählung. Die anderen beiden Techniken sind Unsterblichmachung und eine begrenzte Form der verzögerten Referenzzählung; sie adressieren einige der Multithreading-Skalierungsprobleme bei der Referenzzählung, indem sie einige Referenzzählungsmodifikationen vermeiden.

Voreingenommene Referenzzählung (BRC) ist eine Technik, die erstmals 2018 von Jiho Choi, Thomas Shull und Josep Torrellas beschrieben wurde [3]. Sie basiert auf der Beobachtung, dass die meisten Objekte selbst in Multi-Threaded-Programmen nur von einem einzigen Thread zugegriffen werden. Jedes Objekt ist einem besitzenden Thread zugeordnet (dem Thread, der es erstellt hat). Referenzzählungsoperationen des besitzenden Threads verwenden nicht-atomare Instruktionen, um eine „lokale“ Referenzanzahl zu ändern. Andere Threads verwenden atomare Instruktionen, um eine „geteilte“ Referenzanzahl zu ändern. Dieses Design vermeidet viele atomare Lese-Modifizier-Schreiboperationen, die auf modernen Prozessoren teuer sind.

Die in diesem PEP vorgeschlagene Implementierung von BRC stimmt weitgehend mit der ursprünglichen Beschreibung der voreingenommenen Referenzzählung überein, unterscheidet sich jedoch in Details wie der Größe der Referenzzählungsfelder und speziellen Bits in diesen Feldern. BRC erfordert die Speicherung von drei Informationen im Header jedes Objekts: der „lokalen“ Referenzanzahl, der „geteilten“ Referenzanzahl und der Kennung des besitzenden Threads. Das BRC-Paper packt diese drei Dinge in ein einziges 64-Bit-Feld. Dieses PEP schlägt die Verwendung von drei separaten Feldern im Header jedes Objekts vor, um potenzielle Probleme aufgrund von Überläufen der Referenzanzahl zu vermeiden. Zusätzlich unterstützt das PEP einen schnelleren Deallokationspfad, der im Normalfall eine atomare Operation vermeidet.

Die vorgeschlagene PyObject-Struktur (auch struct _object genannt) ist unten aufgeführt.

struct _object {
  _PyObject_HEAD_EXTRA
  uintptr_t ob_tid;         // owning thread id (4-8 bytes)
  uint16_t __padding;       // reserved for future use (2 bytes)
  PyMutex ob_mutex;         // per-object mutex (1 byte)
  uint8_t ob_gc_bits;       // GC fields (1 byte)
  uint32_t ob_ref_local;    // local reference count (4 bytes)
  Py_ssize_t ob_ref_shared; // shared reference count and state bits (4-8 bytes)
  PyTypeObject *ob_type;
};

Die Felder ob_tid, ob_ref_local und ob_ref_shared werden für die Implementierung der voreingenommenen Referenzzählung verwendet. Das Feld ob_gc_bits wird verwendet, um Flags für die Müllsammlung zu speichern, die zuvor in PyGC_Head gespeichert waren (siehe Garbage Collection (Cycle Collection)). Das Feld ob_mutex bietet ein Sperrfunktion pro Objekt in einem einzigen Byte.

Unsterblichmachung

Einige Objekte, wie z.B. internierte Strings, kleine ganze Zahlen, statisch zugewiesene PyTypeObjects und die Objekte True, False und None, leben während der gesamten Programmlaufzeit. Diese Objekte werden als unsterblich markiert, indem das Feld für die lokale Referenzanzahl (ob_ref_local) auf UINT32_MAX gesetzt wird.

Die Makros Py_INCREF und Py_DECREF sind für unsterbliche Objekte No-Ops. Dies vermeidet Konflikte auf den Referenzzählungsfeldern dieser Objekte, wenn mehrere Threads gleichzeitig darauf zugreifen.

Dieses vorgeschlagene Schema zur Unsterblichmachung ist PEP 683 sehr ähnlich, das in Python 3.12 übernommen wurde, jedoch mit einer leicht anderen Bitdarstellung in den Referenzzählungsfeldern für unsterbliche Objekte, um mit voreingenommener Referenzzählung und verzögerter Referenzzählung zu funktionieren. Siehe auch Warum nicht die Unsterblichmachung von PEP 683 verwenden?.

Voreingenommene Referenzzählung

Die voreingenommene Referenzzählung hat einen schnellen Pfad für Objekte, die vom aktuellen Thread „besessen“ sind, und einen langsamen Pfad für andere Objekte. Der Besitz wird durch das Feld ob_tid angezeigt. Die Bestimmung der Thread-ID erfordert plattformspezifischen Code [5]. Ein Wert von 0 in ob_tid zeigt an, dass das Objekt keinem Thread gehört.

Das Feld ob_ref_local speichert die lokale Referenzanzahl und zwei Flags. Die beiden höchstwertigen Bits werden verwendet, um anzuzeigen, dass das Objekt unsterblich ist oder verzögerte Referenzzählung verwendet (siehe Verzögerte Referenzzählung).

Das Feld ob_ref_shared speichert die geteilte Referenzanzahl. Die beiden *niederwertigsten* Bits werden verwendet, um den Referenzzählungszustand zu speichern. Die geteilte Referenzanzahl ist daher um zwei Bits nach links verschoben. Das Feld ob_ref_shared verwendet die niederwertigsten Bits, da die geteilte Referenzanzahl vorübergehend negativ sein kann; Increfs und Decrefs sind zwischen Threads möglicherweise nicht ausgeglichen.

Die möglichen Referenzzählungszustände sind unten aufgeführt:

  • 0b00 - Standard
  • 0b01 - weakrefs
  • 0b10 - queued
  • 0b11 - merged

Die Zustände bilden eine Progression: Während ihres Lebenszyklus können Objekte zu jedem numerisch höheren Zustand übergehen. Objekte können nur aus den Zuständen „default“ und „merged“ deallokiert werden. Andere Zustände müssen vor der Deallokation in den Zustand „merged“ übergehen. Der Übergang zwischen Zuständen erfordert einen atomaren Compare-and-Swap auf dem Feld ob_ref_shared.

Standard (0b00)

Objekte werden initial im Standardzustand erstellt. Dies ist der einzige Zustand, der den schnellen Deallokations-Code-Pfad ermöglicht. Andernfalls muss der Thread die lokalen und geteilten Referenzzählungsfelder zusammenführen, was einen atomaren Compare-and-Swap erfordert.

Dieser schnelle Deallokations-Code-Pfad wäre bei gleichzeitiger Dereferenzierung von Weakrefs nicht Thread-sicher, daher wird beim ersten Erstellen einer Weak Reference das Objekt in den Zustand „weakrefs“ überführt, wenn es sich derzeit im Zustand „default“ befindet.

Ebenso wäre der schnelle Deallokations-Code-Pfad nicht Thread-sicher mit dem locklosen Listen- und Wörterbuchzugriff (siehe Optimistisches Vermeiden von Sperren), sodass der Thread beim ersten Versuch eines nicht-besitzenden Threads, ein Objekt im Zustand „default“ abzurufen, auf den langsameren Sperr-Code-Pfad zurückfällt und das Objekt in den Zustand „weakrefs“ überführt.

Weakrefs (0b01)

Objekte in den Zuständen weakref und höher unterstützen die Dereferenzierung von Weakrefs sowie den locklosen Listen- und Wörterbuchzugriff durch nicht-besitzende Threads. Sie erfordern vor der Deallokation einen Übergang in den zusammengeführten Zustand, was teurer ist als der schnelle Deallokations-Code-Pfad, der vom Zustand „default“ unterstützt wird.

Queued (0b10)

Der Zustand „queued“ zeigt an, dass ein nicht-besitzender Thread verlangt hat, dass die Referenzzählungsfelder zusammengeführt werden. Dies kann passieren, wenn die geteilte Referenzanzahl negativ wird (aufgrund einer Unausgeglichenheit zwischen Increfs und Decrefs zwischen Threads). Das Objekt wird in die Warteschlange des besitzenden Threads für Objekte eingefügt, die zusammengeführt werden sollen. Der besitzende Thread wird über den eval_breaker-Mechanismus benachrichtigt. In der Praxis ist diese Operation selten. Die meisten Objekte werden nur von einem einzigen Thread zugegriffen, und die von mehreren Threads zugegriffenen Objekte haben selten negative geteilte Referenzanzahlen.

Wenn der besitzende Thread beendet wurde, führt der agierende Thread sofort die lokalen und geteilten Referenzzählungsfelder zusammen und wechselt in den Zustand „merged“.

Merged (0b11)

Der Zustand „merged“ zeigt an, dass das Objekt keinem Thread gehört. Das Feld ob_tid ist in diesem Zustand Null und ob_ref_local wird nicht verwendet. Sobald die geteilte Referenzanzahl Null erreicht, kann das Objekt aus dem zusammengeführten Zustand deallokiert werden.

Pseudo-Code für Referenzzählung

Die vorgeschlagene Operation Py_INCREF und Py_DECREF sollte wie folgt funktionieren (unter Verwendung von C-ähnlichem Pseudocode):

// low two bits of "ob_ref_shared" are used for flags
#define _Py_SHARED_SHIFT 2

void Py_INCREF(PyObject *op)
{
  uint32_t new_local = op->ob_ref_local + 1;
  if (new_local == 0)
    return;  // object is immortal
  if (op->ob_tid == _Py_ThreadId())
    op->ob_ref_local = new_local;
  else
    atomic_add(&op->ob_ref_shared, 1 << _Py_SHARED_SHIFT);
}

void Py_DECREF(PyObject *op)
{
  if (op->ob_ref_local == _Py_IMMORTAL_REFCNT) {
    return;  // object is immortal
  }
  if (op->ob_tid == _Py_ThreadId()) {
    op->ob_ref_local -= 1;
    if (op->ob_ref_local == 0) {
      _Py_MergeZeroRefcount(); // merge refcount
    }
  }
  else {
    _Py_DecRefShared(); // slow path
  }
}

void _Py_MergeZeroRefcount(PyObject *op)
{
  if (op->ob_ref_shared == 0) {
    // quick deallocation code path (common case)
    op->ob_tid = 0;
    _Py_Dealloc(op);
  }
  else {
    // slower merging path not shown
  }
}

Die Referenzimplementierung [17] enthält Implementierungen von _Py_MergeZeroRefcount und _Py_DecRefShared.

Beachten Sie, dass das obige Pseudocode ist: In der Praxis sollte die Implementierung „relaxed atomics“ verwenden, um auf ob_tid und ob_ref_local zuzugreifen, um undefiniertes Verhalten in C und C++ zu vermeiden.

Verzögerte Referenzzählung

Einige Objekttypen, wie z.B. Top-Level-Funktionen, Code-Objekte, Module und Methoden, werden tendenziell häufig von vielen Threads gleichzeitig aufgerufen. Diese Objekte leben nicht unbedingt während der gesamten Programmlaufzeit, daher passt die Unsterblichmachung nicht gut. Dieses PEP schlägt eine begrenzte Form der verzögerten Referenzzählung vor, um Konflikte bei den Referenzzählungsfeldern dieser Objekte in Multi-Threaded-Programmen zu vermeiden.

Typischerweise modifiziert der Interpreter die Referenzzählungen von Objekten, wenn diese auf den Stack des Interpreters gelegt und vom Stack entfernt werden. Der Interpreter überspringt diese Referenzzählungsoperationen für Objekte, die verzögerte Referenzzählung verwenden. Objekte, die verzögerte Referenzzählung unterstützen, werden markiert, indem die beiden höchstwertigen Bits im Feld der lokalen Referenzanzahl auf eins gesetzt werden.

Da einige Referenzzählungsoperationen übersprungen werden, spiegeln die Referenzzählungsfelder nicht mehr die tatsächliche Anzahl der Referenzen auf diese Objekte wider. Die tatsächliche Referenzanzahl ist die Summe der Referenzzählungsfelder plus alle übersprungenen Referenzen vom Interpreter-Stack jedes Threads. Die tatsächliche Referenzanzahl kann nur sicher berechnet werden, wenn alle Threads während der zyklischen Müllsammlung angehalten sind. Folglich können Objekte, die verzögerte Referenzzählung verwenden, nur während der Müllsammlungszyklen deallokiert werden.

Beachten Sie, dass die Objekte, die verzögerte Referenzzählung verwenden, in CPython bereits natürlich Referenzzyklen bilden, sodass sie auch ohne verzögerte Referenzzählung normalerweise vom Garbage Collector deallokiert würden. Zum Beispiel bilden Top-Level-Funktionen und Module einen Referenzzyklus, ebenso wie Methoden und Typobjekte.

Änderungen am Garbage Collector für verzögerte Referenzzählung

Der Tracing-Garbage-Collector findet und deallokiert nicht referenzierte Objekte. Derzeit findet der Tracing-Garbage-Collector nur nicht referenzierte Objekte, die Teil eines Referenzzyklus sind. Mit verzögerter Referenzzählung wird der Tracing-Garbage-Collector auch einige nicht referenzierte Objekte finden und sammeln, die möglicherweise nicht Teil eines Referenzzyklus sind, deren Sammlung aber aufgrund der verzögerten Referenzzählung verzögert wurde. Dies erfordert, dass alle Objekte, die verzögerte Referenzzählung unterstützen, auch ein entsprechendes Typobjekt haben, das Tracing-Garbage-Collection unterstützt (über das Flag Py_TPFLAGS_HAVE_GC). Zusätzlich muss der Garbage Collector den Stack jedes Threads durchlaufen, um zu Beginn jeder Sammlung Referenzen zur GC-Referenzzählung hinzuzufügen.

Referenzzählung von Typobjekten

Typobjekte (PyTypeObject) verwenden eine Mischung aus Referenzzählungstechniken. Statisch zugewiesene Typobjekte sind unsterblich, da die Objekte bereits während der gesamten Programmlaufzeit existieren. Heap-Typobjekte verwenden eine verzögerte Referenzzählung in Kombination mit einer Thread-lokalen Referenzzählung. Eine verzögerte Referenzzählung ist nicht ausreichend, um die Skalierungsprobleme von Heap-Typen in Multi-Threaded-Umgebungen zu lösen, da die meisten Referenzen auf Heap-Typen von Instanzen stammen und nicht von Referenzen auf dem Interpreter-Stack.

Um dies zu beheben, werden die Referenzzählungen von Heap-Typen teilweise verteilt in Thread-lokalen Arrays gespeichert. Jeder Thread speichert ein Array lokaler Referenzzählungen für jedes Heap-Typobjekt. Heap-Typobjekten wird eine eindeutige Nummer zugewiesen, die ihre Position in den lokalen Referenzzählungs-Arrays bestimmt. Die tatsächliche Referenzzählung eines Heap-Typs ist die Summe seiner Einträge in den Thread-lokalen Arrays, zuzüglich der Referenzzählung des PyTypeObject und zuzüglich aller verzögerten Referenzen auf dem Interpreter-Stack.

Threads können ihre eigenen Typ-Referenzzählungs-Arrays nach Bedarf erweitern, wenn sie die lokale Referenzzählung eines Typobjekts inkrementieren oder dekrementieren.

Die Verwendung der Thread-lokalen Referenzzählungs-Arrays ist auf wenige Stellen beschränkt

  • PyType_GenericAlloc(PyTypeObject *type, Py_ssize_t nitems): Erhöht die lokale Referenzzählung des aktuellen Threads für type, falls es sich um einen Heap-Typ handelt.
  • subtype_dealloc(PyObject *self): Verringert die lokale Referenzzählung des aktuellen Threads für self->ob_type, falls der Typ ein Heap-Typ ist.
  • gcmodule.c: Fügt die lokalen Referenzzählungen jedes Threads zur gc_refs-Zählung für das entsprechende Heap-Typobjekt hinzu.

Zusätzlich, wenn ein Thread beendet wird, werden alle nicht-null lokalen Referenzzählungen zum eigenen Referenzzählungsfeld jedes Typobjekts hinzugefügt.

Speicherverwaltung

CPython verwendet derzeit einen internen Allokator, pymalloc, der für die Allokation kleiner Objekte optimiert ist. Die pymalloc-Implementierung ist ohne GIL nicht Thread-sicher. Dieses PEP schlägt vor, pymalloc durch mimalloc zu ersetzen, einen allgemeinen, Thread-sicheren Allokator mit guter Leistung, auch für kleine Allokationen.

Die Verwendung von mimalloc, mit einigen Modifikationen, löst auch zwei weitere Probleme im Zusammenhang mit der Entfernung des GIL. Erstens ermöglicht das Durchlaufen der internen mimalloc-Strukturen dem Garbage Collector, alle Python-Objekte zu finden, ohne eine verknüpfte Liste zu pflegen. Dies wird im Abschnitt Garbage Collection detaillierter beschrieben. Zweitens ermöglichen mimalloc-Heaps und Allokationen basierend auf Größenklassen Sammlungen wie dict im Allgemeinen, Sperren während Leseoperationen zu vermeiden. Dies wird im Abschnitt Thread-Sicherheit von Sammlungen detaillierter beschrieben.

CPython verlangt bereits, dass Objekte, die Garbage Collection unterstützen, die GC-Allokator-APIs verwenden (typischerweise indirekt durch Aufruf von PyType_GenericAlloc). Dieses PEP würde zusätzliche Anforderungen an die Verwendung der Python-Allokator-APIs stellen. Erstens müssen Python-Objekte über Objektallokations-APIs allokiert werden, wie z.B. PyType_GenericAlloc, PyObject_Malloc oder andere Python-APIs, die diese Aufrufe umschließen. Python-Objekte sollten nicht über andere APIs allokiert werden, wie z.B. rohe Aufrufe an C's malloc oder den C++ new-Operator. Darüber hinaus sollte PyObject_Malloc nur für die Allokation von Python-Objekten verwendet werden; es sollte nicht für die Allokation von Puffern, Speichern oder anderen Datenstrukturen verwendet werden, die keine PyObjects sind.

Dieses PEP setzt auch Einschränkungen für die pluggable allocator API (PyMem_SetAllocator). Beim Kompilieren ohne GIL müssen Allokatoren, die über diese API gesetzt wurden, die Allokation letztendlich an den entsprechenden zugrundeliegenden Allokator delegieren, wie z.B. PyObject_Malloc, für Python-Objektallokationen. Dies ermöglicht Allokatoren, die zugrundeliegende Allokatoren "umhüllen", wie z.B. Python's tracemalloc und den Debug-Allokator, aber nicht den Allokator vollständig zu ersetzen.

CPython Free Lists

CPython nutzt Freilisten, um die Allokation kleiner, häufig allokierter Objekte wie Tupel und Zahlen zu beschleunigen. Diese Freilisten werden von der Per-Interpreter-State in PyThreadState verschoben.

Garbage Collection (Cycle Collection)

Der CPython-Garbage-Collector erfordert die folgenden Änderungen, um mit diesem Vorschlag zu funktionieren

  • Verwendung von "stop-the-world", um Thread-Sicherheitsgarantien zu bieten, die zuvor vom GIL bereitgestellt wurden.
  • Abschaffung der generativen Garbage Collection zugunsten eines nicht-generativen Collectors.
  • Integration mit verzögerter Referenzzählung und biased Referenzzählung.

Darüber hinaus ermöglichen die oben genannten Änderungen die Entfernung der Felder _gc_prev und _gc_next aus GC-Objekten. Die GC-Bits, die die Zustände "tracked", "finalized" und "unreachable" speicherten, werden in das Feld ob_gc_bits im PyObject-Header verschoben.

Stop-the-World

Der CPython-Zyklus-Garbage-Collector stützt sich derzeit auf den globalen Interpreter-Lock (GIL), um zu verhindern, dass andere Threads auf Python-Objekte zugreifen, während der Collector Zyklen findet. Der GIL wird während der Zykluserkennungsroutine niemals freigegeben, sodass der Collector auf stabile (d.h. unveränderliche) Referenzzählungen und Referenzen für die Dauer dieser Routine zurückgreifen kann. Nach der Zykluserkennung kann der GIL jedoch vorübergehend freigegeben werden, während die Finalizer und tp_clear-Funktionen von Objekten aufgerufen werden, wodurch andere Threads auf verschachtelte Weise ausgeführt werden können.

Beim Ausführen ohne GIL benötigt die Implementierung eine Möglichkeit, sicherzustellen, dass die Referenzzählungen während der Zykluserkennung stabil bleiben. Threads, die Python-Code ausführen, müssen angehalten werden, um sicherzustellen, dass Referenzen und Referenzzählungen stabil bleiben. Sobald die Zyklen identifiziert sind, werden andere Threads fortgesetzt.

Der aktuelle CPython-Zyklus-Garbage-Collector führt zwei Zykluserkennungsdurchläufe während jedes Garbage-Collection-Zyklus durch. Folglich erfordert dies zwei "Stop-the-World"-Pausen, wenn der Garbage-Collector ohne GIL ausgeführt wird. Der erste Zykluserkennungsdurchlauf identifiziert zyklischen Müll. Der zweite Durchlauf wird nach den Finalizern ausgeführt, um zu identifizieren, welche Objekte immer noch unerreichbar sind. Beachten Sie, dass andere Threads vor den Finalizern und tp_clear-Funktionen wieder aufgenommen werden, um potenzielle Deadlocks zu vermeiden, die im aktuellen CPython-Verhalten nicht vorhanden sind.

Thread-Zustände

Um das Anhalten von Threads für die Garbage Collection zu unterstützen, erhält PyThreadState ein neues Feld "status". Wie die anderen Felder in PyThreadState ist das Statusfeld kein Teil der öffentlichen CPython-API. Das Statusfeld kann einen von drei Zuständen annehmen

  • ATTACHED
  • DETACHED
  • GC

Die Zustände ATTACHED und DETACHED entsprechen weitgehend dem Erwerben und Freigeben des globalen Interpreter-Locks. Beim Kompilieren ohne GIL wechseln Funktionen, die zuvor das GIL erworben haben, den Thread-Status zu ATTACHED, und Funktionen, die zuvor das GIL freigegeben haben, wechseln den Thread-Status zu DETACHED. Genauso wie Threads zuvor das GIL erwerben mussten, bevor sie auf Python-Objekte zugreifen oder diese ändern konnten, müssen sie sich jetzt im Zustand ATTACHED befinden, bevor sie auf Python-Objekte zugreifen oder diese ändern. Da dieselben öffentlichen C-API-Funktionen den Thread "anhängen" wie zuvor das GIL erworben haben (z.B. PyEval_RestoreThread), bleiben die Anforderungen an die Thread-Initialisierung in Erweiterungen gleich. Der wesentliche Unterschied besteht darin, dass mehrere Threads gleichzeitig im angehängten Zustand sein können, während zuvor nur ein Thread gleichzeitig das GIL erwerben konnte.

Während "Stop-the-world"-Pausen muss der Thread, der die Garbage Collection durchführt, sicherstellen, dass kein anderer Thread auf Python-Objekte zugreift oder diese modifiziert. Alle anderen Threads müssen sich im Zustand "GC" befinden. Der Garbage-Collection-Thread kann andere Threads vom Zustand DETACHED in den GC-Zustand überführen, indem er eine atomare Compare-and-Swap-Operation auf dem Statusfeld durchführt. Threads im Zustand ATTACHED werden aufgefordert, sich selbst anzuhalten und ihren Status auf "GC" zu setzen, unter Verwendung des vorhandenen "eval breaker"-Mechanismus. Am Ende der "Stop-the-world"-Pause werden alle Threads im Zustand "GC" auf DETACHED gesetzt und geweckt, falls sie pausiert waren. Threads, die zuvor angehängt waren (d.h. Python-Bytecode ausführten), können sich wieder anhängen (Thread-Status auf ATTACHED setzen) und die Ausführung von Python-Code fortsetzen. Threads, die zuvor DETACHED waren, ignorieren die Benachrichtigung.

Generationen

Der bestehende Python-Garbage-Collector verwendet drei Generationen. Beim Kompilieren ohne GIL wird der Garbage-Collector nur eine einzige Generation verwenden (d.h. er wird nicht-generativ sein). Der Hauptgrund für diese Änderung ist die Reduzierung der Auswirkungen von "Stop-the-world"-Pausen in Multi-Threaded-Anwendungen. Häufige "Stop-the-world"-Pausen für das Sammeln der jungen Generation würden sich stärker auf Multi-Threaded-Anwendungen auswirken als weniger häufige Sammlungen.

Integration mit verzögerter und voreingenommener Referenzzählung

Um nicht referenzierte Objekte zu finden, berechnet der zyklische Garbage Collector die Differenz zwischen der Anzahl der eingehenden Referenzen und der Referenzzählung des Objekts. Diese Differenz wird als gc_refs bezeichnet und im Feld _gc_prev gespeichert. Wenn gc_refs größer als Null ist, ist das Objekt garantiert lebendig (d.h. kein zyklischer Müll). Wenn gc_refs Null ist, ist das Objekt nur lebendig, wenn es transitiv von einem anderen lebenden Objekt referenziert wird. Bei der Berechnung dieser Differenz sollte der Collector den Stack jedes Threads durchlaufen und für jede verzögerte Referenz gc_refs für das referenzierte Objekt inkrementieren. Da Generatorobjekte auch Stacks mit verzögerten Referenzen haben, wird das gleiche Verfahren für jeden Generator-Stack angewendet.

Python-Unit-Tests verwenden häufig gc.collect(), um sicherzustellen, dass nicht referenzierte Objekte zerstört und ihre Finalizer ausgeführt werden. Da die "biased reference counting" die Zerstörung einiger Objekte verzögern kann, die von mehreren Threads referenziert werden, ist es praktisch, sicherzustellen, dass diese Objekte während der Garbage Collection zerstört werden, auch wenn sie nicht Teil von Referenzzyklen sind. Während andere Threads pausiert sind, sollte der Garbage-Collector-Thread die Referenzzählungen für alle in der Warteschlange befindlichen Objekte zusammenführen, aber keine Destruktoren aufrufen, auch wenn die kombinierte Referenzzählung Null ist. (Das Aufrufen von Destruktoren während andere Threads pausiert sind, birgt das Risiko von Deadlocks.) Sobald andere Threads wieder aufgenommen werden, sollte der GC-Thread _Py_Dealloc für diese Objekte mit einer kombinierten Referenzzählung von Null aufrufen.

Thread-Sicherheit von Containern

In CPython schützt der globale Interpreter-Lock (GIL) vor der Beschädigung interner Interpreter-Zustände, wenn mehrere Threads gleichzeitig auf Python-Objekte zugreifen oder diese ändern. Wenn beispielsweise mehrere Threads dieselbe Liste gleichzeitig ändern, stellt der GIL sicher, dass die Länge der Liste (ob_size) genau der Anzahl der Elemente entspricht und dass die Referenzzählungen jedes Elements die Anzahl der Referenzen auf diese Elemente genau widerspiegeln. Ohne den GIL – und ohne andere Änderungen – würden gleichzeitige Modifikationen diese Felder beschädigen und wahrscheinlich zu Programmabstürzen führen.

Der GIL stellt nicht unbedingt sicher, dass Operationen atomar sind oder korrekt bleiben, wenn mehrere Operationen gleichzeitig ablaufen. Beispielsweise ist list.extend(iterable) möglicherweise nicht atomar, wenn das Iterable einen Iterator hat, der in Python implementiert ist (oder den GIL intern freigibt). Ebenso kann list.remove(x) das falsche Objekt entfernen, wenn es sich mit einer anderen Operation überschneidet, die die Liste modifiziert, abhängig von der Implementierung des Gleichheitsoperators. Dennoch stellt der GIL sicher, dass einige Operationen effektiv atomar sind. Beispielsweise kopiert der Konstruktor list(set) die Elemente des Sets atomar in eine neue Liste, und einige Codes verlassen sich darauf, dass diese Kopie atomar ist (d.h. ein Schnappschuss der Elemente im Set vorliegt). Dieses PEP bewahrt diese Eigenschaft.

Dieses PEP schlägt die Verwendung von Objektsperren vor, um viele der gleichen Schutzfunktionen zu bieten, die der GIL bietet. Zum Beispiel hat jede Liste, jedes Wörterbuch und jede Menge eine zugehörige leichtgewichtige Sperre. Alle Operationen, die das Objekt ändern, müssen die Sperre des Objekts halten. Die meisten Operationen, die vom Objekt lesen, sollten ebenfalls die Sperre des Objekts erwerben; die wenigen Leseoperationen, die ohne Sperre durchgeführt werden können, werden nachfolgend beschrieben.

Objektsperren mit kritischen Abschnitten bieten schwächere Schutzfunktionen als der GIL. Da der GIL nicht unbedingt sicherstellt, dass gleichzeitige Operationen atomar oder korrekt sind, kann das Objektsperrschema auch nicht sicherstellen, dass gleichzeitige Operationen atomar oder korrekt sind. Stattdessen zielt die Objektsperrung auf ähnliche Schutzfunktionen wie der GIL ab, jedoch mit gegenseitigem Ausschluss, der auf einzelne Objekte beschränkt ist.

Die meisten Operationen auf einer Instanz eines Container-Typs erfordern das Sperren dieses Objekts. Zum Beispiel

  • list.append, list.insert, list.repeat, PyList_SetItem
  • dict.__setitem__, PyDict_SetItem
  • list.clear, dict.clear
  • list.__repr__, dict.__repr__, usw.
  • list.extend(iterable)
  • setiter_iternext

Einige Operationen arbeiten direkt mit zwei Container-Objekten und kennen die interne Struktur beider Container. Zum Beispiel gibt es interne Spezialisierungen von list.extend(iterable) für bestimmte Iterable-Typen, wie z.B. set. Diese Operationen müssen beide Container sperren, da sie gleichzeitig auf die Interna beider Objekte zugreifen. Beachten Sie, dass die generische Implementierung von list.extend nur ein Objekt (die Liste) sperren muss, da das andere Objekt indirekt über die Thread-sichere Iterator-API angesprochen wird. Operationen, die zwei Container sperren, sind

  • list.extend(list), list.extend(set), list.extend (dictitems) und andere Spezialisierungen, bei denen die Implementierung für den Argumenttyp spezialisiert ist.
  • list.concat(list)
  • list.__eq__(list), dict.__eq__(dict)

Einige einfache Operationen können direkt mit atomaren Zugriffen implementiert werden und benötigen keine Sperren, da sie nur auf ein einziges Feld zugreifen. Zu diesen Operationen gehören

  • len(list), d.h. list_length(PyListObject *a)
  • len(dict)
  • len(set)

Einige wenige ausgewählte Operationen vermeiden optimistisch Sperren, um die Leistung zu verbessern. Diese erfordern spezielle Implementierungen und die Zusammenarbeit mit dem Speicherallokator

  • list[idx] (list_subscript)
  • dict[key] (dict_subscript)
  • listiter_next, dictiter_iternextkey/value/item
  • list.contains

Ausgeliehene Referenzen

Objektsperren bieten viele der wichtigen Schutzfunktionen des GIL, aber es gibt einige Fälle, in denen dies nicht ausreicht. Zum Beispiel kann Code, der sich auf das Upgrade einer geliehenen Referenz zu einer "besessenen" Referenz verlässt, unter bestimmten Umständen unsicher sein

PyObject *item = PyList_GetItem(list, idx);
Py_INCREF(item);

Der GIL stellt sicher, dass kein anderer Thread die Liste zwischen dem Zugriff und dem Aufruf von Py_INCREF ändern kann. Ohne den GIL – selbst mit Objektsperren – könnte ein anderer Thread die Liste ändern, was dazu führt, dass item zwischen dem Zugriff und dem Aufruf von Py_INCREF freigegeben wird.

Die problematischen "borrowed reference" APIs werden durch Funktionen ergänzt, die "new references" zurückgeben, aber ansonsten äquivalent sind

  • PyList_FetchItem(list, idx) für PyList_GetItem
  • PyDict_FetchItem(dict, key) für PyDict_GetItem
  • PyWeakref_FetchObject für PyWeakref_GetObject

Beachten Sie, dass einige APIs, die geliehene Referenzen zurückgeben, wie z.B. PyTuple_GetItem, nicht problematisch sind, da Tupel unveränderlich sind. Ebenso sind nicht alle Verwendungen der obigen APIs problematisch. Zum Beispiel wird PyDict_GetItem oft zum Parsen von Keyword-Argument-Dictionaries in Funktionsaufrufen verwendet; diese Keyword-Argument-Dictionaries sind im Wesentlichen privat (nicht für andere Threads zugänglich).

Python Kritische Abschnitte

Eine einfache Objektsperrung könnte zu Deadlocks führen, die beim Ausführen mit dem GIL nicht vorhanden waren. Threads können Sperren für mehrere Objekte gleichzeitig halten, da Python-Operationen verschachtelt sein können. Operationen auf Objekten können Operationen auf anderen Objekten aufrufen und mehrere Objektsperren erwerben. Wenn Threads versuchen, dieselben Sperren in unterschiedlicher Reihenfolge zu erwerben, kommt es zu Deadlocks.

Dieses PEP schlägt ein Schema namens "Python critical sections" vor, um Objektsperren implizit freizugeben und Deadlocks zu vermeiden. Um das Schema zu verstehen, führen wir zuerst einen allgemeinen Ansatz zur Vermeidung von Deadlocks ein und schlagen dann eine Verfeinerung dieses Ansatzes mit besserer Leistung vor.

Eine Möglichkeit, Deadlocks zu vermeiden, besteht darin, Threads zu erlauben, nur die Sperre (oder Sperren) für eine einzelne Operation gleichzeitig zu halten (typischerweise eine einzelne Sperre, aber einige Operationen beinhalten zwei Sperren, wie oben beschrieben). Wenn ein Thread eine verschachtelte Operation beginnt, sollte er die Sperren für jede äußere Operation aussetzen: Bevor die verschachtelte Operation beginnt, werden die Sperren für die äußere Operation freigegeben, und wenn die verschachtelte Operation abgeschlossen ist, werden die Sperren für die äußere Operation wiedererworben.

Darüber hinaus sollten die Sperren für jede aktive Operation um potenziell blockierende Operationen, wie z.B. I/O (d.h. Operationen, die das GIL freigegeben hätten), ausgesetzt werden. Dies liegt daran, dass die Interaktion zwischen Sperren und blockierenden Operationen zu Deadlocks führen kann, ähnlich wie die Interaktion zwischen mehreren Sperren.

Um die Leistung zu verbessern, schlägt dieses PEP eine Variante des oben genannten Schemas vor, das immer noch Deadlocks vermeidet. Anstatt Sperren sofort auszusetzen, wenn eine verschachtelte Operation beginnt, werden Sperren nur ausgesetzt, wenn der Thread blockieren würde (d.h. das GIL freigegeben hätte). Dies reduziert die Anzahl der Sperrerwerbungen und -freigaben für verschachtelte Operationen und vermeidet gleichzeitig Deadlocks.

Die vorgeschlagene API für Python kritische Abschnitte sind die folgenden vier Makros. Diese sind als öffentlich gedacht (verwendbar von C-API-Erweiterungen), aber nicht Teil der eingeschränkten API

  • Py_BEGIN_CRITICAL_SECTION(PyObject *op);: Beginnt einen kritischen Abschnitt, indem die Mutex für das referenzierte Objekt erworben wird. Wenn das Objekt bereits gesperrt ist, werden die Sperren für alle ausstehenden kritischen Abschnitte freigegeben, bevor dieser Thread auf die Entsperrung des referenzierten Objekts wartet.
  • Py_END_CRITICAL_SECTION;: Beendet die zuletzt begonnene Operation und gibt die Mutex frei. Der nächstältere vorherige kritische Abschnitt (falls vorhanden) wird fortgesetzt, wenn er gerade ausgesetzt ist.
  • Py_BEGIN_CRITICAL_SECTION2(PyObject *a, PyObject *b);: Beginnt einen kritischen Abschnitt, indem die Mutexe für zwei Objekte erworben werden. Um eine konsistente Sperrenreihenfolge zu gewährleisten, wird die Reihenfolge des Erwerbs durch die Speicheradresse bestimmt (d.h. der Mutex mit der niedrigeren Speicheradresse wird zuerst erworben). Wenn einer der Mutexe bereits gesperrt ist, werden die Sperren für alle ausstehenden kritischen Abschnitte freigegeben, bevor dieser Thread auf die Entsperrung der referenzierten Objekte wartet.
  • Py_END_CRITICAL_SECTION2;: Funktioniert genauso wie Py_END_CRITICAL_SECTION, gibt aber zwei Objekte frei.

Darüber hinaus sollte ein Thread beim Übergang vom Zustand ATTACHED zum Zustand DETACHED alle aktiven kritischen Abschnitte aussetzen. Beim Übergang von DETACHED zu ATTACHED sollte der zuletzt ausgesetzte kritische Abschnitt (falls vorhanden) fortgesetzt werden.

Beachten Sie, dass Operationen, die zwei Container gleichzeitig sperren, das Makro Py_BEGIN_CRITICAL_SECTION2 verwenden müssen. Es reicht nicht aus, zwei Aufrufe von Py_BEGIN_CRITICAL_SECTION zu verschachteln, da der innere kritische Abschnitt die Sperren des äußeren kritischen Abschnitts freigeben kann.

Optimistisches Vermeiden von Sperren

Einige Operationen auf dict und list vermeiden optimistisch das Erwerben der Objektsperren. Sie verfügen über eine schnelle Pfadoperation, die keine Sperren erwirbt, aber auf eine langsamere Operation zurückfallen kann, die die Sperre des Wörterbuchs oder der Liste erwirbt, wenn ein anderer Thread diesen Container gleichzeitig modifiziert.

Die Operationen mit einem optimistischen Schnellpfad sind

  • PyDict_FetchItem/GetItem und dict.__getitem__
  • PyList_FetchItem/GetItem und list.__getitem__

Darüber hinaus verwenden Iteratoren für dict und list die oben genannten Funktionen, sodass sie ebenfalls optimistisch das Sperren vermeiden, wenn sie das nächste Element zurückgeben.

Es gibt zwei Gründe, die Sperrung in diesen Funktionen zu vermeiden. Der Hauptgrund ist, dass dies für skalierbare Multi-Threaded-Leistung, selbst für einfache Anwendungen, notwendig ist. Wörterbücher enthalten Top-Level-Funktionen in Modulen und Methoden für Klassen. Diese Wörterbücher werden in Multi-Threaded-Programmen von vielen Threads naturgemäß stark gemeinsam genutzt. Die Konkurrenz um diese Sperren in Multi-Threaded-Programmen zum Laden von Methoden und Funktionen würde die effiziente Skalierung in vielen grundlegenden Programmen behindern.

Der sekundäre Grund für die Vermeidung von Sperren ist die Reduzierung des Overheads und die Verbesserung der Single-Threaded-Leistung. Obwohl der Erwerb von Sperren im Vergleich zu den meisten Operationen einen geringen Overhead hat, sind Zugriffe auf einzelne Elemente von Listen und Wörterbüchern schnelle Operationen (daher ist der Sperr-Overhead vergleichsweise größer) und häufig (daher hat der Overhead mehr Einfluss).

Dieser Abschnitt beschreibt die Herausforderungen bei der Implementierung von Wörterbuch- und Listen-Zugriffen ohne Sperrung, gefolgt von einer Beschreibung der Änderungen dieses PEP am Python-Interpreter, die erforderlich sind, um diese Herausforderungen zu bewältigen.

Die Hauptschwierigkeit besteht darin, dass das Abrufen eines Elements aus einer Liste oder einem Wörterbuch und das Erhöhen der Referenzzählung dieses Elements keine atomare Operation ist. Zwischen dem Zeitpunkt, an dem das Element abgerufen wird, und der Erhöhung der Referenzzählung, kann ein anderer Thread die Liste oder das Wörterbuch ändern und möglicherweise den Speicher für das zuvor abgerufene Element freigeben.

Ein teilweiser Versuch, dieses Problem anzugehen, wäre die Umwandlung der Erhöhung der Referenzzählung in eine bedingte Erhöhung, die nur dann erhöht wird, wenn sie nicht Null ist. Diese Änderung ist nicht ausreichend, da beim Erreichen von Null die Destruktorfunktion eines Python-Objekts aufgerufen wird und der Speicher, der das Objekt speichert, für andere Datenstrukturen wiederverwendet oder an das Betriebssystem zurückgegeben werden kann. Stattdessen schlägt dieses PEP eine Technik vor, um sicherzustellen, dass die Felder der Referenzzählung für die Dauer des Zugriffs gültig bleiben, sodass die bedingte Erhöhung der Referenzzählung sicher ist. Diese Technik erfordert die Zusammenarbeit mit dem Speicherallokator (mimalloc) sowie Änderungen an den Listen- und Wörterbuchobjekten. Die vorgeschlagene Technik ähnelt dem Read-Copy Update (RCU) [6], einem weit verbreiteten Synchronisationsmechanismus im Linux-Kernel.

Die aktuelle Implementierung von list_item (der C-Funktion, die list.__getitem__ implementiert) ist wie folgt

Py_INCREF(a->ob_item[i]);
return a->ob_item[i];

Die vorgeschlagene Implementierung verwendet die bedingte Inkrementierung (_Py_TRY_INCREF) und hat zusätzliche Prüfungen

 PyObject **ob_item = atomic_load(&a->ob_item);
 PyObject *item = atomic_load(&ob_item[i]);
 if (!item || !_Py_TRY_INCREF(item)) goto retry;
 if (item != atomic_load(&ob_item[i])) {
   Py_DECREF(item);
   goto retry;
 }
 if (ob_item != atomic_load(&a->ob_item)) {
   Py_DECREF(item);
   goto retry;
}
return item;

Die Unterroutine "retry" implementiert den gesperrten Fallback-Pfad, wenn gleichzeitige Modifikationen der Liste dazu führen, dass der obige schnelle, nicht-sperrende Pfad fehlschlägt

retry:
  PyObject *item;
  Py_BEGIN_CRITICAL_SECTION(a->ob_mutex);
  item = a->ob_item[i];
  Py_INCREF(item);
  Py_END_CRITICAL_SECTION(a->ob_mutex);
  return item;

Die Modifikationen an der dict-Implementierung sind ähnlich, da die relevanten Teile sowohl des Listen- als auch des Wörterbuch-Abrufs das Laden eines Elements/Werts aus einem Array an einem bekannten Index beinhalten.

Die zusätzlichen Prüfungen nach der bedingten Inkrementierung sind notwendig, da das Schema die sofortige Wiederverwendung von Speicher zulässt, einschließlich des Speichers, der zuvor eine PyObject-Struktur oder ein list- oder dict-Array enthielt. Ohne diese zusätzlichen Prüfungen könnte die Funktion ein Python-Objekt zurückgeben, das nie in der Liste war, wenn der Speicher, der das Python-Objekt belegte, zuvor ein anderes PyObject enthielt, dessen Speicher zuvor ein Element in der Liste enthielt.

Mimalloc-Änderungen für optimierten Zugriff auf list und dict

Die Implementierung erfordert zusätzliche Einschränkungen für den Speicherallokator, einschließlich einiger Änderungen am mimalloc-Code. Einige Hintergrundinformationen zur mimalloc-Implementierung sind hilfreich, um die erforderlichen Änderungen zu verstehen. Einzelne Allokationen von mimalloc werden als "Blöcke" bezeichnet. mimalloc "Seiten" enthalten aufeinanderfolgende Blöcke, die alle die gleiche Größe haben. Eine mimalloc "Seite" ist ähnlich einem "Superblock" in anderen Allokatoren; sie ist KEINE Betriebssystem-Seite. Ein mimalloc "Heap" enthält Seiten verschiedener Größenklassen; jede Seite gehört zu einem einzigen Heap. Wenn keiner der Blöcke einer Seite allokiert ist, kann mimalloc die Seite für eine andere Größenklasse oder einen anderen Heap wiederverwenden (d.h. es kann die Seite neu initialisieren).

Das Listen- und Wörterbuchzugriffsschema funktioniert, indem die Wiederverwendung von mimalloc-Seiten teilweise eingeschränkt wird, damit die Referenzzählungsfelder für die Dauer des Zugriffs gültig bleiben. Die eingeschränkte Wiederverwendung von mimalloc-Seiten wird durch separate Heaps für Python-Objekte [7] erzwungen. Dies stellt sicher, dass selbst wenn ein Element während des Zugriffs freigegeben und der Speicher für ein neues Objekt wiederverwendet wird, das Feld der Referenzzählung des neuen Objekts an derselben Speicherstelle liegt. Das Referenzzählungsfeld bleibt über Allokationen hinweg gültig (oder Null).

Python-Objekte, die Py_TPFLAGS_MANAGED_DICT unterstützen, haben ihre Wörterbuch- und Schwachstellenreferenzfelder vor dem PyObject-Header, sodass ihre Referenzzählungsfelder einen anderen Offset vom Anfang ihrer Allokationen haben. Sie werden in einem separaten mimalloc-Heap gespeichert. Darüber hinaus werden Nicht-GC-Objekte in ihrem eigenen Heap gespeichert, damit die GC nur GC-Objekte betrachten muss. Daher gibt es drei mimalloc-Heaps für Python-Objekte, einen für Nicht-GC-Objekte, einen für GC-Objekte mit verwalteten Wörterbüchern und einen für GC-Objekte ohne verwaltete Wörterbücher.

Mimalloc Seitenwiederverwendung

Es ist von Vorteil, die Einschränkungen für die Wiederverwendung von mimalloc-Seiten auf einen kurzen Zeitraum zu beschränken, um den Gesamtspeicherverbrauch nicht zu erhöhen. Eine genaue Begrenzung der Einschränkungen auf Listen- und Wörterbuchzugriffe würde den Speicherverbrauch minimieren, würde aber teure Synchronisationen erfordern. Am anderen Extrem würde die Beibehaltung der Einschränkungen bis zum nächsten GC-Zyklus keine zusätzlichen Synchronisationen einführen, aber den Speicherverbrauch potenziell erhöhen.

Dieses PEP schlägt ein System vor, das zwischen diesen beiden Extremen liegt und auf FreeBSD's "GUS" [8] basiert. Es verwendet eine Kombination aus globalen und Thread-lokalen Zählern (oder "Sequenznummern"), um die Bestimmung zu koordinieren, wann es sicher ist, eine leere mimalloc-Seite für einen anderen Heap oder eine andere Größenklasse wiederzuverwenden oder sie an das Betriebssystem zurückzugeben.

  • Es gibt eine globale Schreibsequenznummer, die monoton ansteigt.
  • Wenn eine mimalloc-Seite leer ist, wird sie mit der aktuellen Schreibsequenznummer gekennzeichnet. Der Thread kann auch die globale Schreibsequenznummer atomar erhöhen.
  • Jeder Thread hat eine lokale Lesesequenznummer, die die zuletzt beobachtete Schreibsequenznummer aufzeichnet.
  • Threads können die Schreibsequenznummer beobachten, wann immer sie sich nicht in einem Listen- oder Wörterbuchzugriff befinden. Die Referenzimplementierung tut dies in mimalloc's Slow-Path-Allokationsfunktion. Dies wird oft genug aufgerufen, um nützlich zu sein, aber nicht so häufig, um signifikante Overhead zu verursachen.
  • Es gibt eine globale Lesesequenznummer, die das Minimum aller lokalen Lesesequenznummern der aktiven Threads speichert. Ein Thread kann die globale Lesesequenznummer aktualisieren, indem er die lokale Lesesequenznummer jedes Threads scannt. Die Referenzimplementierung tut dies, bevor sie eine neue mimalloc-Seite allokiert, wenn es eingeschränkte Seiten gibt, die potenziell wiederverwendet werden könnten.
  • Eine leere mimalloc-Seite kann für einen anderen Heap oder eine andere Größenklasse wiederverwendet werden, wenn die globale Lesesequenznummer größer ist als die Tag-Nummer der Seite.

Die Bedingung, dass die globale Lesesequenznummer größer ist als der Tag der Seite, ist ausreichend, da sie sicherstellt, dass jeder Thread, der einen gleichzeitigen optimistischen Listen- oder Wörterbuchzugriff hatte, diesen Zugriff abgeschlossen hat. Mit anderen Worten, es gibt keine Threads, die auf die leeren Blöcke der freigegebenen Seite zugreifen, sodass die Seite für jeden anderen Zweck verwendet oder sogar an das Betriebssystem zurückgegeben werden kann.

Zusammenfassung des optimistischen Zugriffs auf dict und list

Dieses PEP schlägt eine Technik für Thread-sichere Listen- und Dictionary-Zugriffe vor, die typischerweise das Erwerben von Sperren vermeidet. Dies reduziert den Ausführungsaufwand und vermeidet einige Multi-Thread-Skalierungshindernisse bei gängigen Operationen wie dem Aufrufen von Funktionen und Methoden. Das Schema funktioniert, indem vorübergehende Beschränkungen für die Wiederverwendung von mimalloc-Seiten eingeführt werden, um sicherzustellen, dass die Referenzzählerfelder von Objekten nach dem Freigeben von Objekten gültig bleiben, sodass bedingte Erhöhungen des Referenzzählers sicher sind. Die Beschränkungen werden auf mimalloc-Seiten und nicht auf einzelne Objekte angewendet, um die Möglichkeiten zur Wiederverwendung von Speicher zu verbessern. Die Beschränkungen werden aufgehoben, sobald das System feststellen kann, dass keine ausstehenden Zugriffe auf die leere mimalloc-Seite erfolgen. Um dies festzustellen, verwendet das System eine Kombination aus leichten pro-Thread-Sequenzzählern und markiert Seiten, wenn sie leer sind. Sobald der lokale Zähler jedes Threads größer ist als die Markierung der Seite, kann sie für jeden Zweck wiederverwendet oder an das Betriebssystem zurückgegeben werden. Die Beschränkungen werden auch aufgehoben, wenn der zyklische Garbage Collector läuft, da die Stop-the-World-Pause sicherstellt, dass Threads keine ausstehenden Referenzen auf leere mimalloc-Seiten haben.

Spezialisierender Interpreter

Der spezialisierende Interpreter erfordert einige Änderungen, um Thread-sicher zu sein, wenn er ohne GIL ausgeführt wird.

  • Gleichzeitige Spezialisierungen werden durch die Verwendung einer Mutex verhindert. Dies verhindert, dass mehrere Threads auf denselben Inline-Cache schreiben.
  • In Multi-Thread-Programmen, die ohne GIL laufen, wird jeder Bytecode nur einmal spezialisiert. Dies verhindert, dass ein Thread einen teilweise geschriebenen Inline-Cache liest.
  • Das Sperren stellt auch sicher, dass zwischengespeicherte Werte von tp_version_tag und keys_version mit den zwischengespeicherten Deskriptoren und anderen Werten konsistent sind.
  • Modifikationen an Inline-Zählern verwenden „relaxed atomics“. Mit anderen Worten, einige Zählerdekremente können verpasst oder überschrieben werden, aber das beeinträchtigt die Korrektheit nicht.

Py_mod_gil Slot

In --disable-gil-Builds prüft CPython beim Laden einer Erweiterung auf einen neuen PEP 489-konformen Py_mod_gil-Slot. Wenn der Slot auf Py_mod_gil_not_used gesetzt ist, fährt die Modulerweiterung normal fort. Wenn der Slot nicht gesetzt ist, pausiert der Interpreter alle Threads und aktiviert den GIL, bevor er fortfährt. Zusätzlich gibt der Interpreter eine sichtbare Warnung aus, die die Erweiterung nennt, dass der GIL aktiviert wurde (und warum), und welche Schritte der Benutzer unternehmen kann, um ihn zu überschreiben.

Umgebungsvariable PYTHONGIL

In --disable-gil-Builds kann der Benutzer das Verhalten auch zur Laufzeit überschreiben, indem er die Umgebungsvariable PYTHONGIL setzt. Das Setzen von PYTHONGIL=0 erzwingt die Deaktivierung des GIL und überschreibt die Modul-Slot-Logik. Das Setzen von PYTHONGIL=1 erzwingt die Aktivierung des GIL.

Die PYTHONGIL=0-Überschreibung ist wichtig, da Erweiterungen, die nicht Thread-sicher sind, in Multi-Thread-Anwendungen weiterhin nützlich sein können. Zum Beispiel möchte man die Erweiterung möglicherweise nur von einem einzigen Thread aus verwenden oder den Zugriff durch Sperren schützen. Zum besseren Verständnis gibt es bereits einige Erweiterungen, die auch mit dem GIL nicht Thread-sicher sind, und Benutzer müssen bereits solche Schritte unternehmen.

Die PYTHONGIL=1-Überschreibung ist manchmal für das Debugging nützlich.

Begründung

Nicht-generationale Garbage Collection

Dieses PEP schlägt vor, von einem generativen zyklischen Garbage Collector zu einem nicht-generativen Collector zu wechseln (wenn CPython ohne den GIL erstellt wird). Das entspricht dem Vorhandensein nur einer Generation (der „alten“ Generation). Es gibt zwei Gründe für diese vorgeschlagene Änderung.

Die zyklische Garbage Collection, selbst nur für die junge Generation, erfordert das Pausieren anderer Threads im Programm. Der Autor ist besorgt, dass häufige Sammlungen der jungen Generation die effiziente Skalierung in Multi-Thread-Programmen beeinträchtigen würden. Dies ist eine Sorge für junge Generationen (aber nicht für die alte Generation), da die jungen Generationen nach einer festen Anzahl von Allokationen gesammelt werden, während die Sammlungen für die ältere Generation im Verhältnis zur Anzahl der lebenden Objekte im Heap geplant werden. Außerdem ist es schwierig, Objekte in jeder Generation effizient zu verfolgen, ohne den GIL. Zum Beispiel verwendet CPython derzeit eine verkettete Liste von Objekten in jeder Generation. Wenn CPython dieses Design beibehalten würde, müssten diese Listen Thread-sicher gemacht werden, und es ist nicht klar, wie das effizient geschehen könnte.

Die generative Garbage Collection wird in vielen anderen Sprach-Laufzeitumgebungen gut eingesetzt. Zum Beispiel verwenden viele der Java HotSpot Garbage Collector-Implementierungen mehrere Generationen [11]. In diesen Laufzeitumgebungen ist eine junge Generation oft ein Gewinn für den Durchsatz: Da ein großer Prozentsatz der jungen Generation typischerweise „tot“ ist, kann der GC einen großen Speicherbereich im Verhältnis zur geleisteten Arbeit zurückgewinnen. Beispielsweise zeigen mehrere Java-Benchmarks, dass über 90 % der „jungen“ Objekte typischerweise gesammelt werden [12] [13]. Dies wird allgemein als „schwache generative Hypothese“ bezeichnet; die Beobachtung ist, dass die meisten Objekte jung sterben. Dieses Muster kehrt sich in CPython aufgrund der Verwendung von Referenzzählung um. Obwohl die meisten Objekte jung sterben, werden sie gesammelt, wenn ihre Referenzzähler Null erreichen. Objekte, die einen Garbage Collection-Zyklus überleben, bleiben wahrscheinlich lebendig [14]. Dieser Unterschied bedeutet, dass die generative Sammlung in CPython weitaus weniger effektiv ist als in vielen anderen Sprach-Laufzeitumgebungen [15].

Optimistisches Vermeiden von Sperren beim Zugriff auf dict und list

Dieser Vorschlag beruht auf einem Schema, das beim Zugriff auf einzelne Elemente in Listen und Dictionaries weitgehend das Erwerben von Sperren vermeidet. Beachten Sie, dass dies nicht „lock-frei“ im Sinne von „lock-free“ und „wait-free“ Algorithmen ist, die einen Fortschritt garantieren. Es vermeidet einfach das Erwerben von Sperren (Mutexes) im häufigen Fall, um Parallelität zu verbessern und den Aufwand zu reduzieren.

Eine viel einfachere Alternative wäre die Verwendung von Reader-Writer-Sperren zum Schutz von Dictionary- und Listen-Zugriffen. Reader-Writer-Sperren erlauben gleichzeitige Lesezugriffe, aber keine Aktualisierungen, was für Listen und Dictionaries ideal erscheinen mag. Das Problem ist, dass Reader-Writer-Sperren erheblichen Aufwand und schlechte Skalierbarkeit aufweisen, insbesondere wenn die kritischen Abschnitte klein sind, wie sie bei Einzel-Element-Dictionary- und Listen-Zugriffen der Fall sind [9]. Die schlechte Leser-Skalierbarkeit rührt daher, dass Leser alle dieselbe Datenstruktur aktualisieren müssen, wie z. B. die Anzahl der Leser in pthread_rwlocks.

Die in diesem PEP beschriebene Technik ist verwandt mit RCU („read-copy-update“) [6] und, in geringerem Maße, mit Hazard Pointers, zwei bekannte Schemata zur Optimierung von gleichzeitigen, hauptsächlich Lese-Datenstrukturen. RCU wird häufig im Linux-Kernel verwendet, um gemeinsame Datenstrukturen skalierbar zu schützen. Sowohl die Technik in diesem PEP als auch RCU arbeiten, indem sie die Rückgewinnung verzögern, während Leser auf die gleichzeitige Datenstruktur zugreifen können. RCU wird am häufigsten zum Schutz einzelner Objekte (wie Hash-Tabellen oder verketteten Listen) verwendet, während dieses PEP ein Schema zum Schutz größerer Speicherblöcke (mimalloc „Seiten“) vorschlägt [10].

Die Notwendigkeit dieses Schemas ist größtenteils auf die Verwendung von Referenzzählung in CPython zurückzuführen. Wenn CPython nur auf einen Tracing Garbage Collector angewiesen wäre, wäre dieses Schema wahrscheinlich nicht notwendig, da Tracing Garbage Collectors die Rückgewinnung bereits auf die erforderliche Weise verzögern. Dies würde Skalierungsprobleme nicht „lösen“, sondern viele der Herausforderungen auf die Implementierung des Garbage Collectors verlagern.

Abwärtskompatibilität

Dieses PEP birgt eine Reihe von Rückwärtskompatibilitätsproblemen beim Erstellen von CPython mit dem Flag --disable-gil, aber diese Probleme treten nicht auf, wenn die Standard-Build-Konfiguration verwendet wird. Nahezu alle Rückwärtskompatibilitätsprobleme betreffen die C-API.

  • CPython-Builds ohne GIL werden aufgrund von Änderungen am Python-Objekt-Header, die für die Unterstützung der verzerrten Referenzzählung erforderlich sind, nicht ABI-kompatibel mit dem Standard-CPython-Build oder mit der stabilen ABI sein. C-API-Erweiterungen müssen speziell für diese Version neu kompiliert werden.
  • C-API-Erweiterungen, die sich auf den GIL verlassen, um globalen Zustand oder Objektzustand in C-Code zu schützen, benötigen zusätzliche explizite Sperren, um Thread-sicher zu bleiben, wenn sie ohne GIL ausgeführt werden.
  • C-API-Erweiterungen, die geliehene Referenzen auf eine Weise verwenden, die ohne GIL nicht sicher ist, müssen die entsprechenden neuen APIs verwenden, die nicht geliehene Referenzen zurückgeben. Beachten Sie, dass nur einige Verwendungen von geliehenen Referenzen ein Problem darstellen; nur Referenzen auf Objekte, die von anderen Threads freigegeben werden könnten, stellen ein Problem dar.
  • Benutzerdefinierte Speicherallokatoren (PyMem_SetAllocator) müssen die tatsächliche Allokation an den zuvor eingestellten Allokator delegieren. Zum Beispiel funktionieren der Python-Debug-Allokator und Tracing-Allokatoren weiterhin, da sie die Allokation an den zugrunde liegenden Allokator delegieren. Andererseits funktioniert das vollständige Ersetzen des Allokators (z. B. mit jemalloc oder tcmalloc) nicht korrekt.
  • Python-Objekte müssen über die Standard-APIs wie PyType_GenericNew oder PyObject_Malloc allokiert werden. Nicht-Python-Objekte dürfen **nicht** über diese APIs allokiert werden. Zum Beispiel ist es derzeit akzeptabel, Puffer (nicht-Python-Objekte) über PyObject_Malloc zu allokieren; das wird nicht mehr erlaubt sein, und Puffer sollten stattdessen über PyMem_Malloc, PyMem_RawMalloc oder malloc allokiert werden.

Es gibt weniger potenzielle Rückwärtskompatibilitätsprobleme für Python-Code.

  • Destruktoren und Weak Reference Callbacks für Code-Objekte und Top-Level-Funktionsobjekte werden aufgrund der Verwendung von verzögerter Referenzzählung bis zur nächsten zyklischen Garbage Collection verzögert.
  • Destruktoren für einige Objekte, auf die von mehreren Threads zugegriffen wird, können sich aufgrund der verzerrten Referenzzählung geringfügig verzögern. Dies ist selten: Die meisten Objekte, auch solche, auf die von mehreren Threads zugegriffen wird, werden sofort zerstört, sobald ihre Referenzzähler Null erreichen. Zwei Stellen in den Tests der Python-Standardbibliothek erforderten gc.collect()-Aufrufe, um weiterhin erfolgreich zu sein.

Distribution

Dieses PEP birgt neue Herausforderungen für die Verteilung von Python. Zumindest für eine gewisse Zeit wird es zwei Versionen von Python geben, die separat kompilierte C-API-Erweiterungen erfordern. Es kann einige Zeit dauern, bis Autoren von C-API-Erweiterungen --disable-gil-kompatible Pakete erstellen und auf PyPI hochladen. Zusätzlich könnten einige Autoren zögern, den --disable-gil-Modus zu unterstützen, bis er weit verbreitet ist, aber die Verbreitung wird wahrscheinlich von der Verfügbarkeit des umfangreichen Satzes von Python-Erweiterungen abhängen.

Um dies zu mildern, wird der Autor mit Anaconda zusammenarbeiten, um eine --disable-gil-Version von Python zusammen mit kompatiblen Paketen von Conda-Kanälen zu verteilen. Dies zentralisiert die Herausforderungen beim Erstellen von Erweiterungen, und der Autor glaubt, dass dies mehr Menschen ermöglichen wird, Python ohne GIL früher zu nutzen, als sie es sonst könnten.

Performance

Die Änderungen zur Thread-Sicherheit von CPython ohne GIL erhöhen den Ausführungsaufwand für --disable-gil-Builds. Die Leistungsauswirkungen sind unterschiedlich für Programme, die nur einen einzelnen Thread verwenden, im Vergleich zu Programmen, die mehrere Threads verwenden, daher berichtet die folgende Tabelle den Ausführungsaufwand getrennt für diese Arten von Programmen.

Ausführungsaufwand auf pyperformance 1.0.6
Intel Skylake AMD Zen 3
Ein Thread 6% 5%
Mehrere Threads 8% 7%

Die als Basis für die Messung des Aufwands verwendete Version ist 018be4c von PR 19474, die unveränderliche Objekte für Python 3.12 implementiert. Der größte Beitrag zum Ausführungsaufwand ist die verzerrte Referenzzählung, gefolgt von der pro-Objekt-Sperrung. Aus Thread-Sicherheitsgründen wird eine Anwendung, die mit mehreren Threads läuft, einen gegebenen Bytecode nur einmal spezialisieren. Deshalb ist der Aufwand für Programme, die mehrere Threads verwenden, höher im Vergleich zu Programmen, die nur einen Thread verwenden. Allerdings sollten Programme, die mehrere Threads verwenden, mit deaktiviertem GIL auch mehrere CPU-Kerne effektiver nutzen können.

Beachten Sie, dass dieses PEP die Leistung von Standard-Builds (nicht --disable-gil) von CPython nicht beeinträchtigen würde.

Build Bots

Die stabilen Build-Bots werden auch --disable-gil-Builds enthalten.

Wie man das lehrt

Im Rahmen der Implementierung des --disable-gil-Modus wird der Autor eine „HOWTO“-Anleitung [18] verfassen, um Pakete mit Python ohne GIL kompatibel zu machen.

Referenzimplementierung

Es gibt zwei GitHub-Repositorys, die Versionen von CPython ohne GIL implementieren.

Das nogil-3.12 basiert auf Python 3.12.0a4. Es ist nützlich zur Bewertung des Single-Threaded-Ausführungsaufwands und als Referenzimplementierung für dieses PEP. Es ist weniger nützlich zur Bewertung der C-API-Erweiterungskompatibilität, da viele Erweiterungen derzeit nicht mit Python 3.12 kompatibel sind. Aufgrund begrenzter Zeit für den 3.12-Port überspringt die nogil-3.12-Implementierung nicht alle verzögerten Referenzzähler. Als temporäre Abhilfe unsterblich macht die Implementierung Objekte, die verzögerte Referenzzähler in Programmen verwenden, die mehrere Threads erzeugen.

Das nogil-Repository basiert auf Python 3.9.10. Es ist nützlich zur Bewertung der Multi-Thread-Skalierung in realen Anwendungen und der Erweiterungskompatibilität. Es ist stabiler und besser getestet als das nogil-3.12-Repository.

Alternativen

Python unterstützt derzeit eine Reihe von Möglichkeiten zur Ermöglichung von Parallelität, aber die bestehenden Techniken haben erhebliche Einschränkungen.

Multiprocessing

Die `multiprocessing`-Bibliothek erlaubt Python-Programmen, Python-Subprozesse zu starten und mit ihnen zu kommunizieren. Dies ermöglicht Parallelität, da jeder Subprozess seinen eigenen Python-Interpreter hat (d. h. einen GIL pro Prozess). Multiprocessing hat einige erhebliche Einschränkungen. Die Kommunikation zwischen Prozessen ist begrenzt: Objekte müssen im Allgemeinen serialisiert oder in den Shared Memory kopiert werden. Dies führt zu Overhead (aufgrund von Serialisierung) und erschwert den Aufbau von APIs auf Multiprocessing. Das Starten eines Subprozesses ist auch teurer als das Starten eines Threads, insbesondere mit der „spawn“-Implementierung. Das Starten eines Threads dauert ~100 µs, während das Erzeugen eines Subprozesses ~50 ms (50.000 µs) aufgrund der Python-Reinitialisierung dauert.

Schließlich unterstützen viele C- und C++-Bibliotheken den Zugriff von mehreren Threads, unterstützen aber keinen Zugriff oder Gebrauch über mehrere Prozesse hinweg.

Freigabe der GIL in C-API-Erweiterungen

C-API-Erweiterungen können den GIL um langlaufende Funktionen herum freigeben. Dies ermöglicht ein gewisses Maß an Parallelität, da mehrere Threads gleichzeitig laufen können, wenn der GIL freigegeben ist, aber der Overhead des Erwerbens und Freigebens des GIL verhindert normalerweise, dass dies über einige Threads hinaus effizient skaliert. Viele wissenschaftliche Computerbibliotheken geben den GIL in rechenintensiven Funktionen frei, und die CPython-Standardbibliothek gibt den GIL um blockierende E/A-Operationen herum frei.

Interne Parallelisierung

In C implementierte Funktionen können intern mehrere Threads verwenden. Zum Beispiel verwenden die NumPy-Distribution von Intel, PyTorch und TensorFlow diese Technik, um einzelne Operationen intern zu parallelisieren. Dies funktioniert gut, wenn die Grundoperationen groß genug sind, um effizient parallelisiert zu werden, aber nicht, wenn viele kleine Operationen vorhanden sind oder wenn die Operationen von Python-Code abhängen. Der Aufruf von Python aus C erfordert den Erwerb des GIL – selbst kurze Python-Code-Schnipsel können die Skalierung behindern.

Abgelehnte Ideen

Warum keinen nebenläufigen Garbage Collector verwenden?

Viele aktuelle Garbage Collectors sind größtenteils nebenläufig – sie vermeiden lange Stop-the-World-Pausen, indem sie zulassen, dass der Garbage Collector nebenläufig mit der Anwendung läuft. Warum also keinen nebenläufigen Collector verwenden?

Nebenläufige Sammlung erfordert Write Barriers (oder Read Barriers). Dem Autor ist kein Weg bekannt, Write Barriers zu CPython hinzuzufügen, ohne die C-API erheblich zu brechen.

Warum PyDict_GetItem nicht zugunsten von PyDict_FetchItem verwerfen?

Dieses PEP schlägt eine neue API PyDict_FetchItem vor, die sich wie PyDict_GetItem verhält, aber eine neue Referenz anstelle einer geliehenen Referenz zurückgibt. Wie in Geliehene Referenzen beschrieben, sind einige Verwendungen von geliehenen Referenzen, die beim Ausführen mit GIL sicher waren, beim Ausführen ohne GIL unsicher und müssen durch Funktionen wie PyDict_FetchItem ersetzt werden, die neue Referenzen zurückgeben.

Dieses PEP schlägt aus mehreren Gründen **nicht** die Deprecation von PyDict_GetItem und ähnlichen Funktionen vor, die geliehene Referenzen zurückgeben.

  • Viele Verwendungen von geliehenen Referenzen sind auch beim Ausführen ohne GIL sicher. Zum Beispiel verwenden C API-Funktionen oft PyDict_GetItem, um Elemente aus dem Schlüsselwort-Argument-Dictionary abzurufen. Diese Aufrufe sind sicher, da das Schlüsselwort-Argument-Dictionary nur für einen einzigen Thread sichtbar ist.
  • Ich habe diesen Ansatz frühzeitig ausprobiert und festgestellt, dass das vollständige Ersetzen von PyDict_GetItem durch PyDict_FetchItem häufig neue Referenzzählungsfehler eingeführt hat. Meiner Meinung nach überwiegt das Risiko, neue Referenzzählungsfehler einzuführen, im Allgemeinen die Risiken, einen PyDict_GetItem-Aufruf zu verpassen, der ohne GIL unsicher ist.

Warum nicht die Unsterblichmachung von PEP 683 verwenden?

Ähnlich wie PEP 683 schlägt dieses PEP ein Unsterblichkeits-Schema für Python-Objekte vor, aber die PEPs verwenden unterschiedliche Bitdarstellungen, um unsterbliche Objekte zu markieren. Die Schemata können nicht identisch sein, da dieses PEP auf verzerrte Referenzzählung angewiesen ist, die zwei Referenzzählerfelder anstelle von einem hat.

Offene Fragen

Verbesserte Spezialisierung

Die Python 3.11-Veröffentlichung führte Quickening und Spezialisierung als Teil des schnelleren CPython-Projekts ein und verbesserte die Leistung erheblich. Spezialisierung ersetzt langsame Bytecode-Anweisungen durch schnellere Varianten [19]. Um die Thread-Sicherheit zu gewährleisten, werden Anwendungen, die mehrere Threads verwenden (und ohne GIL laufen), jeden Bytecode nur einmal spezialisieren, was die Leistung einiger Programme verringern kann. Es ist möglich, die Spezialisierung mehrmals zu unterstützen, aber das erfordert weitere Untersuchungen und ist nicht Teil dieses PEP.

Python Build-Modi

Dieses PEP führt einen neuen Build-Modus (--disable-gil) ein, der nicht ABI-kompatibel mit dem Standard-Build-Modus ist. Der zusätzliche Build-Modus erhöht die Komplexität sowohl für Python-Core-Entwickler als auch für Erweiterungsentwickler. Der Autor glaubt, dass ein lohnendes Ziel darin besteht, diese Build-Modi zu kombinieren und die globale Interpreter-Sperre zur Laufzeit zu steuern, möglicherweise standardmäßig deaktiviert. Der Weg zu diesem Ziel bleibt eine offene Frage, aber ein möglicher Weg könnte wie folgt aussehen:

  1. Im Jahr 2024 wird CPython 3.13 mit Unterstützung für ein --disable-gil-Build-Flag veröffentlicht. Es gibt zwei ABIs für CPython, eine mit und eine ohne GIL. Erweiterungsautoren zielen auf beide ABIs ab.
  2. Nach 2–3 Veröffentlichungen (d. h. im Jahr 2026–2027) wird CPython mit dem GIL, der durch eine Laufzeitumgebungsvariable oder ein Flag gesteuert wird, veröffentlicht. Der GIL ist standardmäßig aktiviert. Es gibt nur eine einzige ABI.
  3. Nach weiteren 2–3 Veröffentlichungen (d. h. 2028–2030) wechselt CPython zur Standardeinstellung, dass der GIL deaktiviert ist. Der GIL kann weiterhin zur Laufzeit über eine Umgebungsvariable oder ein Kommandozeilenargument aktiviert werden.

Dieses PEP deckt den ersten Schritt ab, wobei die verbleibenden Schritte offene Fragen bleiben. In diesem Szenario gäbe es eine Periode von zwei bis drei Jahren, in der Erweiterungsautoren zusätzlich zu jedem unterstützten CPU-Architektur und Betriebssystem einen CPython-Build erstellen müssten.

Integration

Die Referenzimplementierung ändert etwa 15.000 Zeilen Code in CPython und enthält mimalloc, das ebenfalls etwa 15.000 Zeilen Code umfasst. Die meisten Änderungen sind nicht leistungsabhängig und können sowohl in --disable-gil- als auch in Standard-Builds aufgenommen werden. Einige Makros wie Py_BEGIN_CRITICAL_SECTION sind im Standard-Build No-Ops. Der Autor erwartet keine große Anzahl von #ifdef-Anweisungen zur Unterstützung der --disable-gil-Builds.

Minderungsmaßnahmen für Ein-Thread-Leistung

Die in PEP vorgeschlagenen Änderungen erhöhen den Ausführungsaufwand für --disable-gil-Builds im Vergleich zu Python-Builds mit GIL. Mit anderen Worten, sie haben eine langsamere Single-Threaded-Leistung. Es gibt einige mögliche Optimierungen zur Reduzierung des Ausführungsaufwands, insbesondere für --disable-gil-Builds, die nur einen einzigen Thread verwenden. Diese könnten sich lohnen, wenn ein längerfristiges Ziel darin besteht, einen einzigen Build-Modus zu haben, aber die Wahl der Optimierungen und ihre Kompromisse bleiben eine offene Frage.

Referenzen

Danksagungen

Vielen Dank an Hugh Leather, Łukasz Langa und Eric Snow für ihr Feedback zu Entwürfen dieses PEP.


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

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