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

Python Enhancement Proposals

PEP 555 – Kontextlokale Variablen (contextvars)

Autor:
Koos Zevenhoven
Status:
Zurückgezogen
Typ:
Standards Track
Erstellt:
06-Sep-2017
Python-Version:
3.7
Post-History:
06-Sep-2017

Inhaltsverzeichnis

Zusammenfassung

Manchmal, in speziellen Fällen, ist es erwünscht, dass Code Informationen über die Aufrufkette an die Aufgerufenen weitergeben kann, ohne die Informationen explizit als Argumente an jede Funktion in der Aufrufkette übergeben zu müssen. Dieser Vorschlag beschreibt ein Konstrukt, das es Code ermöglicht, explizit in einen Kontext ein- und auszutreten, in dem eine bestimmte Kontextvariable einen gegebenen Wert zugewiesen hat. Dies ist eine moderne Alternative zu einigen Verwendungen von Dingen wie globalen Variablen im traditionellen Single-Threaded (oder Thread-unsicheren) Code und von Thread-lokalem Speicher im traditionellen *Concurrency-unsicheren* Code (Single- oder Multi-Threaded). Insbesondere kann der vorgeschlagene Mechanismus auch mit moderneren nebenläufigen Ausführungsmechanismen wie asynchron ausgeführten Coroutinen verwendet werden, ohne dass die nebenläufig ausgeführten Aufrufkette die Kontexte des jeweils anderen beeinträchtigen.

Die „Aufrufkette“ kann aus normalen Funktionen, await-fähigen Coroutinen oder Generatoren bestehen. Die Semantik des Geltungsbereichs von Kontextvariablen ist in allen Fällen gleichwertig und ermöglicht es, Code frei in *Unterroutinen* (was hier Funktionen, Sub-Generatoren oder Sub-Coroutinen bezeichnet) zu refaktorieren, ohne die Semantik von Kontextvariablen zu beeinträchtigen. Hinsichtlich der Implementierung zielt dieser Vorschlag auf Einfachheit und minimale Änderungen am CPython-Interpreter und anderen Python-Interpretern ab.

Begründung

Betrachten Sie eine moderne Python-*Aufrufkette* (oder Aufrufbaum), die in diesem Vorschlag jede verkettete (verschachtelte) Ausführung von *Unterroutinen* bezeichnet, unter Verwendung beliebiger Kombinationen von normalen Funktionsaufrufen oder Ausdrücken, die await oder yield from verwenden. In einigen Fällen kann die Übergabe notwendiger *Informationen* über die Aufrufkette als Argumente die erforderlichen Funktionssignaturen erheblich verkomplizieren oder ist praktisch unmöglich zu erreichen. In diesen Fällen kann man nach einem anderen Ort suchen, um diese Informationen zu speichern. Betrachten wir einige historische Beispiele.

Die naheliegendste Option ist die Zuweisung des Werts zu einer globalen Variable oder ähnlichem, auf die der Code in der Aufrufkette zugreifen kann. Dies macht den Code jedoch sofort Thread-unsicher, da bei mehreren Threads alle Threads der gleichen globalen Variablen zugewiesen werden und ein anderer Thread jederzeit in der Aufrufkette eingreifen kann. Früher oder später wird wahrscheinlich jemand einen Grund finden, denselben Code in parallelen Threads auszuführen.

Eine etwas weniger naive Option ist es, die Informationen als pro-Thread-Informationen in Thread-lokalem Speicher zu speichern, wobei jeder Thread seine eigene „Kopie“ der Variablen hat, in die andere Threads nicht eingreifen können. Obwohl nicht ideal, war dies in vielen Fällen die beste Lösung. Dank Generatoren und Coroutinen kann die Ausführung der Aufrufkette unterbrochen und fortgesetzt werden, was es Code in anderen Kontexten ermöglicht, nebenläufig ausgeführt zu werden. Daher ist die Verwendung von Thread-lokalem Speicher *Concurrency-unsicher*, da andere Aufrufkette in anderen Kontexten die Thread-lokale Variable beeinträchtigen können.

