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

Python Enhancement Proposals

PEP 550 – Ausführungskontext

Autor:
Yury Selivanov <yury at edgedb.com>, Elvis Pranskevichus <elvis at edgedb.com>
Status:
Zurückgezogen
Typ:
Standards Track
Erstellt:
11. Aug 2017
Python-Version:
3.7
Post-History:
11. Aug 2017, 15. Aug 2017, 18. Aug 2017, 25. Aug 2017, 01. Sep 2017

Inhaltsverzeichnis

Zusammenfassung

Dieser PEP fügt einen neuen generischen Mechanismus zur Gewährleistung eines konsistenten Zugriffs auf nicht-lokalen Zustand im Kontext von Out-of-Order-Ausführung hinzu, wie z. B. in Python-Generatoren und Coroutinen.

Thread-lokaler Speicher, wie threading.local(), ist für Programme, die gleichzeitig im selben OS-Thread ausgeführt werden, unzureichend. Dieser PEP schlägt eine Lösung für dieses Problem vor.

PEP Status

Aufgrund seiner Breite und des Fehlens eines allgemeinen Konsenses zu einigen Aspekten wurde dieser PEP zurückgezogen und durch einen einfacheren PEP 567 ersetzt, der akzeptiert und in Python 3.7 aufgenommen wurde.

PEP 567 implementiert die gleiche Kernidee, beschränkt die ContextVar-Unterstützung jedoch auf asynchrone Tasks, während das Generatorverhalten unverändert bleibt. Letzteres kann in einem zukünftigen PEP nochmals überarbeitet werden.

Begründung

Vor dem Aufkommen der asynchronen Programmierung in Python nutzten Programme OS-Threads, um Nebenläufigkeit zu erreichen. Der Bedarf an Thread-spezifischem Zustand wurde durch threading.local() und sein C-API-Äquivalent PyThreadState_GetDict() gelöst.

Einige Beispiele, wo Thread-lokaler Speicher (TLS) häufig verwendet wird:

  • Kontextmanager wie Dezimal-Kontexte, numpy.errstate und warnings.catch_warnings.
  • Anforderungsbezogene Daten, wie Sicherheitstoken und Anforderungsdaten in Webanwendungen, Sprachkontext für gettext usw.
  • Profiling, Tracing und Logging in großen Codebasen.

Leider funktioniert TLS nicht gut für Programme, die gleichzeitig in einem einzigen Thread ausgeführt werden. Ein Python-Generator ist das einfachste Beispiel für ein nebenläufiges Programm. Betrachten Sie Folgendes:

def fractions(precision, x, y):
    with decimal.localcontext() as ctx:
        ctx.prec = precision
        yield Decimal(x) / Decimal(y)
        yield Decimal(x) / Decimal(y ** 2)

g1 = fractions(precision=2, x=1, y=3)
g2 = fractions(precision=6, x=2, y=3)

items = list(zip(g1, g2))

Der intuitiv erwartete Wert von items ist:

[(Decimal('0.33'), Decimal('0.666667')),
 (Decimal('0.11'), Decimal('0.222222'))]

Überraschenderweise ist das tatsächliche Ergebnis:

[(Decimal('0.33'), Decimal('0.666667')),
 (Decimal('0.111111'), Decimal('0.222222'))]

Dies liegt daran, dass der implizite Dezimalkontext als Thread-lokal gespeichert ist. Eine gleichzeitige Iteration des fractions()-Generators würde den Zustand beschädigen. Für Dezimalzahlen ist die einzige aktuelle Umgehungslösung die Verwendung expliziter Kontextmethodenaufrufe für alle arithmetischen Operationen [28]. Dies untergräbt angeblich die Nützlichkeit überladener Operatoren und macht selbst einfache Formeln schwer zu lesen und zu schreiben.

Coroutinen sind eine weitere Klasse von Python-Code, bei der die Unzuverlässigkeit von TLS ein erhebliches Problem darstellt.

Die Unzulänglichkeit von TLS im asynchronen Code hat zur Verbreitung von Ad-hoc-Lösungen geführt, die auf ihren Geltungsbereich beschränkt sind und nicht alle erforderlichen Anwendungsfälle unterstützen.

Der aktuelle Status quo ist, dass jede Bibliothek (einschließlich der Standardbibliothek), die auf TLS angewiesen ist, wahrscheinlich kaputt geht, wenn sie in asynchronem Code oder mit Generatoren verwendet wird (siehe [3] als Beispielproblem).

Einige Sprachen, die Coroutinen oder Generatoren unterstützen, empfehlen die manuelle Übergabe des Kontexts als Argument an jede Funktion (siehe [1] für ein Beispiel). Dieser Ansatz ist jedoch für Python, wo es ein großes Ökosystem gibt, das für die Arbeit mit einem TLS-ähnlichen Kontext entwickelt wurde, nur begrenzt nützlich. Darüber hinaus sind Bibliotheken wie decimal oder numpy im Kontext von überladenen Operatorimplementierungen implizit auf den Kontext angewiesen.

Die .NET-Laufzeitumgebung, die Unterstützung für async/await bietet, hat eine generische Lösung für dieses Problem namens ExecutionContext (siehe [2]).

Ziele

Das Ziel dieses PEP ist es, eine zuverlässigere Alternative zu threading.local() bereitzustellen, die

  • den Mechanismus und die API zur Behebung von Problemen mit nicht-lokalem Zustand in Coroutinen und Generatoren bereitstellt;
  • TLS-ähnliche Semantik für synchronen Code implementiert, damit Benutzer wie decimal und numpy mit minimalem Risiko, die Abwärtskompatibilität zu brechen, auf den neuen Mechanismus umsteigen können;
  • keine oder nur vernachlässigbare Leistungsauswirkungen auf den bestehenden Code oder den Code hat, der den neuen Mechanismus verwendet, einschließlich C-Erweiterungen.

Spezifikation auf hoher Ebene

Die vollständige Spezifikation dieses PEP ist in drei Teile unterteilt:

  • Spezifikation auf hoher Ebene (dieser Abschnitt): die Beschreibung der Gesamtlösung. Wir zeigen, wie sie auf Generatoren und Coroutinen im Benutzercode angewendet wird, ohne auf Implementierungsdetails einzugehen.
  • Detaillierte Spezifikation: die vollständige Beschreibung neuer Konzepte, APIs und damit verbundener Änderungen an der Standardbibliothek.
  • Implementierungsdetails: die Beschreibung und Analyse von Datenstrukturen und Algorithmen zur Implementierung dieses PEP sowie die notwendigen Änderungen an CPython.

Zu diesem Zweck definieren wir Ausführungskontext als einen undurchsichtigen Container für nicht-lokalen Zustand, der einen konsistenten Zugriff auf seine Inhalte in einer nebenläufigen Ausführungsumgebung ermöglicht.

Eine Kontextvariable ist ein Objekt, das einen Wert im Ausführungskontext darstellt. Ein Aufruf von contextvars.ContextVar(name) erstellt ein neues Kontextvariablenobjekt. Ein Kontextvariablenobjekt hat drei Methoden:

  • get(): gibt den Wert der Variablen im aktuellen Ausführungskontext zurück;
  • set(value): setzt den Wert der Variablen im aktuellen Ausführungskontext;
  • delete(): kann zur Wiederherstellung des Variablenzustands verwendet werden; seine Funktion und Semantik werden unter Setting and restoring context variables erläutert.

