PEP 521 – Verwaltung des globalen Kontexts über ‘with’-Blöcke in Generatoren und Koroutinen
- Autor:
- Nathaniel J. Smith <njs at pobox.com>
- Status:
- Zurückgezogen
- Typ:
- Standards Track
- Erstellt:
- 27-Apr-2015
- Python-Version:
- 3.6
- Post-History:
- 29-Apr-2015
Rücknahme eines PEP
Zurückgezogen zugunsten von PEP 567.
Zusammenfassung
Während wir im Allgemeinen versuchen, globale Zustände nach Möglichkeit zu vermeiden, gibt es dennoch eine Reihe von Situationen, in denen dies als der beste Ansatz anerkannt wird. In Python ist ein Standardmuster für die Handhabung solcher Fälle, den globalen Zustand in globalem oder thread-lokalem Speicher zu speichern und dann with-Blöcke zu verwenden, um Modifikationen dieses globalen Zustands auf einen einzigen dynamischen Gültigkeitsbereich zu beschränken. Beispiele, bei denen dieses Muster verwendet wird, sind die Standardbibliothek warnings.catch_warnings und decimal.localcontext, numpy.errstate von NumPy (das die Fehlerbehandlungseinstellungen des IEEE 754 Gleitkomma-Standards verfügbar macht) und die Handhabung des Logging-Kontexts oder des HTTP-Request-Kontexts in vielen Serveranwendungs-Frameworks.
Es gibt jedoch derzeit keinen ergonomischen Weg, solche lokalen Änderungen am globalen Zustand zu verwalten, wenn man einen Generator oder eine Koroutine schreibt. Zum Beispiel dieser Code
def f():
with warnings.catch_warnings():
for x in g():
yield x
fängt Warnungen, die von g() ausgelöst werden, möglicherweise nicht erfolgreich ab und verschluckt möglicherweise versehentlich Warnungen, die an anderer Stelle im Code ausgelöst werden. Der Kontextmanager, der nur für f und seine Aufgerufenen gelten sollte, hat am Ende einen dynamischen Gültigkeitsbereich, der beliebige und unvorhersehbare Teile seiner Aufrufer umfasst. Dieses Problem wird besonders akut beim Schreiben von asynchronem Code, bei dem im Wesentlichen alle Funktionen zu Koroutinen werden.
Hier schlagen wir vor, dieses Problem zu lösen, indem wir Kontextmanager benachrichtigen, wenn die Ausführung innerhalb ihres Gültigkeitsbereichs unterbrochen oder fortgesetzt wird, und ihnen so ermöglichen, ihre Auswirkungen angemessen einzuschränken.
Spezifikation
Zwei neue, optionale Methoden werden dem Kontextmanager-Protokoll hinzugefügt: __suspend__ und __resume__. Wenn sie vorhanden sind, werden diese Methoden aufgerufen, wenn die Ausführung eines Frames innerhalb des with-Blocks unterbrochen oder fortgesetzt wird.
Formeller ausgedrückt, betrachten wir den folgenden Code
with EXPR as VAR:
PARTIAL-BLOCK-1
f((yield foo))
PARTIAL-BLOCK-2
Derzeit ist dies äquivalent zu folgendem Code (kopiert aus PEP 343)
mgr = (EXPR)
exit = type(mgr).__exit__ # Not calling it yet
value = type(mgr).__enter__(mgr)
exc = True
try:
try:
VAR = value # Only if "as VAR" is present
PARTIAL-BLOCK-1
f((yield foo))
PARTIAL-BLOCK-2
except:
exc = False
if not exit(mgr, *sys.exc_info()):
raise
finally:
if exc:
exit(mgr, None, None, None)
Dieser PEP schlägt vor, die with-Blockbehandlung stattdessen wie folgt zu ändern:
mgr = (EXPR)
exit = type(mgr).__exit__ # Not calling it yet
### --- NEW STUFF ---
if the_block_contains_yield_points: # known statically at compile time
suspend = getattr(type(mgr), "__suspend__", lambda: None)
resume = getattr(type(mgr), "__resume__", lambda: None)
### --- END OF NEW STUFF ---
value = type(mgr).__enter__(mgr)
exc = True
try:
try:
VAR = value # Only if "as VAR" is present
PARTIAL-BLOCK-1
### --- NEW STUFF ---
suspend(mgr)
tmp = yield foo
resume(mgr)
f(tmp)
### --- END OF NEW STUFF ---
PARTIAL-BLOCK-2
except:
exc = False
if not exit(mgr, *sys.exc_info()):
raise
finally:
if exc:
exit(mgr, None, None, None)
Analoge Suspend/Resume-Aufrufe sind ebenfalls um die yield-Punkte eingeschlossen, die in den yield from, await, async with und async for Konstrukten eingebettet sind.
Verschachtelte Blöcke
Bei diesem Code
def f():
with OUTER:
with INNER:
yield VALUE
dann führen wir die folgenden Operationen in der folgenden Reihenfolge durch
INNER.__suspend__()
OUTER.__suspend__()
yield VALUE
OUTER.__resume__()
INNER.__resume__()
Beachten Sie, dass dies sicherstellt, dass die folgende Umstrukturierung gültig ist
def f():
with OUTER:
yield from g()
def g():
with INNER
yield VALUE
Ähnlich werden with-Anweisungen mit mehreren Kontextmanagern von rechts nach links suspendiert und von links nach rechts fortgesetzt.
Weitere Änderungen
Geeignete __suspend__ und __resume__ Methoden werden zu warnings.catch_warnings und decimal.localcontext hinzugefügt.
Begründung
Im Abstrakten haben wir ein Beispiel für plausiblen, aber falschen Code gegeben
def f():
with warnings.catch_warnings():
for x in g():
yield x
Um dies im aktuellen Python korrekt zu machen, müssen wir stattdessen etwas wie folgt schreiben:
def f():
with warnings.catch_warnings():
it = iter(g())
while True:
with warnings.catch_warnings():
try:
x = next(it)
except StopIteration:
break
yield x
Andererseits wird der ursprüngliche Code, wenn dieser PEP akzeptiert wird, so wie er ist korrekt. Oder, wenn das nicht überzeugt, hier ist ein weiteres Beispiel für fehlerhaften Code; die Behebung erfordert noch größere Umwege, und diese werden dem Leser als Übung überlassen
async def test_foo_emits_warning():
with warnings.catch_warnings(record=True) as w:
await foo()
assert len(w) == 1
assert "xyzzy" in w[0].message
Und beachten Sie, dass das letzte Beispiel überhaupt nicht künstlich ist – so schreibt man genau einen Test, ob eine mit async/await verwendende Koroutine korrekt eine Warnung auslöst. Ähnliche Probleme treten bei praktisch jeder Verwendung von warnings.catch_warnings, decimal.localcontext oder numpy.errstate in Code mit async/await auf. Es gibt also eindeutig ein echtes Problem zu lösen, und die wachsende Bedeutung von asynchronem Code macht es immer dringender.
Alternative Ansätze
Die wichtigste vorgeschlagene Alternative ist die Erstellung einer Art „Task-lokalem Speicher“, analog zum „Thread-lokalen Speicher“ [1]. Im Wesentlichen besteht die Idee darin, dass die Ereignisschleife dafür sorgt, dass für jede geplante Aufgabe ein neuer „Task-Namespace“ zugewiesen wird, und eine API bereitstellt, um zu jeder Zeit den Namespace abzurufen, der der aktuell ausgeführten Aufgabe entspricht. Obwohl viele Details noch ausgearbeitet werden müssen [2], scheint die Grundidee machbar zu sein, und sie ist eine besonders natürliche Methode zur Handhabung der Art von globalem Kontext, die auf der obersten Ebene von asynchronen Anwendungs-Frameworks entsteht (z. B. die Einrichtung von Kontextobjekten in einem Web-Framework). Sie hat aber auch eine Reihe von Nachteilen
- Sie löst nur das Problem der Verwaltung von globalem Zustand für Koroutinen, die an eine asynchrone Ereignisschleife
yielden. Aber es gibt eigentlich nichts an diesem Problem, das spezifisch für asyncio ist – wie die obigen Beispiele zeigen, stoßen einfache Generatoren auf genau dasselbe Problem. - Sie schafft eine unnötige Kopplung zwischen Ereignisschleifen und Code, der globale Zustände verwalten muss. Offensichtlich muss ein asynchrones Web-Framework ohnehin mit einer Ereignisschleifen-API interagieren, so dass es in diesem Fall keine große Sache ist. Aber es ist seltsam, dass
warnings,decimaloder NumPy in eine asynchrone Bibliothek-API aufrufen müssen, um auf ihren internen Zustand zuzugreifen, wenn sie selbst keinen asynchronen Code beinhalten. Schlimmer noch, da mehrere Ereignisschleifen-APIs üblich sind, ist nicht klar, welche integriert werden soll. (Dies könnte durch eine Standard-API von CPython zur Erstellung und zum Wechsel von „Task-lokalen Domänen“ etwas gemildert werden, mit denen asyncio, Twisted, Tornado usw. dann arbeiten könnten.) - Es ist keineswegs klar, dass dies akzeptabel schnell gemacht werden kann. NumPy muss die Gleitkommafehler-Einstellungen bei jeder einzelnen arithmetischen Operation überprüfen. Das Überprüfen eines Datenteils im thread-lokalen Speicher ist absurd schnell, da moderne Plattformen massive Ressourcen investiert haben, um diesen Fall zu optimieren (z. B. ein CPU-Register für diesen Zweck zu widmen); das Aufrufen einer Methode für eine Ereignisschleife, um einen Handle für einen Namespace abzurufen, und dann eine Suche in diesem Namespace durchzuführen, ist wesentlich langsamer.
Wichtiger ist, dass diese zusätzlichen Kosten bei *jedem* Zugriff auf die globalen Daten anfallen, selbst für Programme, die überhaupt keine Ereignisschleife verwenden. Der Vorschlag dieses PEPs hingegen beeinflusst nur Code, der tatsächlich
with-Blöcke undyield-Anweisungen kombiniert, was bedeutet, dass die Benutzer, die die Kosten erfahren, dieselben Benutzer sind, die auch die Vorteile nutzen.
Auf der anderen Seite ermöglicht eine so enge Integration zwischen Task-Kontext und der Ereignisschleife potenziell andere Funktionen, die über den Umfang des aktuellen Vorschlags hinausgehen. Zum Beispiel könnte eine Ereignisschleife feststellen, welcher Task-Namespace aktiv war, als eine Aufgabe call_soon aufgerufen hat, und arrangieren, dass der Callback beim Ausführen Zugriff auf denselben Task-Namespace hat. Ob dies nützlich ist, oder sogar gut definiert im Fall von Thread-übergreifenden Aufrufen (was bedeutet es, Task-lokalen Speicher von zwei Threads gleichzeitig aufzurufen?), ist ein Rätsel, über das sich Implementierer von Ereignisschleifen Gedanken machen können – nichts in diesem Vorschlag schließt solche Erweiterungen aus. Es scheint jedoch, dass solche Funktionen hauptsächlich für Zustände nützlich wären, die bereits eine enge Integration mit der Ereignisschleife haben – während wir vielleicht möchten, dass eine Request-ID über call_soon erhalten bleibt, würden die meisten Leute nicht erwarten
with warnings.catch_warnings():
loop.call_soon(f)
dass f mit deaktivierten Warnungen ausgeführt wird, was das Ergebnis wäre, wenn call_soon den globalen Kontext im Allgemeinen beibehält. Es ist auch unklar, wie dies funktionieren würde, da der Warnungskontextmanager __exit__ vor f aufgerufen würde.
Daher vertritt dieser PEP die Position, dass __suspend__/__resume__ und „Task-lokaler Speicher“ zwei komplementäre Werkzeuge sind, die beide unter verschiedenen Umständen nützlich sind.
Abwärtskompatibilität
Da __suspend__ und __resume__ optional sind und standardmäßig keine Operationen ausführen, funktionieren alle bestehenden Kontextmanager weiterhin genau wie zuvor.
Geschwindigkeitsmäßig fügt dieser Vorschlag zusätzlichen Overhead beim Betreten eines with-Blocks hinzu (wo wir jetzt auf zusätzliche Methoden prüfen müssen; fehlgeschlagene Attributsuchen in CPython sind ziemlich langsam, da die Zuweisung eines AttributeError erforderlich ist) und zusätzlichen Overhead an Unterbrechungspunkten. Da die Position von with-Blöcken und Unterbrechungspunkten statisch bekannt ist, kann der Compiler diesen Overhead in allen Fällen, außer wenn tatsächlich ein yield innerhalb eines with vorhanden ist, einfach wegoptimieren. Da wir nur Attributprüfungen für __suspend__ und __resume__ einmal zu Beginn eines with-Blocks durchführen, kann der pro-yield-Overhead, wenn diese Attribute nicht definiert sind, auf einen einzigen C-Level if (frame->needs_suspend_resume_calls) { ... } reduziert werden. Daher erwarten wir, dass der Gesamtoverhead vernachlässigbar ist.
Interaktion mit PEP 492
PEP 492 fügte neue asynchrone Kontextmanager hinzu, die wie reguläre Kontextmanager sind, aber anstelle von regulären Methoden __enter__ und __exit__ haben sie Koroutinen-Methoden __aenter__ und __aexit__.
Nach diesem Muster könnte man erwarten, dass dieser Vorschlag __asuspend__ und __aresume__ Koroutinen-Methoden hinzufügt. Aber das ergibt wenig Sinn, da der ganze Punkt ist, dass __suspend__ aufgerufen werden sollte, bevor unser Ausführungsthread unterbrochen wird und anderer Code ausgeführt werden kann. Das Einzige, was wir durch die Erstellung von __asuspend__ als Koroutine erreichen, ist, dass __asuspend__ selbst yielden kann. Entweder müssen wir __asuspend__ rekursiv aus __asuspend__ aufrufen oder wir müssen aufgeben und zulassen, dass diese yields ohne Aufruf des Suspend-Callbacks erfolgen; in beiden Fällen wird der eigentliche Sinn verfehlt.
Nun, mit einer Ausnahme: ein möglicher Ansatz für Koroutinen-Code ist, yield zu verwenden, um mit dem Koroutinen-Runner zu kommunizieren, ohne ihre Ausführung tatsächlich zu unterbrechen (d.h. die Koroutine könnte wissen, dass der Koroutinen-Runner sie unmittelbar nach der Verarbeitung der yielded Nachricht fortsetzen wird). Ein Beispiel hierfür ist der curio.timeout_after asynchrone Kontextmanager, der eine spezielle set_timeout Nachricht an den Curio-Kernel übergibt, und dann setzt der Kernel die Koroutine, die die Nachricht gesendet hat, sofort (synchron) fort. Und aus Benutzersicht wirkt dieser Timeout-Wert genauso wie die Arten von globalen Variablen, die diesen PEP motiviert haben. Aber es gibt einen entscheidenden Unterschied: diese Art von asynchronem Kontextmanager ist per Definition eng mit dem Koroutinen-Runner integriert. Daher kann der Koroutinen-Runner die Verantwortung für die Verfolgung der Timeouts, die für bestimmte Koroutinen gelten, übernehmen, ohne die Notwendigkeit dieses PEPs (und so funktioniert curio.timeout_after tatsächlich).
Das lässt zwei sinnvolle Ansätze zur Handhabung asynchroner Kontextmanager übrig
- Füge einfache
__suspend__und__resume__Methoden hinzu. - Lasse asynchrone Kontextmanager vorerst unangetastet, bis wir mehr Erfahrung mit ihnen haben.
Beides erscheint plausibel, daher schlägt dieser PEP aus Faulheit / YAGNI vor, sich vorläufig an Option (2) zu halten.
Referenzen
Urheberrecht
Dieses Dokument wurde gemeinfrei erklärt.
Quelle: https://github.com/python/peps/blob/main/peps/pep-0521.rst
Zuletzt geändert: 2025-02-01 08:59:27 GMT