Beachten Sie, dass in den oben genannten beiden historischen Ansätzen die gespeicherten Informationen den *breitesten* verfügbaren Geltungsbereich haben, ohne Probleme zu verursachen. Für eine dritte Lösung auf demselben Weg würde man zuerst ein Äquivalent eines „Threads“ für asynchrone Ausführung und Nebenläufigkeit definieren. Dies könnte als die größte Menge an Code und verschachtelten Aufrufen betrachtet werden, die garantiert sequenziell ohne Mehrdeutigkeit in der Ausführungsreihenfolge ausgeführt wird. Dies könnte als Concurrency-lokaler oder Task-lokaler Speicher bezeichnet werden. In dieser Bedeutung von „Task“ gibt es keine Mehrdeutigkeit in der Reihenfolge der Ausführung des Codes innerhalb eines Tasks. (Dieses Konzept eines Tasks ist nahe verwandt mit einem Task in asyncio, aber nicht exakt.) In solchen Concurrency-lokalen ist es möglich, Informationen über die Aufrufkette an die Aufgerufenen weiterzugeben, ohne dass ein anderer Code-Pfad den Wert im Hintergrund beeinträchtigt.

Gemeinsam für die obigen Ansätze ist, dass sie tatsächlich Variablen mit einem breiten, aber gerade engen genug Geltungsbereich verwenden. Thread-lokale Variablen könnten auch als Thread-weite Globale bezeichnet werden – im Single-Threaded-Code sind sie tatsächlich wirklich global. Und Task-lokale Variablen könnten als Task-weite Globale bezeichnet werden, da Tasks sehr groß sein können.

Das Problem hierbei ist, dass weder globale Variablen, Thread-lokale noch Task-lokale für diesen Zweck der Übergabe von Informationen des Ausführungskontexts über die Aufrufkette gedacht sind. Anstelle des breitestmöglichen Geltungsbereichs von Variablen sollte der Geltungsbereich der Variablen vom Programmierer, typischerweise einer Bibliothek, gesteuert werden, um den gewünschten Geltungsbereich zu haben – nicht breiter. Mit anderen Worten, Task-lokale Variablen (und Globale und Thread-lokale) haben nichts mit der Art der kontextgebundenen Informationsweitergabe zu tun, die dieser Vorschlag ermöglichen soll, auch wenn Task-lokale verwendet werden können, um die gewünschte Semantik zu emulieren. Daher beschreibt dieser Vorschlag im Folgenden die Semantik und die Umrisse einer Implementierung für *kontextlokale Variablen* (oder Kontextvariablen, contextvars). Tatsächlich kann ein asynchroner Framework als Nebeneffekt dieses PEPs die vorgeschlagene Funktion verwenden, um Task-lokale Variablen zu implementieren.

Vorschlag

Da die vorgeschlagene Semantik keine direkte Erweiterung von etwas bereits in Python Verfügbarem ist, wird dieser Vorschlag zunächst in Bezug auf Semantik und API auf einer ziemlich hohen Ebene beschrieben. Insbesondere werden Python with-Anweisungen stark in der Beschreibung verwendet, da sie gut zur vorgeschlagenen Semantik passen. Die zugrunde liegenden __enter__- und __exit__-Methoden entsprechen jedoch Funktionen in der schnelleren (C) API auf niedrigerer Ebene. Zur Klarheit dieses Dokuments werden die Funktionen auf niedrigerer Ebene in der Definition der Semantik nicht explizit benannt. Nach der Beschreibung der Semantik und der High-Level-API wird die Implementierung beschrieben, die auf eine niedrigere Ebene geht.

Semantik und übergeordnete API

Kernkonzept

Eine kontextlokale Variable wird durch eine einzelne Instanz von contextvars.Var dargestellt, z. B. cvar. Jeder Code, der Zugriff auf das cvar-Objekt hat, kann dessen Wert in Bezug auf den aktuellen Kontext abfragen. In der High-Level-API wird dieser Wert durch die Eigenschaft cvar.value gegeben

cvar = contextvars.Var(default="the default value",
                       description="example context variable")

assert cvar.value == "the default value"  # default still applies