Regulärer Single-Threaded Code

In regulärem, single-threaded Code, der keine Generatoren oder Coroutinen beinhaltet, verhalten sich Kontextvariablen wie globale Variablen.

var = contextvars.ContextVar('var')

def sub():
    assert var.get() == 'main'
    var.set('sub')

def main():
    var.set('main')
    sub()
    assert var.get() == 'sub'

Multithreaded Code

In multithreaded Code verhalten sich Kontextvariablen wie Thread-lokale Variablen.

var = contextvars.ContextVar('var')

def sub():
    assert var.get() is None  # The execution context is empty
                              # for each new thread.
    var.set('sub')

def main():
    var.set('main')

    thread = threading.Thread(target=sub)
    thread.start()
    thread.join()

    assert var.get() == 'main'

Generatoren

Im Gegensatz zu regulären Funktionsaufrufen können Generatoren ihre Kontrolle über die Ausführung kooperativ an den Aufrufer abgeben. Darüber hinaus steuert ein Generator nicht, wo die Ausführung nach der Übergabe fortgesetzt wird. Er kann von einer beliebigen Codestelle wieder aufgenommen werden.

Aus diesen Gründen ist das am wenigsten überraschende Verhalten von Generatoren wie folgt:

  • Änderungen an Kontextvariablen sind immer lokal und im äußeren Kontext nicht sichtbar, aber für den vom Generator aufgerufenen Code sichtbar;
  • Sobald eine Kontextvariable im Generator gesetzt ist, ist garantiert, dass sie sich zwischen den Iterationen nicht ändert;
  • Änderungen an Kontextvariablen im äußeren Kontext (wo der Generator iteriert wird) sind für den Generator sichtbar, es sei denn, diese Variablen wurden auch im Generator modifiziert.

Betrachten wir dies:

var1 = contextvars.ContextVar('var1')
var2 = contextvars.ContextVar('var2')

def gen():
    var1.set('gen')
    assert var1.get() == 'gen'
    assert var2.get() == 'main'
    yield 1

    # Modification to var1 in main() is shielded by
    # gen()'s local modification.
    assert var1.get() == 'gen'

    # But modifications to var2 are visible
    assert var2.get() == 'main modified'
    yield 2

def main():
    g = gen()

    var1.set('main')
    var2.set('main')
    next(g)

    # Modification of var1 in gen() is not visible.
    assert var1.get() == 'main'

    var1.set('main modified')
    var2.set('main modified')
    next(g)

Betrachten wir nun erneut das Beispiel für die Dezimalpräzision aus dem Abschnitt Rationale und sehen wir, wie der Ausführungskontext die Situation verbessern kann.

import decimal

# create a new context var
decimal_ctx = contextvars.ContextVar('decimal context')

# Pre-PEP 550 Decimal relies on TLS for its context.
# For illustration purposes, we monkey-patch the decimal
# context functions to use the execution context.
# A real working fix would need to properly update the
# C implementation as well.
def patched_setcontext(context):
    decimal_ctx.set(context)

def patched_getcontext():
    ctx = decimal_ctx.get()
    if ctx is None:
        ctx = decimal.Context()
        decimal_ctx.set(ctx)
    return ctx

decimal.setcontext = patched_setcontext
decimal.getcontext = patched_getcontext

def fractions(precision, x, y):
    with decimal.localcontext() as ctx:
        ctx.prec = precision
        yield MyDecimal(x) / MyDecimal(y)
        yield MyDecimal(x) / MyDecimal(y ** 2)

g1 = fractions(precision=2, x=1, y=3)
g2 = fractions(precision=6, x=2, y=3)

items = list(zip(g1, g2))

Der Wert von items ist:

[(Decimal('0.33'), Decimal('0.666667')),
 (Decimal('0.11'), Decimal('0.222222'))]

was dem erwarteten Ergebnis entspricht.

Coroutinen und asynchrone Tasks

Ähnlich wie Generatoren können Coroutinen Kontrolle abgeben und zurückgewinnen. Der Hauptunterschied zu Generatoren besteht darin, dass Coroutinen die Kontrolle nicht an den unmittelbaren Aufrufer abgeben. Stattdessen wechselt der gesamte Coroutinen-Aufrufstapel (durch await verkettete Coroutinen) zu einem anderen Coroutinen-Aufrufstapel. In dieser Hinsicht ist das await auf eine Coroutine konzeptionell ähnlich einem regulären Funktionsaufruf, und eine Coroutinen-Kette (oder ein "Task", z. B. ein asyncio.Task) ist konzeptionell ähnlich einem Thread.

Aus dieser Ähnlichkeit schließen wir, dass sich Kontextvariablen in Coroutinen wie "Task-lokale Variablen" verhalten sollten:

  • Änderungen an Kontextvariablen in einer Coroutine sind für die Coroutine, die auf sie wartet, sichtbar;
  • Änderungen an Kontextvariablen, die vor dem Warten im Aufrufer vorgenommen wurden, sind für die aufgerufene Coroutine sichtbar;
  • Änderungen an Kontextvariablen, die in einem Task vorgenommen wurden, sind in anderen Tasks nicht sichtbar;
  • Von anderen Tasks erstellte Tasks erben den Ausführungskontext vom Elterntask, aber jegliche Änderungen an Kontextvariablen, die nach der Erstellung des Kind-Tasks im Elterntask vorgenommen wurden, sind *nicht* sichtbar.

Der letzte Punkt zeigt ein Verhalten, das sich von OS-Threads unterscheidet. OS-Threads erben den Ausführungskontext nicht standardmäßig. Dafür gibt es zwei Gründe: die Absicht der allgemeinen Nutzung und die Abwärtskompatibilität.

Der Hauptgrund dafür, dass Tasks den Kontext erben und Threads nicht, ist die Absicht der allgemeinen Nutzung. Tasks werden oft für relativ kurzlebige Operationen verwendet, die logisch an den Code gebunden sind, der den Task gestartet hat (z. B. Ausführen einer Coroutine mit Timeout in asyncio). OS-Threads hingegen werden normalerweise für langlaufenden, logisch getrennten Code verwendet.

In Bezug auf die Abwärtskompatibilität möchten wir, dass sich der Ausführungskontext wie threading.local() verhält. Dies soll Bibliotheken ermöglichen, den Ausführungskontext anstelle von TLS mit geringerem Risiko, die Kompatibilität mit bestehendem Code zu brechen, zu verwenden.

Betrachten wir einige Beispiele, um die gerade definierten Semantiken zu veranschaulichen.

Weitergabe von Kontextvariablen innerhalb eines einzelnen Tasks

import asyncio

var = contextvars.ContextVar('var')

async def main():
    var.set('main')
    await sub()
    # The effect of sub() is visible.
    assert var.get() == 'sub'

async def sub():
    assert var.get() == 'main'
    var.set('sub')
    assert var.get() == 'sub'

loop = asyncio.get_event_loop()
loop.run_until_complete(main())

Weitergabe von Kontextvariablen zwischen Tasks

import asyncio

var = contextvars.ContextVar('var')

async def main():
    var.set('main')
    loop.create_task(sub())  # schedules asynchronous execution
                             # of sub().
    assert var.get() == 'main'
    var.set('main changed')

