PEP 568 – Generator-sensitivity for Context Variables
- Autor:
- Nathaniel J. Smith <njs at pobox.com>
- Status:
- Verschoben
- Typ:
- Standards Track
- Erstellt:
- 04-Jan-2018
- Python-Version:
- 3.8
- Post-History:
Zusammenfassung
Kontextvariablen bieten einen generischen Mechanismus zur Verfolgung von dynamischen, kontextlokalen Zuständen, ähnlich dem Thread-lokalen Speicher, aber verallgemeinert, um mit anderen Arten von Thread-ähnlichen Kontexten, wie z. B. asyncio Tasks, zurechtzukommen. PEP 550 schlug einen Mechanismus für kontextlokalen Zustand vor, der auch sensibel für den Generator-Kontext war, aber dies war ziemlich kompliziert, so dass der BDFL bat, ihn zu vereinfachen. Das Ergebnis war PEP 567, der für die Aufnahme in 3.7 vorgesehen ist. Dieses PEP erweitert dann die Mechanismen von PEP 567, um die Generator-Kontextsensitivität hinzuzufügen.
Dieses PEP beginnt im Status "verschoben", da nicht genügend Zeit bleibt, es vor dem Feature-Freeze für 3.7 angemessen zu berücksichtigen. Das einzige Ziel *jetzt* ist es, zu verstehen, was erforderlich wäre, um die Generator-Kontextsensitivität in 3.8 hinzuzufügen, damit wir vermeiden können, in 3.7 etwas zu versenden, das dies versehentlich ausschließen würde. (Das absichtliche Ausschließen kann bis 3.8 warten ;-).)
Begründung
[Derzeit ist der Punkt dieses PEP lediglich, zu verstehen, *wie* dies funktionieren würde, wobei die Diskussion darüber, *ob* es eine gute Idee ist, auf nach dem Feature-Freeze für 3.7 verschoben wird. Die Begründung ist also noch zu klären.]
Zusammenfassung auf hoher Ebene
Anstatt ein einzelnes Context zu halten, enthält der Thread-Zustand jetzt eine ChainMap von Contexts. ContextVar.get und ContextVar.set werden von der ChainMap unterstützt. Generatoren und asynchrone Generatoren haben jeweils einen zugeordneten Context, den sie auf die ChainMap legen, während sie laufen, um ihre kontextlokalen Änderungen von ihren Aufrufern zu isolieren, obwohl dies in Fällen wie @contextlib.contextmanager überschrieben werden kann, wo das "Auslaufen" von Kontextänderungen vom Generator in seinen Aufrufer erwünscht ist.
Spezifikation
Überprüfung von PEP 567
Beginnen wir mit einer Überprüfung, wie PEP 567 funktioniert, und im nächsten Abschnitt werden wir die Unterschiede beschreiben.
In PEP 567 ist ein Context eine Mapping von ContextVar Objekten zu beliebigen Werten. In unserem Pseudocode werden wir so tun, als ob er ein dict für die Speicherung verwendet. (Die tatsächliche Implementierung verwendet ein HAMT, das semantisch einem dict entspricht, aber mit anderen Leistungsmerkmalen.)
class Context(collections.abc.Mapping):
def __init__(self):
self._data = {}
self._in_use = False
def __getitem__(self, key):
return self._data[key]
def __iter__(self):
return iter(self._data)
def __len__(self):
return len(self._data)
Zu jedem gegebenen Zeitpunkt enthält der Thread-Zustand einen aktuellen Context (initialisiert zu einem leeren Context, wenn der Thread-Zustand erstellt wird); wir können Context.run verwenden, um den aktuellen Context vorübergehend zu wechseln.
# Context.run
def run(self, fn, *args, **kwargs):
if self._in_use:
raise RuntimeError("Context already in use")
tstate = get_thread_state()
old_context = tstate.current_context
tstate.current_context = self
self._in_use = True
try:
return fn(*args, **kwargs)
finally:
state.current_context = old_context
self._in_use = False
Wir können eine flache Kopie des aktuellen Context erhalten, indem wir copy_context aufrufen; dies wird üblicherweise beim Starten einer neuen Aufgabe verwendet, damit die Kind-Aufgabe den Kontext von ihrem Elternteil erben kann.
def copy_context():
tstate = get_thread_state()
new_context = Context()
new_context._data = dict(tstate.current_context)
return new_context
In der Praxis arbeiten Endbenutzer im Allgemeinen mit ContextVar Objekten, die auch die einzige Möglichkeit bieten, einen Context zu ändern. Sie arbeiten mit einer Dienstklasse Token, die verwendet werden kann, um einen ContextVar auf seinen vorherigen Wert zurückzusetzen.
class Token:
MISSING = sentinel_value()
# Note: constructor is private
def __init__(self, context, var, old_value):
self._context = context
self.var = var
self.old_value = old_value
# XX: PEP 567 currently makes this a method on ContextVar, but
# I'm going to propose it switch to this API because it's simpler.
def reset(self):
# XX: should we allow token reuse?
# XX: should we allow tokens to be used if the saved
# context is no longer active?
if self.old_value is self.MISSING:
del self._context._data[self.context_var]
else:
self._context._data[self.context_var] = self.old_value
# XX: the handling of defaults here uses the simplified proposal from
# https://mail.python.org/pipermail/python-dev/2018-January/151596.html
# This can be updated to whatever we settle on, it was just less
# typing this way :-)
class ContextVar:
def __init__(self, name, *, default=None):
self.name = name
self.default = default
def get(self):
context = get_thread_state().current_context
return context.get(self, self.default)
def set(self, new_value):
context = get_thread_state().current_context
token = Token(context, self, context.get(self, Token.MISSING))
context._data[self] = new_value
return token
Änderungen von PEP 567 zu diesem PEP
Im Allgemeinen bleibt der Context gleich. Nun speichert der Thread-Zustand jedoch statt eines einzelnen Context Objekts einen Stapel davon. Dieser Stapel verhält sich wie eine collections.ChainMap, daher werden wir diese in unserem Pseudocode verwenden. Context.run wird dann
# Context.run
def run(self, fn, *args, **kwargs):
if self._in_use:
raise RuntimeError("Context already in use")
tstate = get_thread_state()
old_context_stack = tstate.current_context_stack
tstate.current_context_stack = ChainMap([self]) # changed
self._in_use = True
try:
return fn(*args, **kwargs)
finally:
state.current_context_stack = old_context_stack
self._in_use = False
Abgesehen von einigen aktualisierten Variablennamen (z. B. tstate.current_context → tstate.current_context_stack) ist die einzige Änderung hier in der markierten Zeile, die den Kontext nun in eine ChainMap einschließt, bevor er im Thread-Zustand abgelegt wird.
Wir fügen auch eine Context.push Methode hinzu, die fast genau wie Context.run ist, außer dass sie den Context vorübergehend auf den bestehenden Stapel legt, anstatt den gesamten Stapel vorübergehend zu ersetzen.
# Context.push
def push(self, fn, *args, **kwargs):
if self._in_use:
raise RuntimeError("Context already in use")
tstate = get_thread_state()
tstate.current_context_stack.maps.insert(0, self) # different from run
self._in_use = True
try:
return fn(*args, **kwargs)
finally:
tstate.current_context_stack.maps.pop(0) # different from run
self._in_use = False
In den meisten Fällen erwarten wir nicht, dass push direkt verwendet wird; stattdessen wird es implizit von Generatoren verwendet. Insbesondere erhält jedes Generator- und asynchrone Generatorobjekt ein neues Attribut .context. Wenn ein (asynchroner) Generator-Objekt erstellt wird, wird dieses Attribut mit einem leeren Context initialisiert (self.context = Context()). Dies ist ein veränderbares Attribut; es kann durch Benutzercode geändert werden. Der Versuch, es auf etwas anderes als ein Context Objekt oder None zu setzen, löst jedoch einen Fehler aus.
Immer wenn wir einen Generator über __next__, send, throw oder close betreten, oder einen asynchronen Generator betreten, indem wir eine dieser Methoden auf seinem __anext__, asend, athrow oder aclose Coroutinen aufrufen, wird sein .context Attribut geprüft und, wenn nicht None, automatisch gepusht.
# GeneratorType.__next__
def __next__(self):
if self.context is not None:
return self.context.push(self.__real_next__)
else:
return self.__real_next__()
Obwohl wir nicht erwarten, dass Leute Context.push oft verwenden, bewahrt die Tatsache, dass es eine öffentliche API ist, das Prinzip, dass ein Generator immer als explizite Iterator-Klasse mit äquivalenten Semantiken neu geschrieben werden kann.
Außerdem ändern wir contextlib.(async)contextmanager so, dass es das .context Attribut seines (asynchronen) Generatorobjekts immer auf None setzt.
# contextlib._GeneratorContextManagerBase.__init__
def __init__(self, func, args, kwds):
self.gen = func(*args, **kwds)
self.gen.context = None # added
...
Dies stellt sicher, dass Code wie dieser weiterhin wie erwartet funktioniert.
@contextmanager
def decimal_precision(prec):
with decimal.localcontext() as ctx:
ctx.prec = prec
yield
with decimal_precision(2):
...
Die allgemeine Idee hier ist, dass standardmäßig jeder Generator sein eigenes lokales Kontext erhält, aber wenn Benutzer explizit ein anderes Verhalten wünschen, können sie dies tun.
Ansonsten funktioniert es größtenteils wie zuvor, außer dass wir alles umstellen, um die ChainMap des Thread-Zustands anstelle des Context des Thread-Zustands zu verwenden. Im Detail
Die Funktion copy_context gibt jetzt eine abgeflachte Kopie des "effektiven" Kontexts zurück. (Als Optimierung kann die Implementierung wählen, diese Abflachung verzögert durchzuführen, aber wenn dies der Fall ist, wird dies für den Benutzer unsichtbar gemacht.) Im Vergleich zu unserer vorherigen Implementierung oben besteht die einzige Änderung darin, dass tstate.current_context durch tstate.current_context_stack ersetzt wurde.
def copy_context() -> Context:
tstate = get_thread_state()
new_context = Context()
new_context._data = dict(tstate.current_context_stack)
return new_context
Token ist unverändert, und die Änderungen an ContextVar.get sind trivial.
# ContextVar.get
def get(self):
context_stack = get_thread_state().current_context_stack
return context_stack.get(self, self.default)
ContextVar.set ist etwas interessanter: Anstatt wie alles andere durch die ChainMap Mechanik zu gehen, modifiziert es immer den obersten Context im Stapel und – entscheidend! – richtet das zurückgegebene Token ein, um seinen Zustand später wiederherzustellen. Dies ermöglicht es uns zu vermeiden, versehentlich Werte zwischen verschiedenen Ebenen des Stapels zu "befördern", wie es passieren würde, wenn wir old = var.get(); ...; var.set(old) machen würden.
# ContextVar.set
def set(self, new_value):
top_context = get_thread_state().current_context_stack.maps[0]
token = Token(top_context, self, top_context.get(self, Token.MISSING))
top_context._data[self] = new_value
return token
Und schließlich, um die Introspektion des gesamten Kontext-Stapels zu ermöglichen, stellen wir eine neue Funktion contextvars.get_context_stack zur Verfügung.
def get_context_stack() -> List[Context]:
return list(get_thread_state().current_context_stack.maps)
Das ist alles.
Vergleich mit PEP 550
Der Hauptunterschied zu PEP 550 besteht darin, dass es das, was wir "Kontexte" und "Kontext-Stapel" nennen, als zwei verschiedene konkrete Typen (jeweils LocalContext und ExecutionContext) reifiziert hat. Dies führte zu viel Verwirrung darüber, was die Unterschiede waren und welches Objekt wo verwendet werden sollte. Dieser Vorschlag vereinfacht die Dinge, indem nur der Context reifiziert wird, der "nur ein Dictionary" ist, und der "Kontext-Stapel" zu einem unbenannten Merkmal des Laufzeitzustands des Interpreters wird – obwohl es immer noch möglich ist, ihn mithilfe von get_context_stack zur Fehlersuche und für andere Zwecke zu introspektieren.
Implementierungshinweise
Context wird weiterhin eine HAMT-basierte Mapping-Struktur unter der Haube anstelle eines dict verwenden, da wir erwarten, dass Aufrufe an copy_context viel häufiger sind als ContextVar.set. In fast allen Fällen stellt copy_context fest, dass sich nur ein Context im Stapel befindet (da es selten vorkommt, dass Generatoren neue Aufgaben starten) und kann ihn einfach direkt wiederverwenden; in anderen Fällen sind HAMTs billig zu verschmelzen und dies kann verzögert geschehen.
Anstatt ein tatsächliches ChainMap Objekt zu verwenden, werden wir den Kontext-Stapel durch eine geeignete Struktur darstellen – die wahrscheinlich am besten geeigneten Optionen sind entweder eine reine list mit dem "oberen" Ende des Stapels am Ende der Liste, so dass wir push/pop verwenden können, oder eine intrusive verkettete Liste (PyThreadState → Context → Context → ...), wobei das "obere" Ende des Stapels am Anfang der Liste steht, um ein effizientes Push/Pop zu ermöglichen.
Eine kritische Optimierung in PEP 567 ist das Caching von Werten innerhalb von ContextVar. Der Wechsel von einem einzelnen Kontext zu einem Kontext-Stapel macht dies etwas komplizierter, aber nicht zu sehr. Derzeit invalidieren wir den Cache, wenn sich der aktuelle Context des Thread-Zustands ändert (beim Thread-Wechsel und beim Betreten/Verlassen von Context.run). Der einfachste Ansatz hier wäre, den Cache zu invalidieren, wenn sich der Stapel ändert (beim Thread-Wechsel, beim Betreten/Verlassen von Context.run und beim Betreten/Verlassen von Context.push). Die Hauptwirkung davon ist, dass die Iteration eines Generators den Cache invalidiert. Es scheint unwahrscheinlich, dass dies ernsthafte Probleme verursacht, aber wenn es das tut, dann denke ich, kann es mit einem clevereren Cache-Schlüssel vermieden werden, der erkennt, dass das Pushen und dann das Poppen eines Context den Thread-Zustand in seinen vorherigen Zustand zurückversetzt. (Idee: Speichern des Cache-Schlüssels für eine bestimmte Stapelkonfiguration im obersten Context.)
Es scheint in diesem Design unvermeidlich, dass ein nicht zwischengespeicherter get O(n) sein wird, wobei n die Größe des Kontext-Stapels ist. Allerdings wird n im Allgemeinen sehr klein sein – es ist ungefähr die Anzahl der verschachtelten Generatoren, also normalerweise n=1, und es wird extrem selten sein, n größer als, sagen wir, 5 zu sehen. Im schlimmsten Fall ist n durch das Rekursionslimit begrenzt. Zusätzlich können wir erwarten, dass in den meisten Fällen tiefer Generator-Rekursion die meisten Contexts im Stapel leer sind und daher während der Suche extrem schnell übersprungen werden können. Und für wiederholte Suchen wird der Caching-Mechanismus greifen. Es ist also wahrscheinlich möglich, einen extremen Fall zu konstruieren, bei dem dies Leistungsprobleme verursacht, aber gewöhnlicher Code sollte im Wesentlichen unbeeinflusst bleiben.
Urheberrecht
Dieses Dokument wurde gemeinfrei erklärt.
Quelle: https://github.com/python/peps/blob/main/peps/pep-0568.rst
Zuletzt geändert: 2025-02-01 08:55:40 GMT