PEP 789 – Verhinderung von Fehlern bei der Aufgaben-Abbrechung durch Begrenzung von Yield in asynchronen Generatoren
- Autor:
- Zac Hatfield-Dodds <zac at zhd.dev>, Nathaniel J. Smith <njs at pobox.com>
- PEP-Delegate:
- Discussions-To:
- Discourse thread
- Status:
- Entwurf
- Typ:
- Standards Track
- Erstellt:
- 14. Mai 2024
- Python-Version:
- 3.14
Zusammenfassung
Strukturierte Nebenläufigkeit wird in Python immer beliebter. Schnittstellen wie die Kontextmanager asyncio.TaskGroup und asyncio.timeout unterstützen kompositionale Argumentation und ermöglichen es Entwicklern, die Lebensdauern von nebenläufigen Aufgaben klar abzugrenzen. Die Verwendung von yield zum Aussetzen eines Frames innerhalb eines solchen Kontexts führt jedoch zu Situationen, in denen die falsche Aufgabe abgebrochen wird, Timeouts ignoriert und Ausnahmen falsch behandelt werden. Grundlegender noch, das Aussetzen eines Frames innerhalb einer TaskGroup verletzt das strukturierte Nebenläufigkeitsdesignprinzip, dass Kindaufgaben innerhalb ihres Elternframes gekapselt sind.
Um diese Probleme zu lösen, schlägt dieses PEP einen neuen Kontextmanager sys.prevent_yields() vor. Wenn Sie sich syntaktisch innerhalb dieses Kontexts befinden, löst der Versuch, yield zu verwenden, einen RuntimeError aus und verhindert, dass die Aufgabe aussetzt. Zusätzlich wird ein Mechanismus bereitgestellt, damit Dekoratoren wie @contextmanager Yields innerhalb der dekorierten Funktion zulassen. sys.prevent_yields() wird von asyncio und nachgelagerten Bibliotheken verwendet, um Task-Gruppen, Timeouts und Abbrüche zu implementieren; und ein verwandter Mechanismus von contextlib usw., um Generatoren in Kontextmanager umzuwandeln, die sichere Yields zulassen.
Hintergrund
Strukturierte Nebenläufigkeit ist in Python immer beliebter, in Form von neueren asyncio-Schnittstellen und Drittanbieterbibliotheken wie Trio und anyio. Diese Schnittstellen unterstützen kompositionale Argumentation, *solange* Benutzer niemals ein yield schreiben, das einen Frame aussetzt, während es sich innerhalb eines Abbruchkontexts befindet.
Ein Abbruchkontext ist ein Kontextmanager, der... Arbeit abbrechen kann, die innerhalb dieses Kontexts stattfindet (...Kontext). In asyncio ist dies implizit im Design von with asyncio.timeout(): oder async with asyncio.TaskGroup() as tg:, die die enthaltene Arbeit nach der angegebenen Dauer abbrechen bzw. Geschwisteraufgaben abbrechen, wenn eine von ihnen eine Ausnahme auslöst. Die Kernfunktionalität eines Abbruchkontexts ist synchron, aber die benutzersichtbaren Kontextmanager können entweder synchron oder asynchron sein. [1] [2]
Dieser strukturierte Ansatz funktioniert hervorragend, es sei denn, man stößt auf eine bestimmte scharfe Kante: das Brechen der Verschachtelungsstruktur durch yielden innerhalb eines Abbruchkontexts. Dies hat sehr ähnliche Auswirkungen auf die strukturierte Ablaufsteuerung wie das Hinzufügen von nur wenigen funktionsübergreifenden gotos, und die Auswirkungen sind wirklich verheerend.
- Die falsche Aufgabe kann abgebrochen werden, sei es aufgrund eines Timeouts, eines Fehlers in einer Geschwisteraufgabe oder einer expliziten Anforderung, eine andere Aufgabe abzubrechen.
- Ausnahmen, einschließlich
CancelledError, können an die falsche Aufgabe geliefert werden. - Ausnahmen können vollständig verloren gehen und anstatt einer
ExceptionGrouphinzugefügt zu werden.
Problembeschreibung
Das grundlegende Problem hierbei ist: Yield setzt einen Aufruf-Frame aus. Es ist nur sinnvoll, in einem Leaf-Frame zu yielden – d.h., wenn Ihr Aufrufstapel A -> B -> C lautet, können Sie C aussetzen, aber Sie können B nicht aussetzen, während C weiterläuft.
Aber eine TaskGroup ist eine Art "nebenläufiger Aufruf"-Primitiv, bei dem ein einzelner Frame mehrere Kind-Frames haben kann, die nebenläufig laufen. Das bedeutet, wenn wir zulassen, dass Leute Yield und TaskGroup mischen, können wir in genau diese Situation geraten, in der B ausgesetzt wird, aber C aktiv läuft. Das ist unsinnig und verursacht ernsthafte praktische Probleme (z.B. wenn C eine Ausnahme auslöst und A zurückgekehrt ist, haben wir keine Möglichkeit, sie weiterzugeben).
Dies ist eine grundlegende Inkompatibilität zwischen der Generator-Ablaufsteuerung und der strukturierten Nebenläufigkeits-Ablaufsteuerung, nichts, was wir durch Anpassung unserer APIs beheben können. Die einzige Lösung scheint zu sein, Yield innerhalb einer TaskGroup zu verbieten.
Obwohl Timeouts keine Kindaufgabe laufen lassen, führen die nahe Analogie und die damit verbundenen Probleme dazu, dass wir schließen, dass Yield innerhalb aller Abbruchkontexte verboten werden sollte, nicht nur bei TaskGroups. Siehe Können wir Ausnahmen nicht einfach an die richtige Stelle liefern? für eine Diskussion.
Motivierende Beispiele
Betrachten wir drei Beispiele, um zu sehen, wie das in der Praxis aussehen könnte.
Weitergabe eines Timeouts an den äußeren Geltungsbereich
Angenommen, wir möchten über einen asynchronen Iterator iterieren, aber für jedes Element höchstens max_time Sekunden warten. Wir könnten die Logik dafür natürlich in einem asynchronen Generator kapseln, damit die Aufrufstelle weiterhin eine einfache async for-Schleife verwenden kann.
async def iter_with_timeout(ait, max_time):
try:
while True:
with timeout(max_time):
yield await anext(ait)
except StopAsyncIteration:
return
async def fn():
async for elem in iter_with_timeout(ait, max_time=1.0):
await do_something_with(elem)
Leider gibt es in dieser Version einen Fehler: Der Timeout könnte ablaufen, nachdem der Generator ge-yieldet hat, aber bevor er wieder aufgenommen wird! In diesem Fall sehen wir eine CancelledError, die in der äußeren Aufgabe ausgelöst wird, wo sie nicht vom with timeout(max_time):-Statement abgefangen werden kann.
Die Korrektur ist ziemlich einfach: Holen Sie sich das nächste Element innerhalb des Timeout-Kontexts und yielden Sie es dann *außerhalb* dieses Kontexts.
async def correct_iter_with_timeout(ait, max_time):
try:
while True:
with timeout(max_time):
tmp = await anext(ait)
yield tmp
except StopAsyncIteration:
return
Weitergabe von Hintergrundaufgaben (unterbricht Abbrechung und Fehlerbehandlung)
Timeouts sind nicht die einzige Schnittstelle, die einen Abbruchkontext umschließt – und wenn Sie Hintergrund-Worker-Aufgaben benötigen, können Sie die TaskGroup nicht einfach vor dem Yield schließen.
Als Beispiel betrachten wir einen Fan-In-Generator, den wir verwenden werden, um die Feeds von mehreren "Sensoren" zusammenzuführen. Wir werden unsere Mock-Sensoren auch mit einem kleinen Puffer einrichten, damit wir eine Fehlermeldung in der Hintergrundaufgabe auslösen, während die Ablaufsteuerung außerhalb des combined_iterators-Generators liegt.
import asyncio, itertools
async def mock_sensor(name):
for n in itertools.count():
await asyncio.sleep(0.1)
if n == 1 and name == "b": # 'presence detection'
yield "PRESENT"
elif n == 3 and name == "a": # inject a simple bug
print("oops, raising RuntimeError")
raise RuntimeError
else:
yield f"{name}-{n}" # non-presence sensor data
async def move_elements_to_queue(ait, queue):
async for obj in ait:
await queue.put(obj)
async def combined_iterators(*aits):
"""Combine async iterators by starting N tasks, each of
which move elements from one iterable to a shared queue."""
q = asyncio.Queue(maxsize=2)
async with asyncio.TaskGroup() as tg:
for ait in aits:
tg.create_task(move_elements_to_queue(ait, q))
while True:
yield await q.get()
async def turn_on_lights_when_someone_gets_home():
combined = combined_iterators(mock_sensor("a"), mock_sensor("b"))
async for event in combined:
print(event)
if event == "PRESENT":
break
print("main task sleeping for a bit")
await asyncio.sleep(1) # do some other operation
asyncio.run(turn_on_lights_when_someone_gets_home())
Wenn wir diesen Code ausführen, sehen wir die erwartete Abfolge von Beobachtungen, dann eine 'detection' und dann, während die Hauptaufgabe schläft, lösen wir diesen RuntimeError im Hintergrund aus. Aber... wir beobachten den RuntimeError nicht wirklich, nicht einmal als __context__ einer anderen Ausnahme!
>> python3.11 demo.py
a-0
b-0
a-1
PRESENT
main task sleeping for a bit
oops, raising RuntimeError
Traceback (most recent call last):
File "demo.py", line 39, in <module>
asyncio.run(turn_on_lights_when_someone_gets_home())
...
File "demo.py", line 37, in turn_on_lights_when_someone_gets_home
await asyncio.sleep(1) # do some other operation
File ".../python3.11/asyncio/tasks.py", line 649, in sleep
return await future
asyncio.exceptions.CancelledError
Auch hier ist das Problem, dass wir innerhalb eines Abbruchkontexts yieldet haben; diesmal der Kontext, den eine TaskGroup verwendet, um Geschwisteraufgaben abzubrechen, wenn eine der Kindaufgaben eine Ausnahme auslöst. Die CancelledError, die für die Geschwisteraufgabe bestimmt war, wurde stattdessen in die *äußere* Aufgabe injiziert, und so hatten wir keine Chance, eine ExceptionGroup(..., [RuntimeError()]) zu erstellen und auszulösen.
Um dies zu beheben, müssen wir unseren asynchronen Generator in einen asynchronen Kontextmanager umwandeln, der einen asynchronen Iterable liefert – in diesem Fall einen Generator, der die Warteschlange umschließt; in Zukunft vielleicht die Warteschlange selbst.
async def queue_as_aiterable(queue):
# async generators that don't `yield` inside a cancel scope are fine!
while True:
try:
yield await queue.get()
except asyncio.QueueShutDown:
return
@asynccontextmanager # yield-in-cancel-scope is OK in a context manager
async def combined_iterators(*aits):
q = asyncio.Queue(maxsize=2)
async with asyncio.TaskGroup() as tg:
for ait in aits:
tg.create_task(move_elements_to_queue(ait, q))
yield queue_as_aiterable(q)
async def turn_on_lights_when_someone_gets_home():
...
async with combined_iterators(...) as ait:
async for event in ait:
...
In einem benutzerdefinierten Kontextmanager
Das Yielden innerhalb eines Abbruchkontexts kann sicher sein, wenn und nur wenn Sie den Generator zur Implementierung eines Kontextmanagers verwenden [3] – in diesem Fall werden alle weitergegebenen Ausnahmen an die erwartete Aufgabe weitergeleitet.
Wir haben auch die Linter-Regel ASYNC101 in flake8-async implementiert, die vor dem Yielden in bekannten Abbruchkontexten warnt. Könnte Benutzeraufklärung ausreichen, um diese Probleme zu vermeiden? Leider nicht: benutzerdefinierte Kontextmanager können auch einen Abbruchkontext umschließen, und es ist nicht machbar, alle solchen Fälle zu erkennen oder zu linten.
Dies tritt regelmäßig in der Praxis auf, da "einige Hintergrundaufgaben für die Dauer dieses Kontexts ausführen" ein sehr gängiges Muster in der strukturierten Nebenläufigkeit ist. Wir haben dies oben in combined_iterators() gesehen; und diesen Fehler in mehreren Implementierungen des WebSocket-Protokolls gesehen.
async def get_messages(websocket_url):
# The websocket protocol requires background tasks to manage the socket heartbeat
async with open_websocket(websocket_url) as ws: # contains a TaskGroup!
while True:
yield await ws.get_message()
async with open_websocket(websocket_url) as ws:
async for message in get_messages(ws):
...
Spezifikation
Um diese Probleme zu vermeiden, schlagen wir vor:
- einen neuen Kontextmanager,
with sys.prevent_yields(reason): ..., der einen RuntimeError auslöst, wenn Sie versuchen, darin zu yielden. [4] Abbruchkontext-ähnliche Kontextmanager in asyncio und Downstream-Code können dies dann wrappen, um Yields innerhalb *ihres* With-Blocks zu verhindern. - einen Mechanismus, mit dem Generator-zu-Kontextmanager-Dekoratoren Yields über einen Aufruf wieder ermöglichen können. Wir sind uns noch nicht sicher, wie das aussehen soll; die führenden Kandidaten sind
- ein Code-Objekt-Attribut,
fn.__code__.co_allow_yields = True, oder - eine Art Aufruf-Flag, z.B.
fn.__invoke_with_yields__, um ein Code-Objekt, das zwischen dekorierten und undekorierten Funktionen geteilt werden könnte, nicht zu verändern.
- ein Code-Objekt-Attribut,
Implementierung – Verfolgung von Frames
Der neue Kontextmanager sys.prevent_yields erfordert Unterstützung des Interpreters. Für jeden Frame verfolgen wir die Ein- und Ausgänge dieses Kontextmanagers.
Wir sind nicht besonders an die genaue Darstellung gebunden; wir werden sie als Stapel (was klare Fehlermeldungen unterstützen würde) besprechen, aber kompaktere Darstellungen wie Integer-Paare würden ebenfalls funktionieren.
- Beim Betreten eines neu erstellten oder wieder aufgenommenen Frames, initialisieren Sie leere Stapel von Einträgen und Ausgängen.
- Beim Zurückkehren aus einem Frame, verschmelzen Sie diese Stapel in den Stapel des Elternframes.
- Beim Yielden
- wenn
entries != [] und nicht frame.allow_yield_flag, lösen Sie eineRuntimeErroraus, anstatt zu yielden (das neue Verhalten, das dieses PEP vorschlägt) - andernfalls verschmelzen Sie Stapel in den Elternframe wie bei einer Rückgabe.
- wenn
Da es sich hier um das Yielden von Frames *innerhalb* einer Aufgabe handelt, nicht um das Umschalten zwischen Aufgaben, sollten syntaktisches yield und yield from betroffen sein, aber await-Ausdrücke sollten nicht.
Wir können den Overhead reduzieren, indem wir diese Metadaten in einem einzigen Stapel pro Thread für alle Stack-Frames speichern, die keine Generatoren sind.
Bearbeitete Beispiele
Kein-Yield-Beispiel
In diesem Beispiel sehen wir mehrere Runden des Stapel-Zusammenführens, während wir von sys.prevent_yields über den benutzerdefinierten ContextManager zurück zum ursprünglichen Frame entrollen. Der Grund für die Verhinderung von Yields wird der Kürze halber nicht gezeigt; er ist Teil des "1 enter" -Zustands.
Ohne yield lösen wir keine Fehler aus, und da die Anzahl der Ein- und Ausgänge die Frame-Rückgaben wie üblich ausgleicht, ohne weitere Nachverfolgung.
Versuche-zu-Yield-Beispiel
In diesem Beispiel versucht der Frame, innerhalb des sys.prevent_yields-Kontexts zu yielden. Dies wird vom Interpreter erkannt, der eine RuntimeError auslöst, anstatt den Frame auszusetzen.
Erlaubt-zu-Yield-Beispiel
In diesem Beispiel hat ein Dekorator den Frame als Yield-erlaubt markiert. Dies könnte @contextlib.contextmanager oder ein ähnlicher Dekorator sein.
Wenn der Frame Yields erlaubt sind, wird der Ein-/Ausgangsstapel vor dem Aussetzen in den Stapel des Elternframes gemerged. Wenn der Frame wieder aufgenommen wird, ist sein Stapel leer. Schließlich, wenn der Frame beendet wird, wird der Ausgang in den Stapel des Elternframes gemerged und dieser neu ausbalanciert.
Dies stellt sicher, dass der Elternframe korrekt jeglichen verbleibenden sys.prevent_yields-Zustand erbt, während der Frame sicher ausgesetzt und wieder aufgenommen werden kann.
Yield für Kontextmanager erlauben
TODO: Dieser Abschnitt ist ein Platzhalter, abhängig von einer Entscheidung über den Mechanismus, damit ``@contextmanager`` Yields in der umhüllten Funktion wieder aktivieren kann.
- Erklären und zeigen Sie ein Codebeispiel, wie
@asynccontextmanagerdas Flag setzt
Beachten Sie, dass Drittanbieter-Dekoratoren wie @pytest.fixture zeigen, dass wir den Interpreter nicht einfach auf contextlib spezialisieren können.
Verhalten, wenn sys.prevent_yields missbraucht wird
Obwohl unklug, ist es möglich, sys.prevent_yields.__enter__ und .__exit__ in einer Reihenfolge aufzurufen, die keiner gültigen Verschachtelung entspricht, oder auf andere Weise einen ungültigen Frame-Zustand zu erhalten.
Es gibt zwei Möglichkeiten, wie sys.prevent_yields.__exit__ einen ungültigen Zustand erkennen kann. Erstens, wenn Yields nicht verhindert werden, können wir einfach eine Ausnahme auslösen, ohne den Zustand zu ändern. Zweitens, wenn ein unerwarteter Eintrag am oberen Rand des Stapels liegt, schlagen wir vor, diesen Eintrag zu entfernen und eine Ausnahme auszulösen – dies stellt sicher, dass fehlerhafte Aufrufe den Stapel immer noch leeren, während sie gleichzeitig deutlich machen, dass etwas nicht stimmt.
(und wenn wir z.B. eine Integer- statt einer Stapel-basierten Darstellung wählen, sind solche Zustände möglicherweise gar nicht von korrekter Verschachtelung unterscheidbar, in welchem Fall die Frage nicht aufkommt)
Antizipierte Verwendungen
In der Standardbibliothek könnte sys.prevent_yields von asyncio.TaskGroup, asyncio.timeout und asyncio.timeout_at verwendet werden. Nachgelagert erwarten wir, es in trio.CancelScope, async Fixtures (in pytest-trio, anyio etc.) und vielleicht anderen Orten zu verwenden.
Wir betrachten Anwendungsfälle, die nichts mit asynchroner Korrektheit zu tun haben, wie z.B. die Verhinderung, dass decimal.localcontext aus einem Generator austritt, außerhalb des Rahmens dieses PEPs.
Die Unterstützung für Generator-zu-Kontextmanager würde von @contextlib.(async)contextmanager und gegebenenfalls in (Async)ExitStack verwendet werden.
Abwärtskompatibilität
Die Hinzufügung des Kontextmanagers sys.prevent_yields, Änderungen an @contextlib.(async)contextmanager und die entsprechenden Interpreterunterstützung sind alle vollständig abwärtskompatibel.
Das Verhindern von Yields innerhalb von asyncio.TaskGroup, asycio.timeout und asyncio.timeout_at wäre eine Breaking Change für mindestens einige existierende Codebasen, die (wenn auch unsicher und anfällig für die oben genannten motivierenden Probleme) oft genug funktionieren, um in Produktion zu gehen.
Wir werden Feedback von der Community zu geeigneten Deprecationspfaden für Code der Standardbibliothek einholen, einschließlich der vorgeschlagenen Länge etwaiger Deprecationsperioden. Als erster Vorschlag könnten wir das Aussetzen von Standardbibliothekskontexten in Python 3.14 nur unter asyncio-Debugmodus mit einer DeprecationWarning versehen; dann zur Standardwarnung und Fehler unter Debugmodus in 3.15 übergehen; und schließlich einen harten Fehler in 3.16 einführen.
Unabhängig von der Nutzung der Standardbibliothek würden nachgelagerte Frameworks diese Funktionalität sofort übernehmen.
Wie weit verbreitet ist dieser Fehler?
Wir haben hier keine soliden Zahlen, glauben aber, dass viele Projekte in der Wildnis betroffen sind. Seitdem ich auf der Arbeit innerhalb einer Woche auf einen moderaten und einen kritischen Fehler gestoßen bin, der auf das Aussetzen eines Abbruchkontexts zurückzuführen ist, habe ich statische Analyse mit einigem Erfolg eingesetzt. Drei Personen, mit denen Zac auf der PyCon sprach, erkannten die Symptome und kamen zu dem Schluss, dass sie wahrscheinlich betroffen waren.
TODO: Führen Sie die Linter-Regel ASYNC101 in Ökosystem-Projekten aus, z.B. den aio-libs-Paketen, und verschaffen Sie sich einen Eindruck von der Häufigkeit in weit verbreiteten PyPI-Paketen? Dies würde helfen, die Break-/Deprecationspfade für Code der Standardbibliothek zu informieren.
Wie man das lehrt
Asynchrone Generatoren werden Anfängern nur sehr selten beigebracht.
Die meisten mittleren und fortgeschrittenen Python-Programmierer werden mit diesem PEP nur als Benutzer von TaskGroup, timeout und @contextmanager interagieren. Für diese Gruppe erwarten wir, dass eine klare Fehlermeldung und Dokumentation ausreichen.
- Eine neue Sektion wird zur Seite Entwicklung mit asyncio hinzugefügt, die kurz besagt, dass asynchrone Generatoren nicht
yielden dürfen, wenn sie sich innerhalb eines "Abbruchkontext"-Kontexts befinden, d.h.TaskGroupodertimeoutKontextmanager. Wir gehen davon aus, dass die Problemstellung und Teile des Motivationsabschnitts als Grundlage für diese Dokumente dienen werden.- Beim Arbeiten in Codebasen, die asynchrone Generatoren vollständig vermeiden [5], haben wir festgestellt, dass ein asynchroner Kontextmanager, der einen asynchronen Iterable liefert, ein sicherer und ergonomischer Ersatz für asynchrone Generatoren ist – und die Probleme mit der verzögerten Bereinigung vermeidet, die in PEP 533 beschrieben sind und die dieser Vorschlag nicht behandelt.
- In der Dokumentation jedes Kontextmanagers, der einen Abbruchkontext umschließt und damit jetzt
sys.prevent_yields, fügen Sie einen Standardhinweis hinzu, wie z.B. "Wenn er innerhalb eines asynchronen Generators verwendet wird, ist es ein Fehler, [in diesem Kontextmanager zuyielden]." mit einem Hyperlink zur obigen Erklärung.
Für asyncio, Trio, curio oder andere Framework-Maintainer, die Abbruchkontext-Semantiken implementieren, werden wir sicherstellen, dass die Dokumentation von sys.prevent_yields eine vollständige Erklärung liefert, die aus den Lösungs- und Implementierungsabschnitten dieses PEPs destilliert ist. Wir erwarten, dass wir die meisten dieser Maintainer um ihr Feedback zum PEP-Entwurf bitten.
Abgelehnte Alternativen
PEP 533, deterministische Bereinigung für Iteratoren
PEP 533 schlägt die Ergänzung von __[a]iterclose__ zum Iterator-Protokoll vor, im Wesentlichen das Umschließen jeder (asynchronen) for-Schleife mit with [a]closing(ait). Dies wäre zwar nützlich, um die rechtzeitige und deterministische Bereinigung von Ressourcen sicherzustellen, die von Iteratoren gehalten werden, und löst das Problem, das es zu lösen versucht, aber es adressiert nicht vollständig die Probleme, die dieses PEP motivieren.
Selbst mit PEP 533 würden fehlgeleitete Abbrüche immer noch an die falsche Aufgabe geliefert und könnten verheerende Auswirkungen haben, bevor der Iterator geschlossen wird. Außerdem adressiert es nicht das grundlegende Problem der strukturierten Nebenläufigkeit mit TaskGroup, bei dem das Aussetzen eines Frames, der eine TaskGroup besitzt, mit dem Modell, dass Kindaufgaben vollständig in ihrem Elternframe gekapselt sind, unvereinbar ist.
Asynchrone Generatoren ganz verwerfen
Auf dem Sprachgipfel 2024 schlugen mehrere Teilnehmer stattdessen vor, asynchrone Generatoren *in toto* zu verwerfen. Unglücklicherweise verwenden zwar die in der Praxis häufigen Fälle asynchrone Generatoren, aber Trio-Code kann das gleiche Problem mit Standard-Generatoren auslösen.
# We use Trio for this example, because while `asyncio.timeout()` is async,
# Trio's CancelScope type and timeout context managers are synchronous.
import trio
def abandon_each_iteration_after(max_seconds):
# This is of course broken, but I can imagine someone trying it...
while True:
with trio.move_on_after(max_seconds):
yield
@trio.run
async def main():
for _ in abandon_each_iteration_after(max_seconds=1):
await trio.sleep(3)
Wenn es den fraglichen Fehler nicht gäbe, würde dieser Code ziemlich idiomatisch aussehen – aber nach etwa einer Sekunde löst er anstatt zur nächsten Iteration überzugehen eine Ausnahme aus.
Traceback (most recent call last):
File "demo.py", line 10, in <module>
async def main():
File "trio/_core/_run.py", line 2297, in run
raise runner.main_task_outcome.error
File "demo.py", line 12, in main
await trio.sleep(3)
File "trio/_timeouts.py", line 87, in sleep
await sleep_until(trio.current_time() + seconds)
...
File "trio/_core/_run.py", line 1450, in raise_cancel
raise Cancelled._create()
trio.Cancelled: Cancelled
Darüber hinaus gibt es einige synchrone Kontextmanager, die keine Abbruchkontexte umfassen, die verwandte Probleme aufweisen, wie z.B. der oben erwähnte decimal.localcontext. Während die Behebung des folgenden Beispiels kein Ziel dieses PEPs ist, zeigt es, dass Yield-innerhalb-mit-Probleme nicht ausschließlich asynchronen Generatoren vorbehalten sind.
import decimal
def why_would_you_do_this():
with decimal.localcontext(decimal.Context(prec=1)):
yield
one = decimal.Decimal(1)
print(one / 3) # 0.3333333333333333333333333333
next(gen := why_would_you_do_this())
print(one / 3) # 0.3
Obwohl ich gute Erfahrungen in asynchronem Python ohne asynchrone Generatoren gemacht habe [5], würde ich lieber das Problem lösen, als sie aus der Sprache zu entfernen.
Können wir Ausnahmen nicht einfach an die richtige Stelle liefern?
Wenn wir PEP 568 (Generator-Sensitivität für Kontextvariablen; siehe auch PEP 550) implementieren würden, wäre es möglich, Ausnahmen von Timeouts zu behandeln: Die Ereignisschleife könnte verhindern, eine CancelledError auszulösen, bis der Generator-Frame, der den Kontextmanager enthält, auf dem Stapel liegt – entweder wenn der Generator wieder aufgenommen wird oder wenn er finalisiert wird.
Dies kann beliebig lange dauern; selbst wenn wir PEP 533 implementieren würden, um eine rechtzeitige Bereinigung beim Beenden von (asynchronen) for-Schleifen sicherzustellen, ist es immer noch möglich, einen Generator manuell mit next/send anzutreiben.
Dies löst jedoch nicht das andere Problem mit TaskGroup. Das Modell für Generatoren ist, dass man einen Stack-Frame in suspendiertem Zustand versetzt und ihn dann als inertes Wert behandeln kann, das gespeichert, verschoben und vielleicht an einem beliebigen Ort verworfen oder wiederbelebt werden kann. Das Modell für strukturierte Nebenläufigkeit ist, dass Ihr Stack zu einem Baum wird, wobei Kindaufgaben innerhalb eines Elternframes gekapselt sind. Sie erweitern das grundlegende strukturierte Programmiermodell in unterschiedliche und leider inkompatible Richtungen.
Angenommen zum Beispiel, dass das Aussetzen eines Frames, der eine offene TaskGroup enthält, auch alle Kindaufgaben aussetzen würde. Dies würde die "abwärts gerichtete" strukturierte Nebenläufigkeit wahren, insofern als dass Kinder gekapselt bleiben – allerdings auf Kosten des Deadlocks unserer beiden motivierenden Beispiele und vieler realweltlicher Codes. Es wäre jedoch immer noch möglich, den Generator in einer anderen Aufgabe wieder aufzunehmen, was die "aufwärts gerichtete" Invariante der strukturierten Nebenläufigkeit verletzen würde.
Wir glauben nicht, dass es sich lohnt, so viel Maschinerie hinzuzufügen, um Abbruchkontexte zu handhaben, während Task Groups weiterhin kaputt bleiben.
Alternative Implementierung – Bytecode inspizieren
Jelle Zijlstra hat eine Alternative skizziert, bei der sys.prevent_yields den Bytecode von Aufrufern inspiziert, bis er sicher ist, dass zwischen dem aufrufenden Instruktionszeiger und dem nächsten Kontextende kein Yield liegt. Wir erwarten, dass die Unterstützung für syntaktisch verschachtelte Kontextmanager ziemlich einfach hinzugefügt werden könnte.
Es ist jedoch noch unklar, wie dies funktionieren würde, wenn benutzerdefinierte Kontextmanager sys.prevent_yields umschließen. Schlimmer noch, dieser Ansatz ignoriert explizite Aufrufe von __enter__() und __exit__(), was bedeutet, dass sich das Kontextmanagement-Protokoll je nachdem, ob die with-Anweisung verwendet wurde, unterscheiden würde.
Die "nur bezahlen, wenn Sie es benutzen"-Performance-Kosten sind sehr attraktiv. Die Inspektion von Frame-Objekten ist jedoch für Kern-Kontrollflusskonstrukte unerschwinglich teuer und verursacht durch Deoptimierung verlangsamte Programme. Auf der anderen Seite führt die Hinzufügung von Interpreter-Unterstützung für bessere Leistung zu den gleichen "unabhängig von der Nutzung bezahlten" Semantiken wie unsere bevorzugte Lösung oben.
Fußnoten
Urheberrecht
Dieses Dokument wird in die Public Domain oder unter die CC0-1.0-Universal-Lizenz gestellt, je nachdem, welche Lizenz permissiver ist.
Quelle: https://github.com/python/peps/blob/main/peps/pep-0789.rst
Zuletzt geändert: 2024-06-04 01:45:13 GMT