async def sub():
    # Sleeping will make sub() run after
    # "var" is modified in main().
    await asyncio.sleep(1)

    # The value of "var" is inherited from main(), but any
    # changes to "var" made in main() after the task
    # was created are *not* visible.
    assert var.get() == 'main'

    # This change is local to sub() and will not be visible
    # to other tasks, including main().
    var.set('sub')

loop = asyncio.get_event_loop()
loop.run_until_complete(main())

Wie oben gezeigt, sind Änderungen am Ausführungskontext lokal für den Task, und Tasks erhalten einen Schnappschuss des Ausführungskontexts zum Zeitpunkt der Erstellung.

Es gibt einen schmalen Randfall, der zu überraschendem Verhalten führen kann. Betrachten Sie das folgende Beispiel, bei dem wir die Kontextvariable in einer verschachtelten Coroutine ändern:

async def sub(var_value):
    await asyncio.sleep(1)
    var.set(var_value)

async def main():
    var.set('main')

    # waiting for sub() directly
    await sub('sub-1')

    # var change is visible
    assert var.get() == 'sub-1'

    # waiting for sub() with a timeout;
    await asyncio.wait_for(sub('sub-2'), timeout=2)

    # wait_for() creates an implicit task, which isolates
    # context changes, which means that the below assertion
    # will fail.
    assert var.get() == 'sub-2'  #  AssertionError!

Sich darauf zu verlassen, dass Kontextänderungen an den Aufrufer weitergegeben werden, ist letztendlich ein schlechtes Muster. Aus diesem Grund wird das Verhalten im obigen Beispiel nicht als schwerwiegendes Problem betrachtet und kann durch ordnungsgemäße Dokumentation behoben werden.

Detaillierte Spezifikation

Konzeptionell ist ein Ausführungskontext (EC) ein Stapel logischer Kontexte. Pro Python-Thread gibt es immer genau einen aktiven EC.

Ein logischer Kontext (LC) ist eine Zuordnung von Kontextvariablen zu ihren Werten in diesem spezifischen LC.

Eine Kontextvariable ist ein Objekt, das einen Wert im Ausführungskontext darstellt. Ein neues Kontextvariablenobjekt wird durch Aufruf von contextvars.ContextVar(name: str) erstellt. Der Wert des erforderlichen Arguments name wird von der EC-Maschinerie nicht verwendet, kann aber zur Fehlersuche und Introspektion genutzt werden.

Das Kontextvariablenobjekt hat die folgenden Methoden und Attribute:

  • name: der an ContextVar() übergebene Wert.
  • get(*, topmost=False, default=None): Wenn topmost auf False (Standard) gesetzt ist, durchläuft die Methode den Ausführungskontext von oben nach unten, bis der Variablenwert gefunden wird. Wenn topmost auf True gesetzt ist, gibt sie den Wert der Variablen im obersten logischen Kontext zurück. Wenn der Variablenwert nicht gefunden wurde, wird der Wert von default zurückgegeben.
  • set(value): setzt den Wert der Variablen im obersten logischen Kontext.
  • delete(): entfernt die Variable aus dem obersten logischen Kontext. Nützlich bei der Wiederherstellung des logischen Kontexts in den Zustand vor dem set()-Aufruf, z. B. in einem Kontextmanager. Weitere Informationen finden Sie unter Setting and restoring context variables.

Generatoren

Bei der Erstellung hat jedes Generatorobjekt ein leeres logisches Kontextobjekt, das in seinem __logical_context__-Attribut gespeichert ist. Dieser logische Kontext wird zu Beginn jeder Generatoriteration auf den Ausführungskontext gestapelt und am Ende wieder entfernt.

var1 = contextvars.ContextVar('var1')
var2 = contextvars.ContextVar('var2')

def gen():
    var1.set('var1-gen')
    var2.set('var2-gen')

    # EC = [
    #     outer_LC(),
    #     gen_LC({var1: 'var1-gen', var2: 'var2-gen'})
    # ]
    n = nested_gen()  # nested_gen_LC is created
    next(n)
    # EC = [
    #     outer_LC(),
    #     gen_LC({var1: 'var1-gen', var2: 'var2-gen'})
    # ]

    var1.set('var1-gen-mod')
    var2.set('var2-gen-mod')
    # EC = [
    #     outer_LC(),
    #     gen_LC({var1: 'var1-gen-mod', var2: 'var2-gen-mod'})
    # ]
    next(n)

def nested_gen():
    # EC = [
    #     outer_LC(),
    #     gen_LC({var1: 'var1-gen', var2: 'var2-gen'}),
    #     nested_gen_LC()
    # ]
    assert var1.get() == 'var1-gen'
    assert var2.get() == 'var2-gen'

    var1.set('var1-nested-gen')
    # EC = [
    #     outer_LC(),
    #     gen_LC({var1: 'var1-gen', var2: 'var2-gen'}),
    #     nested_gen_LC({var1: 'var1-nested-gen'})
    # ]
    yield

    # EC = [
    #     outer_LC(),
    #     gen_LC({var1: 'var1-gen-mod', var2: 'var2-gen-mod'}),
    #     nested_gen_LC({var1: 'var1-nested-gen'})
    # ]
    assert var1.get() == 'var1-nested-gen'
    assert var2.get() == 'var2-gen-mod'

    yield

# EC = [outer_LC()]

g = gen()  # gen_LC is created for the generator object `g`
list(g)

# EC = [outer_LC()]

Der obige Ausschnitt zeigt den Zustand des Ausführungskontextstapels während der Lebensdauer des Generators.

contextlib.contextmanager

Der Dekorator contextlib.contextmanager() kann verwendet werden, um einen Generator in einen Kontextmanager zu verwandeln. Ein Kontextmanager, der den Wert einer Kontextvariablen temporär modifiziert, könnte wie folgt definiert werden:

var = contextvars.ContextVar('var')

@contextlib.contextmanager
def var_context(value):
    original_value = var.get()

    try:
        var.set(value)
        yield
    finally:
        var.set(original_value)

Leider würde dies nicht sofort funktionieren, da die Modifikation der Variablen var auf den Generator var_context() beschränkt ist und daher im with-Block nicht sichtbar wäre.

def func():
    # EC = [{}, {}]

    with var_context(10):
        # EC becomes [{}, {}, {var: 10}] in the
        # *precision_context()* generator,
        # but here the EC is still [{}, {}]

        assert var.get() == 10  # AssertionError!

Die Lösung dafür besteht darin, das Attribut __logical_context__ des Generators auf None zu setzen. Dies bewirkt, dass der Generator die Modifikation des Ausführungskontextstapels vermeidet.

Wir modifizieren den Dekorator contextlib.contextmanager() so, dass genobj.__logical_context__ auf None gesetzt wird, um gut funktionierende Kontextmanager zu erzeugen.

def func():
    # EC = [{}, {}]

    with var_context(10):
        # EC = [{}, {var: 10}]
        assert var.get() == 10

    # EC becomes [{}, {var: None}]

Auflistung von Kontextvariablen

Die Methode ExecutionContext.vars() gibt eine Liste von ContextVar-Objekten zurück, die Werte im Ausführungskontext haben. Diese Methode ist hauptsächlich für Introspektion und Logging nützlich.

Coroutinen

In CPython teilen sich Coroutinen die Implementierung mit Generatoren. Der Unterschied besteht darin, dass in Coroutinen __logical_context__ standardmäßig auf None gesetzt ist. Dies betrifft sowohl die async def Coroutinen als auch die alten Generator-basierten Coroutinen (Generatoren, die mit @types.coroutine dekoriert sind).