# In code examples, all ``assert`` statements should
# succeed according to the proposed semantics.

Für diesen Kontext wurden keine Zuweisungen an cvar vorgenommen, daher liefert cvar.value den Standardwert. Das Zuweisen neuer Werte zu Kontextvariablen erfolgt auf eine hochgradig Scope-bewusste Weise

with cvar.assign(new_value):
    assert cvar.value is new_value
    # Any code here, or down the call chain from here, sees:
    #     cvar.value is new_value
    # unless another value has been assigned in a
    # nested context
    assert cvar.value is new_value
# the assignment of ``cvar`` to ``new_value`` is no longer visible
assert cvar.value == "the default value"

Hier gibt cvar.assign(value) ein anderes Objekt zurück, nämlich contextvars.Assignment(cvar, new_value). Der wesentliche Teil hierbei ist, dass das Anwenden einer Kontextvariablen-Zuweisung (Assignment.__enter__) mit einer De-Zuweisung (Assignment.__exit__) gekoppelt ist. Diese Operationen legen die Grenzen für den Geltungsbereich des zugewiesenen Werts fest.

Zuweisungen an dieselbe Kontextvariable können verschachtelt werden, um die äußere Zuweisung in einem engeren Kontext zu überschreiben

assert cvar.value == "the default value"
with cvar.assign("outer"):
    assert cvar.value == "outer"
    with cvar.assign("inner"):
        assert cvar.value == "inner"
    assert cvar.value == "outer"
assert cvar.value == "the default value"

Auch mehrere Variablen können verschachtelt zugewiesen werden, ohne sich gegenseitig zu beeinflussen

cvar1 = contextvars.Var()
cvar2 = contextvars.Var()

assert cvar1.value is None # default is None by default
assert cvar2.value is None

with cvar1.assign(value1):
    assert cvar1.value is value1
    assert cvar2.value is None
    with cvar2.assign(value2):
        assert cvar1.value is value1
        assert cvar2.value is value2
    assert cvar1.value is value1
    assert cvar2.value is None
assert cvar1.value is None
assert cvar2.value is None

Oder mit praktischerer Python-Syntax

with cvar1.assign(value1), cvar2.assign(value2):
    assert cvar1.value is value1
    assert cvar2.value is value2

In einem anderen *Kontext*, in einem anderen Thread oder einer anderweitig nebenläufig ausgeführten Aufgabe oder einem anderen Code-Pfad können die Kontextvariablen einen völlig anderen Zustand haben. Der Programmierer muss sich also nur um den aktuellen Kontext kümmern.

Refactoring in Unterroutinen

Code, der Kontextvariablen verwendet, kann in Unterroutinen refaktorisiert werden, ohne die Semantik zu beeinträchtigen. Zum Beispiel

assi = cvar.assign(new_value)
def apply():
    assi.__enter__()
assert cvar.value == "the default value"
apply()
assert cvar.value is new_value
assi.__exit__()
assert cvar.value == "the default value"

Oder ähnlich in einem asynchronen Kontext, in dem await-Ausdrücke verwendet werden. Die Unterroutine kann nun eine Coroutine sein

assi = cvar.assign(new_value)
async def apply():
    assi.__enter__()
assert cvar.value == "the default value"
await apply()
assert cvar.value is new_value
assi.__exit__()
assert cvar.value == "the default value"

Oder wenn die Unterroutine ein Generator ist

def apply():
    yield
    assi.__enter__()

was mit yield from apply() oder mit Aufrufen von next oder .send aufgerufen wird. Dies wird in späteren Abschnitten näher erläutert.

Semantik für Generatoren und Generator-basierte Coroutinen

Generatoren, Coroutinen und asynchrone Generatoren agieren als Unterroutinen auf ähnliche Weise wie normale Funktionen. Sie haben jedoch die zusätzliche Möglichkeit, durch yield-Ausdrücke unterbrochen zu werden. Zuweisungskontexte, die innerhalb eines Generators betreten werden, bleiben normalerweise über Yields hinweg erhalten

def genfunc():
    with cvar.assign(new_value):
        assert cvar.value is new_value
        yield
        assert cvar.value is new_value