Asynchronous Generators

Die Semantik des Ausführungskontexts in asynchronen Generatoren unterscheidet sich nicht von der regulärer Generatoren.

asyncio

asyncio verwendet Loop.call_soon, Loop.call_later und Loop.call_at, um die asynchrone Ausführung einer Funktion zu planen. asyncio.Task verwendet call_soon(), um die umschlossene Coroutine auszuführen.

Wir modifizieren Loop.call_{at,later,soon} so, dass sie das neue optionale Schlüsselwortargument execution_context akzeptieren, das standardmäßig auf eine Kopie des aktuellen Ausführungskontexts gesetzt wird.

def call_soon(self, callback, *args, execution_context=None):
    if execution_context is None:
        execution_context = contextvars.get_execution_context()

    # ... some time later

    contextvars.run_with_execution_context(
        execution_context, callback, args)

Die Funktion contextvars.get_execution_context() gibt eine flache Kopie des aktuellen Ausführungskontexts zurück. Mit flacher Kopie meinen wir hier einen neuen Ausführungskontext, bei dem

  • die Nachschläge in der Kopie die gleichen Ergebnisse wie im ursprünglichen Ausführungskontext liefern und
  • jegliche Änderungen im ursprünglichen Ausführungskontext die Kopie nicht beeinflussen und
  • jegliche Änderungen an der Kopie den ursprünglichen Ausführungskontext nicht beeinflussen.

Entweder von Folgendem erfüllt die Kopieranforderungen:

  • ein neuer Stapel mit flachen Kopien logischer Kontexte;
  • ein neuer Stapel mit einem gequetschten logischen Kontext.

Die Funktion contextvars.run_with_execution_context(ec, func, *args, **kwargs) führt func(*args, **kwargs) mit ec als Ausführungskontext aus. Die Funktion führt folgende Schritte durch:

  1. Setzt ec als aktuellen Ausführungskontextstapel im aktuellen Thread.
  2. Legt einen leeren logischen Kontext auf den Stapel.
  3. Führt func(*args, **kwargs) aus.
  4. Nimmt den logischen Kontext vom Stapel.
  5. Stellt den ursprünglichen Ausführungskontextstapel wieder her.
  6. Gibt das Ergebnis von func() zurück oder löst es aus.

Diese Schritte stellen sicher, dass ec nicht von func modifiziert werden kann, was run_with_execution_context() idempotent macht.

asyncio.Task wird wie folgt modifiziert:

class Task:
    def __init__(self, coro):
        ...
        # Get the current execution context snapshot.
        self._exec_context = contextvars.get_execution_context()

        # Create an empty Logical Context that will be
        # used by coroutines run in the task.
        coro.__logical_context__ = contextvars.LogicalContext()

        self._loop.call_soon(
            self._step,
            execution_context=self._exec_context)

    def _step(self, exc=None):
        ...
        self._loop.call_soon(
            self._step,
            execution_context=self._exec_context)
        ...

Generatoren, die in Iteratoren umgewandelt werden

Jeder Python-Generator kann als äquivalenter Iterator dargestellt werden. Compiler wie Cython verlassen sich auf dieses Axiom. In Bezug auf den Ausführungskontext sollte ein solcher Iterator sich genauso verhalten wie der Generator, den er repräsentiert.

Das bedeutet, dass es eine Python-API geben muss, um neue logische Kontexte zu erstellen und Code mit einem gegebenen logischen Kontext auszuführen.

Die Funktion contextvars.LogicalContext() erstellt einen neuen, leeren logischen Kontext.

Die Funktion contextvars.run_with_logical_context(lc, func, *args, **kwargs) kann verwendet werden, um Funktionen im angegebenen logischen Kontext auszuführen. Der lc kann als Ergebnis des Aufrufs modifiziert werden.

Die Funktion contextvars.run_with_logical_context() führt folgende Schritte aus:

  1. Legt lc auf den aktuellen Ausführungskontextstapel.
  2. Führt func(*args, **kwargs) aus.
  3. Nimmt lc vom Ausführungskontextstapel.
  4. Gibt das Ergebnis von func() zurück oder löst es aus.

Durch die Verwendung von LogicalContext() und run_with_logical_context() können wir das Generatorverhalten wie folgt nachbilden:

class Generator:

    def __init__(self):
        self.logical_context = contextvars.LogicalContext()

    def __iter__(self):
        return self

    def __next__(self):
        return contextvars.run_with_logical_context(
            self.logical_context, self._next_impl)

    def _next_impl(self):
        # Actual __next__ implementation.
        ...

Sehen wir uns an, wie dieses Muster auf ein Beispielgenerator angewendet werden kann.

# create a new context variable
var = contextvars.ContextVar('var')

def gen_series(n):
    var.set(10)

    for i in range(1, n):
        yield var.get() * i

# gen_series is equivalent to the following iterator:

class CompiledGenSeries:

    # This class is what the `gen_series()` generator can
    # be transformed to by a compiler like Cython.

    def __init__(self, n):
        # Create a new empty logical context,
        # like the generators do.
        self.logical_context = contextvars.LogicalContext()

        # Initialize the generator in its LC.
        # Otherwise `var.set(10)` in the `_init` method
        # would leak.
        contextvars.run_with_logical_context(
            self.logical_context, self._init, n)

    def _init(self, n):
        self.i = 1
        self.n = n
        var.set(10)

    def __iter__(self):
        return self

    def __next__(self):
        # Run the actual implementation of __next__ in our LC.
        return contextvars.run_with_logical_context(
            self.logical_context, self._next_impl)

    def _next_impl(self):
        if self.i == self.n:
            raise StopIteration

        result = var.get() * self.i
        self.i += 1
        return result

Für handgeschriebene Iteratoren ist ein solcher Ansatz zur Kontextverwaltung normalerweise nicht erforderlich, und es ist einfacher, Kontextvariablen direkt in __next__ zu setzen und wiederherzustellen.

class MyIterator:

    # ...

    def __next__(self):
        old_val = var.get()
        try:
            var.set(new_val)
            # ...
        finally:
            var.set(old_val)

Implementierung

Der Ausführungskontext wird als unveränderliche verknüpfte Liste von logischen Kontexten implementiert, wobei jeder logische Kontext eine unveränderliche Schwachschlüsselzuordnung ist. Ein Zeiger auf den aktuell aktiven Ausführungskontext wird im OS-Thread-Status gespeichert.

                  +-----------------+
                  |                 |     ec
                  |  PyThreadState  +-------------+
                  |                 |             |
                  +-----------------+             |
                                                  |
ec_node             ec_node             ec_node   v
+------+------+     +------+------+     +------+------+
| NULL |  lc  |<----| prev |  lc  |<----| prev |  lc  |
+------+--+---+     +------+--+---+     +------+--+---+
          |                   |                   |
LC        v         LC        v         LC        v
+-------------+     +-------------+     +-------------+
| var1: obj1  |     |    EMPTY    |     | var1: obj4  |
| var2: obj2  |     +-------------+     +-------------+
| var3: obj3  |
+-------------+

Die Wahl der unveränderlichen Liste von unveränderlichen Abbildungen als grundlegende Datenstruktur wird durch die Notwendigkeit motiviert, contextvars.get_execution_context() effizient zu implementieren, was häufig von asynchronen Tasks und Callbacks verwendet wird. Wenn der EC unveränderlich ist, kann get_execution_context() einfach die aktuelle Ausführungskontextkopie per Referenz kopieren.

def get_execution_context(self):
    return PyThreadState_Get().ec

Betrachten wir alle möglichen Szenarien zur Kontextmodifikation.

  • Die Methode ContextVariable.set() wird aufgerufen.
    def ContextVar_set(self, val):
        # See a more complete set() definition
        # in the `Context Variables` section.
    
        tstate = PyThreadState_Get()
        top_ec_node = tstate.ec
        top_lc = top_ec_node.lc
        new_top_lc = top_lc.set(self, val)
        tstate.ec = ec_node(
            prev=top_ec_node.prev,
            lc=new_top_lc)
    
  • Die Funktion contextvars.run_with_logical_context() wird aufgerufen, in diesem Fall wird das übergebene logische Kontextobjekt an den Ausführungskontext angehängt.
    def run_with_logical_context(lc, func, *args, **kwargs):
        tstate = PyThreadState_Get()
    
        old_top_ec_node = tstate.ec
        new_top_ec_node = ec_node(prev=old_top_ec_node, lc=lc)
    
        try:
            tstate.ec = new_top_ec_node
            return func(*args, **kwargs)
        finally:
            tstate.ec = old_top_ec_node
    
  • Die Funktion contextvars.run_with_execution_context() wird aufgerufen, in diesem Fall wird der aktuelle Ausführungskontext auf den übergebenen Ausführungskontext gesetzt, mit einem neuen leeren logischen Kontext, der daran angehängt ist.
    def run_with_execution_context(ec, func, *args, **kwargs):
        tstate = PyThreadState_Get()
    
        old_top_ec_node = tstate.ec
        new_lc = contextvars.LogicalContext()
        new_top_ec_node = ec_node(prev=ec, lc=new_lc)
    
        try:
            tstate.ec = new_top_ec_node
            return func(*args, **kwargs)
        finally:
            tstate.ec = old_top_ec_node
    
  • Entweder genobj.send(), genobj.throw() oder genobj.close() werden auf einem genobj-Generator aufgerufen, in diesem Fall wird der in genobj aufgezeichnete logische Kontext auf den Stapel gelegt.
    PyGen_New(PyGenObject *gen):
        if (gen.gi_code.co_flags &
                (CO_COROUTINE | CO_ITERABLE_COROUTINE)):
            # gen is an 'async def' coroutine, or a generator
            # decorated with @types.coroutine.
            gen.__logical_context__ = None
        else:
            # Non-coroutine generator
            gen.__logical_context__ = contextvars.LogicalContext()
    
    gen_send(PyGenObject *gen, ...):
        tstate = PyThreadState_Get()
    
        if gen.__logical_context__ is not None:
            old_top_ec_node = tstate.ec
            new_top_ec_node = ec_node(
                prev=old_top_ec_node,
                lc=gen.__logical_context__)
    
            try:
                tstate.ec = new_top_ec_node
                return _gen_send_impl(gen, ...)
            finally:
                gen.__logical_context__ = tstate.ec.lc
                tstate.ec = old_top_ec_node
        else:
            return _gen_send_impl(gen, ...)
    
  • Coroutinen und asynchrone Generatoren teilen sich die Implementierung mit Generatoren, und die obigen Änderungen gelten auch für sie.

In bestimmten Szenarien muss der EC möglicherweise gequetscht werden, um die Größe der Kette zu begrenzen. Betrachten Sie zum Beispiel den folgenden Eckfall:

async def repeat(coro, delay):
    await coro()
    await asyncio.sleep(delay)
    loop.create_task(repeat(coro, delay))

async def ping():
    print('ping')

loop = asyncio.get_event_loop()
loop.create_task(repeat(ping, 1))
loop.run_forever()

Im obigen Code wird die EC-Kette so lange wachsen, wie repeat() aufgerufen wird. Jeder neue Task ruft contextvars.run_with_execution_context() auf, was einen neuen logischen Kontext an die Kette anhängt. Um ein unbegrenztes Wachstum zu verhindern, prüft contextvars.get_execution_context(), ob die Kette länger als ein vordefinierter Maximalwert ist, und wenn ja, quetscht sie die Kette zu einem einzigen LC.

def get_execution_context():
    tstate = PyThreadState_Get()

    if tstate.ec_len > EC_LEN_MAX:
        squashed_lc = contextvars.LogicalContext()

        ec_node = tstate.ec
        while ec_node:
            # The LC.merge() method does not replace
            # existing keys.
            squashed_lc = squashed_lc.merge(ec_node.lc)
            ec_node = ec_node.prev

        return ec_node(prev=NULL, lc=squashed_lc)
    else:
        return tstate.ec

Logischer Kontext

Der logische Kontext ist eine unveränderliche Schwachschlüsselzuordnung, die die folgenden Eigenschaften in Bezug auf die Garbage Collection aufweist:

  • ContextVar-Objekte werden nur aus dem Anwendungscode stark referenziert, nicht aus irgendeiner EC-Maschinerie oder den Werten, auf die sie verweisen. Das bedeutet, dass es keine Referenzzyklen gibt, die ihre Lebensdauer unnötig verlängern oder ihre Sammlung durch den GC verhindern könnten.
  • Werte, die in den Ausführungskontext eingefügt werden, bleiben garantiert erhalten, solange ein ContextVar-Schlüssel im Thread darauf verweist.
  • Wenn eine ContextVar vom Garbage Collector gesammelt wird, werden alle ihre Werte aus allen Kontexten entfernt, was ihnen erlaubt, bei Bedarf vom GC gesammelt zu werden.
  • Wenn ein OS-Thread seine Ausführung beendet hat, wird sein Thread-Status zusammen mit seinem Ausführungskontext bereinigt, wodurch alle Werte bereinigt werden, die an alle Kontextvariablen im Thread gebunden sind.

Wie bereits erwähnt, benötigen wir contextvars.get_execution_context(), um unabhängig von der Größe des Ausführungskontexts konsistent schnell zu sein, daher ist der logische Kontext notwendigerweise eine unveränderliche Abbildung.

Die Wahl von dict für die zugrundeliegende Implementierung ist sub-optimal, da LC.set() ein dict.copy() veranlasst, was eine O(N)-Operation ist, wobei N die Anzahl der Elemente im LC ist.

get_execution_context() ist beim Quetschen des EC eine O(M)-Operation, wobei M die Gesamtzahl der Kontextvariablenwerte im EC ist.

Daher wählen wir anstelle von dict Hash Array Mapped Trie (HAMT) als zugrundeliegende Implementierung für logische Kontexte. (Scala und Clojure verwenden HAMT zur Implementierung von Hochleistungs-unveränderlichen Sammlungen [5], [6]).

Mit HAMT wird .set() zu einer O(log N)-Operation, und das Quetschen von get_execution_context() ist im Durchschnitt aufgrund der strukturellen Teilung in HAMT effizienter.

Siehe Appendix: HAMT Performance Analysis für eine detailliertere Analyse der HAMT-Leistung im Vergleich zu dict.

Context Variables

Die Methoden ContextVariable.get() und ContextVariable.set() werden wie folgt implementiert (in Pseudocode):