g = genfunc()
next(g)
assert cvar.value == "the default value"
with cvar.assign(another_value):
    next(g)

Der äußere Kontext, der für den Generator sichtbar ist, kann sich jedoch über Yields hinweg ändern

def genfunc():
    assert cvar.value is value2
    yield
    assert cvar.value is value1
    yield
    with cvar.assign(value3):
        assert cvar.value is value3

with cvar.assign(value1):
    g = genfunc()
    with cvar.assign(value2):
        next(g)
    next(g)
    next(g)
    assert cvar.value is value1

Ähnliche Semantik gilt für asynchrone Generatoren, die durch async def ... yield ... definiert werden.

Standardmäßig lecken Werte, die innerhalb eines Generators zugewiesen werden, nicht über Yields in den Code, der den Generator antreibt. Zuweisungskontexte, die innerhalb des Generators betreten und offen gelassen werden, werden jedoch nach Abschluss des Generators mit einem StopIteration oder einer anderen Ausnahme außerhalb des Generators sichtbar.

assi = cvar.assign(new_value)
def genfunc():
    yield
    assi.__enter__():
    yield

g = genfunc()
assert cvar.value == "the default value"
next(g)
assert cvar.value == "the default value"
next(g)  # assi.__enter__() is called here
assert cvar.value == "the default value"
next(g)
assert cvar.value is new_value
assi.__exit__()

Spezielle Funktionalität für Framework-Autoren

Frameworks wie asyncio oder Drittanbieterbibliotheken können zusätzliche Funktionalität in contextvars verwenden, um die gewünschte Semantik in Fällen zu erzielen, die nicht vom Python-Interpreter bestimmt werden. Einige der in diesem Abschnitt beschriebenen Semantiken werden auch anschließend zur Beschreibung der internen Implementierung verwendet.

Leaking Yields

Mithilfe des Dekorators contextvars.leaking_yields kann man wählen, den Kontext über yield-Ausdrücke in den äußeren Kontext zu lecken, der den Generator antreibt

@contextvars.leaking_yields
def genfunc():
    assert cvar.value == "outer"
    with cvar.assign("inner"):
        yield
        assert cvar.value == "inner"
    assert cvar.value == "outer"

g = genfunc():
with cvar.assign("outer"):
    assert cvar.value == "outer"
    next(g)
    assert cvar.value == "inner"
    next(g)
    assert cvar.value == "outer"

Erfassung von contextvar-Zuweisungen

Mithilfe von contextvars.capture() kann man die Zuweisungskontexte erfassen, die von einem Codeblock betreten werden. Die vom Codeblock angewendeten Änderungen können dann rückgängig gemacht und anschließend erneut angewendet werden, selbst in einem anderen Kontext

assert cvar1.value is None # default
assert cvar2.value is None # default
assi1 = cvar1.assign(value1)
assi2 = cvar1.assign(value2)
with contextvars.capture() as delta:
    assi1.__enter__()
    with cvar2.assign("not captured"):
        assert cvar2.value is "not captured"
    assi2.__enter__()
assert cvar1.value is value2
delta.revert()
assert cvar1.value is None
assert cvar2.value is None
...
with cvar1.assign(1), cvar2.assign(2):
    delta.reapply()
    assert cvar1.value is value2
    assert cvar2.value == 2

Das erneute Anwenden des „Deltas“, wenn sein Nettoinhalt De-Zuweisungen enthält, ist möglicherweise nicht möglich (siehe auch Implementierung und offene Fragen).

Erhalten eines Schnappschusses des Kontextzustands

Die Funktion contextvars.get_local_state() gibt ein Objekt zurück, das die angewendeten Zuweisungen an alle kontextlokalen Variablen im Kontext darstellt, in dem die Funktion aufgerufen wird. Dies kann als äquivalent zur Verwendung von contextvars.capture() betrachtet werden, um alle Kontextänderungen seit Beginn der Ausführung zu erfassen. Das zurückgegebene Objekt unterstützt die Methoden .revert() und reapply() wie oben.