class ContextVar:

    def get(self, *, default=None, topmost=False):
        tstate = PyThreadState_Get()

        ec_node = tstate.ec
        while ec_node:
            if self in ec_node.lc:
                return ec_node.lc[self]
            if topmost:
                break
            ec_node = ec_node.prev

        return default

    def set(self, value):
        tstate = PyThreadState_Get()
        top_ec_node = tstate.ec

        if top_ec_node is not None:
            top_lc = top_ec_node.lc
            new_top_lc = top_lc.set(self, value)
            tstate.ec = ec_node(
                prev=top_ec_node.prev,
                lc=new_top_lc)
        else:
            # First ContextVar.set() in this OS thread.
            top_lc = contextvars.LogicalContext()
            new_top_lc = top_lc.set(self, value)
            tstate.ec = ec_node(
                prev=NULL,
                lc=new_top_lc)

    def delete(self):
        tstate = PyThreadState_Get()
        top_ec_node = tstate.ec

        if top_ec_node is None:
            raise LookupError

        top_lc = top_ec_node.lc
        if self not in top_lc:
            raise LookupError

        new_top_lc = top_lc.delete(self)

        tstate.ec = ec_node(
            prev=top_ec_node.prev,
            lc=new_top_lc)

Für effizienten Zugriff in leistungskritischen Codepfaden, wie z. B. in numpy und decimal, cachen wir Lookups in ContextVariable.get(), was dies zu einer O(1)-Operation macht, wenn der Cache getroffen wird. Der Cache-Schlüssel setzt sich aus Folgendem zusammen:

  • Die neue uint64_t PyThreadState->unique_id, eine global eindeutige Thread-Status-ID. Sie wird aus der neuen uint64_t PyInterpreterState->ts_counter berechnet, die inkrementiert wird, wenn ein neuer Thread-Status erstellt wird.
  • Die neue uint64_t PyThreadState->stack_version, ein Thread-spezifischer Zähler, der inkrementiert wird, wenn ein nicht-leerer logischer Kontext auf den Stapel gelegt oder von ihm entfernt wird.
  • Der Zähler uint64_t ContextVar->version, der inkrementiert wird, wenn sich der Wert der Kontextvariable in irgendeinem logischen Kontext in irgendeinem OS-Thread ändert.

Der Cache wird dann wie folgt implementiert:

class ContextVar:

    def set(self, value):
        ...  # implementation
        self.version += 1

    def get(self, *, default=None, topmost=False):
        if topmost:
            return self._get_uncached(
                default=default, topmost=topmost)

        tstate = PyThreadState_Get()
        if (self.last_tstate_id == tstate.unique_id and
                self.last_stack_ver == tstate.stack_version and
                self.last_version == self.version):
            return self.last_value

        value = self._get_uncached(default=default)

        self.last_value = value  # borrowed ref
        self.last_tstate_id = tstate.unique_id
        self.last_stack_version = tstate.stack_version
        self.last_version = self.version

        return value

Beachten Sie, dass last_value eine geliehene Referenz ist. Wir gehen davon aus, dass das Wertobjekt am Leben ist, wenn die Versionsprüfungen korrekt sind. Dies ermöglicht eine ordnungsgemäße Garbage Collection der Werte von Kontextvariablen.

Dieser generische Caching-Ansatz ähnelt dem, was die aktuelle C-Implementierung von decimal zur Zwischenspeicherung des aktuellen Dezimalkontexts tut, und weist ähnliche Leistungseigenschaften auf.

Leistungsüberlegungen

Tests der Referenzimplementierung basierend auf früheren Überarbeitungen dieses PEP haben eine Verlangsamung von 1-2 % bei Generator-Mikrobenchmarks und keine spürbaren Unterschiede bei Makrobenchmarks gezeigt.

Die Leistung von nicht-Generator- und nicht-Async-Code wird von diesem PEP nicht beeinträchtigt.

Zusammenfassung der neuen APIs

Python

Die folgenden neuen Python-APIs werden durch diesen PEP eingeführt:

  1. Die neue Klasse contextvars.ContextVar(name: str='...'), deren Instanzen die folgenden Eigenschaften haben:
    • das schreibgeschützte Attribut .name,
    • die Methode .get(), die den Wert der Variablen im aktuellen Ausführungskontext zurückgibt;
    • die Methode .set(), die den Wert der Variablen im aktuellen logischen Kontext setzt;
    • die Methode .delete(), die den Wert der Variablen aus dem aktuellen logischen Kontext entfernt.
  2. Die neue Klasse contextvars.ExecutionContext(), die einen Ausführungskontext darstellt.
  3. Die neue Klasse contextvars.LogicalContext(), die einen logischen Kontext darstellt.
  4. Die neue Funktion contextvars.get_execution_context(), die eine ExecutionContext-Instanz zurückgibt, die eine Kopie des aktuellen Ausführungskontexts darstellt.
  5. Die Funktion contextvars.run_with_execution_context(ec: ExecutionContext, func, *args, **kwargs), die func mit dem bereitgestellten Ausführungskontext ausführt.
  6. Die Funktion contextvars.run_with_logical_context(lc: LogicalContext, func, *args, **kwargs), die func mit dem angegebenen logischen Kontext über den aktuellen Ausführungskontext ausführt.

C API

  1. PyContextVar * PyContext_NewVar(char *desc): Erzeugt ein PyContextVar-Objekt.
  2. PyObject * PyContext_GetValue(PyContextVar *, int topmost): Gibt den Wert der Variablen im aktuellen Ausführungskontext zurück.
  3. int PyContext_SetValue(PyContextVar *, PyObject *): Setzt den Wert der Variablen im aktuellen logischen Kontext.
  4. int PyContext_DelValue(PyContextVar *): Löscht den Wert der Variablen aus dem aktuellen logischen Kontext.
  5. PyLogicalContext * PyLogicalContext_New(): Erzeugt einen neuen, leeren PyLogicalContext.
  6. PyExecutionContext * PyExecutionContext_New(): Erzeugt einen neuen, leeren PyExecutionContext.
  7. PyExecutionContext * PyExecutionContext_Get(): Gibt den aktuellen Ausführungskontext zurück.
  8. int PyContext_SetCurrent( PyExecutionContext *, PyLogicalContext *): Setzt das übergebene EC-Objekt als aktuellen Ausführungskontext für den aktiven Thread-Zustand und/oder setzt das übergebene LC-Objekt als aktuellen logischen Kontext.

Designüberlegungen

Sollte "yield from" Kontextänderungen lecken?

Nein. Es könnte argumentiert werden, dass yield from semantisch äquivalent zum Aufruf einer Funktion ist und Kontextänderungen "leaken" sollte. Es ist jedoch nicht möglich, gleichzeitig Folgendes zu erfüllen:

  • next(gen) leakt keine Kontextänderungen, die in gen vorgenommen wurden, und
  • yield from gen leakt Kontextänderungen, die in gen vorgenommen wurden.

Der Grund dafür ist, dass yield from mit einem teilweise iterierten Generator verwendet werden kann, der bereits lokale Kontextänderungen aufweist.

var = contextvars.ContextVar('var')

def gen():
    for i in range(10):
        var.set('gen')
        yield i

def outer_gen():
    var.set('outer_gen')
    g = gen()

    yield next(g)
    # Changes not visible during partial iteration,
    # the goal of this PEP:
    assert var.get() == 'outer_gen'

    yield from g
    assert var.get() == 'outer_gen'  # or 'gen'?

Ein weiteres Beispiel wäre die Refaktorierung eines expliziten for..in yield-Konstrukts in einen yield from-Ausdruck. Betrachten Sie den folgenden Code:

def outer_gen():
    var.set('outer_gen')

    for i in gen():
        yield i
    assert var.get() == 'outer_gen'

den wir refaktorieren möchten, um yield from zu verwenden

def outer_gen():
    var.set('outer_gen')

    yield from gen()
    assert var.get() == 'outer_gen'  # or 'gen'?

Die obigen Beispiele verdeutlichen, dass es unsicher ist, Generatorcode mit yield from zu refaktorieren, wenn dieser Kontextänderungen leaken kann.

Daher ist das einzig gut definierte und konsistente Verhalten, Kontextänderungen in Generatoren **immer** zu isolieren, unabhängig davon, wie sie iteriert werden.

Sollte PyThreadState_GetDict() den Ausführungskontext verwenden?

Nein. PyThreadState_GetDict basiert auf TLS (Thread-Local Storage), und eine Änderung seiner Semantik würde die Abwärtskompatibilität brechen.

PEP 521

PEP 521 schlägt eine alternative Lösung für das Problem vor, die das Context-Manager-Protokoll um zwei neue Methoden erweitert: __suspend__() und __resume__(). Ebenso wird das asynchrone Context-Manager-Protokoll um __asuspend__() und __aresume__() erweitert.

Dies ermöglicht die Implementierung von Context Managern, die nicht-lokale Zustände verwalten und die in Generatoren und Coroutinen korrekt funktionieren.

Betrachten Sie beispielsweise den folgenden Context Manager, der den Ausführungszustand verwendet:

class Context:

    def __init__(self):
        self.var = contextvars.ContextVar('var')

    def __enter__(self):
        self.old_x = self.var.get()
        self.var.set('something')

    def __exit__(self, *err):
        self.var.set(self.old_x)

Eine äquivalente Implementierung mit PEP 521

local = threading.local()

class Context:

    def __enter__(self):
        self.old_x = getattr(local, 'x', None)
        local.x = 'something'

    def __suspend__(self):
        local.x = self.old_x

    def __resume__(self):
        local.x = 'something'

    def __exit__(self, *err):
        local.x = self.old_x

Der Nachteil dieses Ansatzes ist die Einführung erheblicher neuer Komplexität in das Context-Manager-Protokoll und die Interpreter-Implementierung. Dieser Ansatz beeinträchtigt wahrscheinlich auch die Leistung von Generatoren und Coroutinen negativ.

Zusätzlich ist die Lösung in PEP 521 auf Context Manager beschränkt und bietet keinen Mechanismus zur Weitergabe von Zuständen in asynchronen Tasks und Callbacks.

Kann der Ausführungskontext ohne Modifikation von CPython implementiert werden?

Nein.

Es stimmt zwar, dass das Konzept von "Task-Locals" für Coroutinen in Bibliotheken implementiert werden kann (siehe zum Beispiel [29] und [30]). Generatoren hingegen werden direkt vom Python-Interpreter verwaltet, sodass ihr Kontext ebenfalls vom Interpreter verwaltet werden muss.

Darüber hinaus kann der Ausführungskontext überhaupt nicht in einem Drittanbieter-Modul implementiert werden, da die Standardbibliothek, einschließlich decimal, sich sonst nicht darauf verlassen könnte.

Sollten wir sys.displayhook und andere APIs aktualisieren, um EC zu verwenden?

APIs wie die Umleitung von stdout durch Überschreiben von sys.stdout oder die Angabe neuer Ausnahme-Anzeigeprogramme durch Überschreiben der Funktion sys.displayhook wirken **per Design** auf den gesamten Python-Prozess. Ihre Benutzer gehen davon aus, dass die Auswirkungen von Änderungen an ihnen über OS-Threads hinweg sichtbar sein werden. Daher können wir diese APIs nicht einfach dazu verwenden, den neuen Ausführungskontext zu nutzen.

Dennoch denken wir, dass es möglich ist, neue APIs zu entwerfen, die kontextsensitiv sind, aber das liegt außerhalb des Umfangs dieses PEP.

Greenlets

Greenlet ist eine alternative Implementierung der kooperativen Zeitplanung für Python. Obwohl das Greenlet-Paket nicht Teil von CPython ist, verlassen sich beliebte Frameworks wie gevent darauf, und es ist wichtig, dass Greenlet geändert werden kann, um Ausführungskontexte zu unterstützen.

Konzeptionell ist das Verhalten von Greenlets dem von Generatoren sehr ähnlich, was bedeutet, dass ähnliche Änderungen bei Ein- und Austritt von Greenlets vorgenommen werden können, um Unterstützung für Ausführungskontexte hinzuzufügen. Dieses PEP stellt die notwendigen C-APIs dafür bereit.

Kontextmanager als Schnittstelle für Modifikationen

Dieses PEP konzentriert sich auf die Low-Level-Mechanismen und die minimale API, die grundlegende Operationen mit Ausführungskontext ermöglicht.

Zur Bequemlichkeit der Entwickler könnte eine High-Level-Context-Manager-Schnittstelle zum Modul contextvars hinzugefügt werden. Zum Beispiel:

with contextvars.set_var(var, 'foo'):
    # ...

Setzen und Wiederherstellen von Kontextvariablen

Die Methode ContextVar.delete() entfernt die Kontextvariable aus dem obersten logischen Kontext.

Wenn die Variable im obersten logischen Kontext nicht gefunden wird, wird ein LookupError ausgelöst, ähnlich wie del var einen NameError auslöst, wenn var nicht im Geltungsbereich liegt.

Diese Methode ist nützlich, wenn die (seltene) Notwendigkeit besteht, den Zustand eines logischen Kontexts korrekt wiederherzustellen, z. B. wenn ein verschachtelter Generator den logischen Kontext *vorübergehend* ändern möchte.

var = contextvars.ContextVar('var')

def gen():
    with some_var_context_manager('gen'):
        # EC = [{var: 'main'}, {var: 'gen'}]
        assert var.get() == 'gen'
        yield

    # EC = [{var: 'main modified'}, {}]
    assert var.get() == 'main modified'
    yield

def main():
    var.set('main')
    g = gen()
    next(g)
    var.set('main modified')
    next(g)

Das obige Beispiel würde nur dann korrekt funktionieren, wenn es eine Möglichkeit gäbe, var in gen() aus dem logischen Kontext zu löschen. Das Setzen auf einen "vorherigen Wert" in __exit__() würde Änderungen maskieren, die zwischen den Iterationen in main() vorgenommen wurden.

Alternative Designs für die ContextVar API

Logischer Kontext mit gestapelten Werten

Nach dem in diesem PEP vorgestellten Design ist der logische Kontext eine einfache LC({ContextVar: value, ...})-Zuordnung. Eine alternative Darstellung ist die Speicherung eines Stacks von Werten für jede Kontextvariable: LC({ContextVar: [val1, val2, ...], ...}).

Die ContextVar-Methoden wären dann:

  • get(*, default=None) – durchläuft den Stack der logischen Kontexte und gibt den obersten Wert aus dem ersten nicht-leeren logischen Kontext zurück.
  • push(val) – pusht val auf den Stack der Werte im aktuellen logischen Kontext.
  • pop() – poppt den obersten Wert vom Stack der Werte im aktuellen logischen Kontext.