Ausführen von Code in einem sauberen Zustand

Obwohl es möglich ist, alle angewendeten Kontextänderungen mit den obigen Primitiven rückgängig zu machen, wird eine praktischere Methode zum Ausführen eines Codeblocks in einem sauberen Kontext bereitgestellt

with context_vars.clean_context():
    # here, all context vars start off with their default values
# here, the state is back to what it was before the with block.

Implementierung

Dieser Abschnitt beschreibt mit zunehmendem Detailgrad, wie die beschriebene Semantik implementiert werden kann. Derzeit wird eine Implementierung beschrieben, die auf Einfachheit, aber ausreichende Funktionalität abzielt. Weitere Details werden später hinzugefügt.

Alternativ bietet eine etwas kompliziertere Implementierung geringfügige zusätzliche Funktionen, während sie einen gewissen Performance-Overhead hinzufügt und mehr Code in der Implementierung erfordert.

Datenstrukturen und Implementierung des Kernkonzepts

Jeder Thread des Python-Interpreters speichert seinen eigenen Stapel von contextvars.Assignment-Objekten, die jeweils einen Zeiger auf die vorherige (äußere) Zuweisung wie in einer verketteten Liste haben. Der lokale Zustand (auch zurückgegeben von contextvars.get_local_state()) besteht dann aus einem Verweis auf die Spitze des Stapels und einem Zeiger/schwachen Verweis auf den Boden des Stapels. Dies ermöglicht effiziente Stapelmanipulationen. Ein Objekt, das von contextvars.capture() erzeugt wird, ist ähnlich, verweist jedoch nur auf einen Teil des Stapels, wobei der untere Verweis auf die Spitze des Stapels zeigt, wie sie zu Beginn des Capture-Blocks war.

Nun entwickelt sich der Stapel entsprechend den __enter__- und __exit__-Methoden der Zuweisung. Zum Beispiel

cvar1 = contextvars.Var()
cvar2 = contextvars.Var()
# stack: []
assert cvar1.value is None
assert cvar2.value is None

with cvar1.assign("outer"):
    # stack: [Assignment(cvar1, "outer")]
    assert cvar1.value == "outer"

    with cvar1.assign("inner"):
        # stack: [Assignment(cvar1, "outer"),
        #         Assignment(cvar1, "inner")]
        assert cvar1.value == "inner"

        with cvar2.assign("hello"):
            # stack: [Assignment(cvar1, "outer"),
            #         Assignment(cvar1, "inner"),
            #         Assignment(cvar2, "hello")]
            assert cvar2.value == "hello"

        # stack: [Assignment(cvar1, "outer"),
        #         Assignment(cvar1, "inner")]
        assert cvar1.value == "inner"
        assert cvar2.value is None

    # stack: [Assignment(cvar1, "outer")]
    assert cvar1.value == "outer"

# stack: []
assert cvar1.value is None
assert cvar2.value is None

Das Abrufen eines Werts aus dem Kontext mittels cvar1.value kann als Suchen der obersten Vorkommen einer cvar1-Zuweisung auf dem Stapel und Rückgabe des dortigen Werts oder des Standardwerts, falls keine Zuweisung auf dem Stapel gefunden wird, implementiert werden. Dies kann jedoch optimiert werden, um in den meisten Fällen eine O(1)-Operation zu sein. Dennoch kann selbst die Suche durch den Stapel relativ schnell sein, da diese Stapel nicht dazu bestimmt sind, sehr groß zu werden.

Die obige Beschreibung reicht bereits aus, um das Kernkonzept zu implementieren. Suspendierbare Frames erfordern zusätzliche Aufmerksamkeit, wie im Folgenden erläutert.

Implementierung der Generator- und Coroutinen-Semantik

Innerhalb von Generatoren, Coroutinen und asynchronen Generatoren werden Zuweisungen und De-Zuweisungen auf genau die gleiche Weise wie überall sonst behandelt. Es sind jedoch einige Änderungen an den eingebauten Generator-Methoden send, __next__, throw und close erforderlich. Hier ist das Python-Äquivalent der Änderungen, die für send für einen Generator erforderlich sind (hier bezieht sich _old_send auf das Verhalten in Python 3.6)