Im Vergleich zum Single-Value-Design mit den Methoden set() und delete() ermöglicht der Stack-basierte Ansatz eine einfachere Implementierung des Setzen/Wiederherstellen-Musters. Die mentale Belastung dieses Ansatzes wird jedoch als höher eingeschätzt, da zwei Stacks zu berücksichtigen wären: ein Stack von LCs und ein Stack von Werten in jedem LC.

(Diese Idee wurde von Nathaniel Smith vorgeschlagen.)

ContextVar "set/reset"

Noch ein weiterer Ansatz ist, ein spezielles Objekt von ContextVar.set() zurückzugeben, das die Änderung der Kontextvariable im aktuellen logischen Kontext repräsentiert.

var = contextvars.ContextVar('var')

def foo():
    mod = var.set('spam')

    # ... perform work

    mod.reset()  # Reset the value of var to the original value
                 # or remove it from the context.

Der entscheidende Fehler in diesem Ansatz ist, dass es möglich wird, Kontextvariablen-"Modifikationsobjekte" in Code zu übergeben, der in einem anderen Ausführungskontext läuft, was zu undefinierten Nebeneffekten führt.

Abwärtskompatibilität

Dieser Vorschlag bewahrt 100% Rückwärtskompatibilität.

Abgelehnte Ideen

Replikation der threading.local() Schnittstelle

Die Wahl der threading.local()-ähnlichen Schnittstelle für Kontextvariablen wurde aus folgenden Gründen erwogen und abgelehnt:

  • Eine Umfrage in der Standardbibliothek und bei Django hat gezeigt, dass die überwiegende Mehrheit der threading.local()-Verwendungen ein einziges Attribut beinhalten, was darauf hindeutet, dass der Namensraumansatz im Feld nicht so hilfreich ist.
  • Die Verwendung von __getattr__() anstelle von .get() für die Wertsuche bietet keine Möglichkeit, die Tiefe der Suche anzugeben (d. h. nur den obersten logischen Kontext durchsuchen).
  • Eine Single-Value- ContextVar ist in Bezug auf die Sichtbarkeit einfacher zu verstehen. Angenommen, ContextVar() ist ein Namensraum, und betrachten Sie Folgendes:
    ns = contextvars.ContextVar('ns')
    
    def gen():
        ns.a = 2
        yield
        assert ns.b == 'bar' # ??
    
    def main():
        ns.a = 1
        ns.b = 'foo'
        g = gen()
        next(g)
        # should not see the ns.a modification in gen()
        assert ns.a == 1
        # but should gen() see the ns.b modification made here?
        ns.b = 'bar'
        yield
    

    Das obige Beispiel zeigt, dass die Überlegung zur Sichtbarkeit verschiedener Attribute derselben Kontextvariable nicht trivial ist.

  • Eine Single-Value- ContextVar ermöglicht eine unkomplizierte Implementierung des Lookup-Caches.
  • Die Single-Value- ContextVar-Schnittstelle ermöglicht eine einfache C-API, die im Wesentlichen mit der Python-API identisch ist.

Siehe auch die Diskussion im Mailinglisten-Thread: [26], [27].

Coroutinen lecken standardmäßig keine Kontextänderungen

In V4 (Versionshistorie) dieses PEP wurde davon ausgegangen, dass Coroutinen in Bezug auf den Ausführungskontext genau wie Generatoren funktionieren: Änderungen in "awaited" Coroutinen waren in der äußeren Coroutine nicht sichtbar.

Diese Idee wurde abgelehnt, da sie die semantische Ähnlichkeit der Task- und Thread-Modelle bricht und es insbesondere unmöglich macht, asynchrone Context Manager, die Kontextvariablen ändern, zuverlässig zu implementieren, da __aenter__ eine Coroutine ist.

Anhang: HAMT Leistungsanalyse

../_images/pep-0550-hamt_vs_dict-v2.png

Abbildung 1. Der Benchmark-Code ist hier zu finden: [9].

Die obige Tabelle zeigt, dass:

  • HAMT zeigt für alle gemessenen Dictionary-Größen eine Leistung nahe O(1).
  • dict.copy() wird bei etwa 100 Elementen sehr langsam.
../_images/pep-0550-lookup_hamt.png

Abbildung 2. Der Benchmark-Code ist hier zu finden: [10].

Abbildung 2 vergleicht die Look-up-Kosten von dict mit einer HAMT-basierten unveränderlichen Zuordnung. Die HAMT-Lookup-Zeit ist im Durchschnitt 30-40% langsamer als Python-Dict-Lookups, was ein sehr gutes Ergebnis ist, wenn man bedenkt, dass letztere sehr gut optimiert sind.

Es gibt Forschungsergebnisse [8], die zeigen, dass es weitere mögliche Leistungsverbesserungen für HAMTs gibt.

Die Referenzimplementierung von HAMT für CPython finden Sie hier: [7].

Danksagungen

Vielen Dank an Victor Petrovykh für unzählige Diskussionen zu diesem Thema sowie für Korrekturlesen und Bearbeitung des PEP.

Vielen Dank an Nathaniel Smith für den Vorschlag des ContextVar-Designs [17] [18], dafür, dass er das PEP zu einem vollständigeren Design vorangetrieben hat, und für die Idee, einen Stack von Kontexten im Thread-Zustand zu haben.

Vielen Dank an Alyssa (Nick) Coghlan für zahlreiche Vorschläge und Ideen im Mailinglisten-Thread und für die Entwicklung eines Falls, der eine vollständige Neufassung der ursprünglichen PEP-Version erforderte [19].

Versionshistorie

  1. Ursprüngliche Revision, veröffentlicht am 11. August 2017 [20].
  2. V2 veröffentlicht am 15. August 2017 [21].

    Die grundlegende Einschränkung, die zu einer vollständigen Neugestaltung der ersten Version führte, war, dass es nicht möglich war, einen Iterator zu implementieren, der mit dem EC auf die gleiche Weise interagiert wie Generatoren (siehe [19]).

    Version 2 war eine komplette Neufassung, die neue Terminologie (Local Context, Execution Context, Context Item) und neue APIs einführte.

  3. V3 veröffentlicht am 18. August 2017 [22].

    Aktualisierungen

    • Local Context wurde in Logical Context umbenannt. Der Begriff "local" war mehrdeutig und kollidierte mit lokalen Namensräumen.
    • Context Item wurde in Context Key umbenannt, siehe den Thread mit Alyssa Coghlan, Stefan Krah und Yury Selivanov [23] für Details.
    • Das Design des Context Item Get Cache wurde gemäß Nathaniël Smiths Idee in [25] angepasst.
    • Coroutinen werden ohne Logical Context erstellt; die ceval-Schleife muss den await-Ausdruck nicht mehr speziell behandeln (vorgeschlagen von Alyssa Coghlan in [24]).
  4. V4 veröffentlicht am 25. August 2017 [31].
    • Der Spezifikationsabschnitt wurde komplett überarbeitet.
    • Coroutinen haben jetzt ihren eigenen Logical Context. Das bedeutet, dass es keinen Unterschied zwischen Coroutinen, Generatoren und asynchronen Generatoren in Bezug auf die Interaktion mit dem Execution Context gibt.
    • Context Key wurde in Context Var umbenannt.
    • Die Unterscheidung zwischen Generatoren und Coroutinen in Bezug auf die Isolation des logischen Kontexts wurde entfernt.
  5. V5 veröffentlicht am 01. September 2017: die aktuelle Version.

Referenzen


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

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