def send(self, value):
    if self.gi_contextvars is LEAK:
        # If decorated with contextvars.leaking_yields.
        # Nothing needs to be done to leak context through yields :)
        return self._old_send(value)
    try:
        with contextvars.capture() as delta:
            if self.gi_contextvars:
                # non-zero captured content from previous iteration
                self.gi_contextvars.reapply()
            ret = self._old_send(value)
    except Exception:
        raise  # back to the calling frame (e.g. StopIteration)
    else:
        # suspending, revert context changes but save them for later
        delta.revert()
        self.gi_contextvars = delta
    return ret

Die entsprechenden Modifikationen für die anderen Methoden sind im Wesentlichen identisch. Dasselbe gilt für Coroutinen und asynchrone Generatoren.

Für Code, der keine contextvars verwendet, sind die Ergänzungen O(1) und reduzieren sich im Wesentlichen auf ein paar Zeigervergleiche. Für Code, der contextvars verwendet, sind die Ergänzungen in den meisten Fällen immer noch O(1).

Mehr zur Implementierung

Der Rest der Funktionalität, einschließlich contextvars.leaking_yields, contextvars.capture(), contextvars.get_local_state() und contextvars.clean_context(), ist tatsächlich recht einfach zu implementieren, aber ihre Implementierung wird in späteren Versionen dieses Vorschlags weiter diskutiert. Das Caching von zugewiesenen Werten ist etwas komplizierter und wird später diskutiert, aber es scheint, dass die meisten Fälle eine O(1)-Komplexität erreichen sollten.

Abwärtskompatibilität

Es gibt keine *direkten* Abwärtskompatibilitätsprobleme, da eine völlig neue Funktion vorgeschlagen wird.

Allerdings müssen verschiedene traditionelle Verwendungen von Thread-lokalem Speicher einen reibungslosen Übergang zu contextvars erfahren, damit sie Concurrency-sicher sein können. Es gibt verschiedene Ansätze dafür, einschließlich der Emulation von Task-lokalem Speicher mit ein wenig Hilfe von asynchronen Frameworks. Eine vollständig allgemeine Implementierung kann nicht bereitgestellt werden, da die gewünschte Semantik vom Design des Frameworks abhängen kann.

Eine andere Möglichkeit, mit dem Übergang umzugehen, besteht darin, dass Code zuerst nach einem mit contextvars erstellten Kontext sucht. Wenn dies fehlschlägt, weil kein neuer Kontext gesetzt wurde oder weil der Code auf einer älteren Python-Version läuft, wird auf Thread-lokalen Speicher zurückgegriffen.

Offene Fragen

De-Zuweisungen außerhalb der Reihenfolge

In diesem Vorschlag werden alle Variablen-De-Zuweisungen in umgekehrter Reihenfolge im Vergleich zu den vorhergehenden Zuweisungen vorgenommen. Dies hat zwei nützliche Eigenschaften: Es ermutigt die Verwendung von with-Anweisungen zur Definition des Zuweisungsbereichs und hat die Tendenz, Fehler frühzeitig zu erkennen (das Vergessen eines .__exit__()-Aufrufs führt oft zu einem sinnvollen Fehler. Dies als Anforderung zu haben, ist auch im Hinblick auf Implementierungseinfachheit und Performance vorteilhaft. Dennoch ist das Zulassen von Out-of-Order-Context-Exits nicht gänzlich ausgeschlossen, und sinnvolle Implementierungsstrategien dafür existieren.

Abgelehnte Ideen

Dynamische Scoping, verbunden mit Unterroutinen-Scopes

Der Geltungsbereich der Wertesichtbarkeit sollte nicht durch die Art und Weise bestimmt werden, wie der Code in Unterroutinen refaktorisiert wird. Es ist notwendig, eine pro-Variable-Kontrolle über den Zuweisungsbereich zu haben.

Danksagungen

Wird noch hinzugefügt.

Referenzen

Wird noch hinzugefügt.


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

Zuletzt geändert: 2025-02-01 08:59:27 GMT