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

Python Enhancement Proposals

PEP 806 – Gemischte Sync/Async-Kontextmanager mit präziser Async-Markierung

Autor:
Zac Hatfield-Dodds <zac at zhd.dev>
Sponsor:
Jelle Zijlstra <jelle.zijlstra at gmail.com>
Discussions-To:
Discourse thread
Status:
Entwurf
Typ:
Standards Track
Erstellt:
05-Sep-2025
Python-Version:
3.15
Post-History:
22-Mai-2025, 25-Sep-2025

Inhaltsverzeichnis

Zusammenfassung

Python erlaubt den Anweisungen with und async with, mehrere Kontextmanager in einer einzigen Anweisung zu handhaben, solange sie alle synchron bzw. asynchron sind. Beim Mischen von synchronen und asynchronen Kontextmanagern müssen Entwickler tief verschachtelte Anweisungen verwenden oder riskante Workarounds wie die übermäßige Nutzung von AsyncExitStack nutzen.

Wir schlagen daher vor, dass with-Anweisungen sowohl synchrone als auch asynchrone Kontextmanager in einer einzigen Anweisung akzeptieren, indem einzelne async-Kontextmanager mit dem Schlüsselwort async präfigiert werden.

Diese Änderung eliminiert unnötige Verschachtelung, verbessert die Lesbarkeit des Codes und erhöht die Ergonomie, ohne den async-Code weniger explizit zu machen.

Motivation

Moderne Python-Anwendungen müssen häufig mehrere Ressourcen erwerben, über eine Mischung aus synchronen und asynchronen Kontextmanagern. Während die reinen Sync- oder reinen Async-Fälle eine einzelne Anweisung mit mehreren Kontextmanagern zulassen, führt das Mischen der beiden zum „Staircase of Doom“ (Treppe des Verderbens).

async def process_data():
    async with acquire_lock() as lock:
        with temp_directory() as tmpdir:
            async with connect_to_db(cache=tmpdir) as db:
                with open('config.json', encoding='utf-8') as f:
                    # We're now 16 spaces deep before any actual logic
                    config = json.load(f)
                    await db.execute(config['query'])
                    # ... more processing

Diese übermäßige Einrückung entmutigt die Verwendung von Kontextmanagern trotz ihrer wünschenswerten Semantik. Siehe den Abschnitt Abgelehnte Ideen für aktuelle Workarounds und Kommentare zu deren Nachteilen.

Mit diesem PEP könnte die Funktion stattdessen so geschrieben werden:

async def process_data():
    with (
        async acquire_lock() as lock,
        temp_directory() as tmpdir,
        async connect_to_db(cache=tmpdir) as db,
        open('config.json', encoding='utf-8') as f,
    ):
        config = json.load(f)
        await db.execute(config['query'])
        # ... more processing

Diese kompakte Alternative vermeidet, dass bei jedem Wechsel zwischen Sync- und Async-Kontextmanagern eine neue Einrückungsebene erzwungen wird. Gleichzeitig werden nur vorhandene Schlüsselwörter verwendet, wobei der asynchrone Code mit dem Schlüsselwort async noch präziser als mit unserer aktuellen Syntax unterschieden wird.

Wir schlagen nicht vor, dass die async with-Anweisung jemals veraltet sein sollte, und befürworten sogar ihre fortgesetzte Verwendung für Einzeilenanweisungen, sodass „async“ das erste Nicht-Leerzeichen-Token jeder Zeile ist, die einen asynchronen Kontextmanager öffnet.

Unser Vorschlag erlaubt dennoch with async some_ctx() und legt Wert auf ein konsistentes Syntaxdesign gegenüber der Erzwingung eines einzelnen Codestils, was wir von Stilrichtlinien, Lintern, Formatierern usw. erwarten. Weitere Diskussionen finden Sie hier.

Reale Auswirkungen

Diese Verbesserungen adressieren täglich auftretende Schmerzpunkte von Python-Entwicklern. Wir haben eine Codebasis aus der Praxis untersucht und mehr als zehntausend Funktionen gefunden, die mindestens einen asynchronen Kontextmanager enthalten. 19% davon enthielten auch einen synchronen Kontextmanager. Zum Vergleich: Asynchrone Funktionen enthalten synchrone Kontextmanager etwa doppelt so oft wie asynchrone Kontextmanager.

39% der Funktionen mit sowohl with als auch async with-Anweisungen könnten sofort auf die vorgeschlagene Syntax umgestellt werden, dies ist jedoch eine grobe Untergrenze aufgrund der Vermeidung von synchronen Kontextmanagern und der Verwendung von Workarounds, die unter Abgelehnte Ideen aufgeführt sind. Basierend auf der Untersuchung einer zufälligen Stichprobe von Funktionen schätzen wir, dass zwischen 20% und 50% der asynchronen Funktionen, die irgendeinen Kontextmanager enthalten, with async verwenden würden, wenn dieser PEP akzeptiert wird.

Im breiteren Ökosystem erwarten wir niedrigere Raten, vielleicht im Bereich von 5% bis 20%: Die untersuchte Codebasis verwendet strukturierte Nebenläufigkeit mit Trio und nutzt auch intensiv Kontextmanager, um die in PEP 533 und PEP 789 diskutierten Probleme zu mildern.

Begründung

Gemischte synchron/asynchrone Kontextmanager sind in modernen Python-Anwendungen üblich, z. B. bei asynchronen Datenbankverbindungen oder API-Clients und synchronen Dateivorgängen. Die aktuelle Syntax zwingt Entwickler, sich zwischen tief verschachteltem Code oder fehleranfälligen Workarounds wie AsyncExitStack zu entscheiden.

Dieser PEP befasst sich mit dem Problem durch eine minimale Syntaxänderung, die auf bestehenden Mustern aufbaut. Indem einzelne Kontextmanager mit async gekennzeichnet werden können, behalten wir Pythons expliziten Ansatz für asynchronen Code bei und eliminieren gleichzeitig unnötige Verschachtelungen.

Die Implementierung als syntaktischer Zucker sorgt für keinen Laufzeit-Overhead – die neue Syntax wird zu denselben verschachtelten with- und async with-Anweisungen aufgelöst, die Entwickler heute schreiben. Dieser Ansatz erfordert keine neuen Protokolle, keine Änderungen an bestehenden Kontextmanagern und keine neuen Laufzeitverhalten, die verstanden werden müssen.

Spezifikation

Die with (..., async ...):-Syntax wird in eine Sequenz von Kontextmanagern aufgelöst, so wie aktuelle Multi-Kontext-with-Anweisungen, außer dass die mit dem Schlüsselwort async präfigierten das __aenter__ / __aexit__-Protokoll verwenden.

Nur die with-Anweisung wird modifiziert; async with async ctx(): ist ein Syntaxfehler.

Der ast.withitem-Knoten erhält ein neues is_async-Integer-Attribut, analog zum bestehenden is_async-Attribut in ast.comprehension. Für async with-Anweisungselemente ist dieses Attribut immer 1. Für Elemente in einer regulären with-Anweisung ist das Attribut 1, wenn das async-Schlüsselwort vorhanden ist, und andernfalls 0. Dies ermöglicht es der AST, präzise darzustellen, welche Kontextmanager das asynchrone Protokoll verwenden sollten, und gleichzeitig die Abwärtskompatibilität mit vorhandenen AST-Verarbeitungstools zu wahren.

Abwärtskompatibilität

Diese Änderung ist vollständig abwärtskompatibel: Der einzige beobachtbare Unterschied ist, dass bestimmte Syntax, die zuvor einen SyntaxError auslöste, nun erfolgreich ausgeführt wird.

Bibliotheken, die Kontextmanager implementieren (Standardbibliothek und Drittanbieter), funktionieren ohne Änderungen mit der neuen Syntax. Bibliotheken und Tools, die direkt mit Quellcode arbeiten, benötigen kleine Aktualisierungen, wie bei jeder neuen Syntax.

Wie man das lehrt

Wir empfehlen die Einführung von „gemischten Kontextmanagern“ zusammen mit oder unmittelbar nach async with. Ein Tutorial könnte beispielsweise behandeln:

  1. Grundlegende Kontextmanager: Beginnen Sie mit einzelnen with-Anweisungen
  2. Mehrere Kontextmanager: Zeigen Sie die aktuelle Kommasyntax
  3. Asynchrone Kontextmanager: Führen Sie async with ein
  4. Gemischte Kontexte: „Markieren Sie jeden asynchronen Kontextmanager mit async

Abgelehnte Ideen

Workaround: Ein as_acm() Wrapper

Es ist einfach, eine Hilfsfunktion zu implementieren, die einen synchronen Kontextmanager in einen asynchronen Kontextmanager einwickelt. Zum Beispiel:

@contextmanager
async def as_acm(sync_cm):
    with sync_cm as result:
        await sleep(0)
        yield result

async with (
    acquire_lock(),
    as_acm(open('file')) as f,
):
    ...

Dies ist unser empfohlener Workaround für fast allen Code.

Es gibt jedoch einige Fälle, in denen ein Rückruf an die asynchrone Laufzeit (d.h. die Ausführung von await sleep(0)), um eine Unterbrechung zu ermöglichen, unerwünscht ist. Andererseits wäre das *Weglassen* von await sleep(0) die Eigenschaft der Transitivität zu brechen, dass ein syntaktisches await / async for / async with immer einen Rückruf an die asynchrone Laufzeit tätigt (oder eine Ausnahme auslöst). Obwohl nur wenige Codebasen diese Eigenschaft heute erzwingen, haben wir sie als unverzichtbar für die Verhinderung von Deadlocks befunden und bevorzugen daher eine sauberere Grundlage für das Ökosystem.

Workaround: Verwendung von AsyncExitStack

AsyncExitStack bietet eine leistungsstarke, Low-Level-Schnittstelle, die den expliziten Eintritt von synchronen und/oder asynchronen Kontextmanagern ermöglicht.

async with contextlib.AsyncExitStack() as stack:
    await stack.enter_async_context(acquire_lock())
    f = stack.enter_context(open('file', encoding='utf-8'))
    ...

Allerdings führt AsyncExitStack zu erheblicher Komplexität und potenziellem Fehlerquellen – es ist leicht, Eigenschaften zu verletzen, die die syntaktische Verwendung von Kontextmanagern garantieren würde, wie z.B. die „Last-in, First-out“-Reihenfolge.

Workaround: Ein Helfer basierend auf AsyncExitStack

Wir könnten auch einen multicontext()-Wrapper implementieren, der einige der Nachteile der direkten Verwendung von AsyncExitStack vermeidet.

async with multicontext(
    acquire_lock(),
    open('file'),
) as (f, _):
    ...

Dieser Helfer bricht jedoch die Lokalität von as-Klauseln, was es leicht macht, die übergebenen Variablen versehentlich falsch zuzuweisen (wie im Codebeispiel). Er erfordert entweder die Unterscheidung zwischen synchronen und asynchronen Kontextmanagern anhand von etwas wie einer getaggten Vereinigung – vielleicht durch Überladung eines Operators, sodass z.B. async_ @ acquire_lock() funktioniert – oder durch Raten, was mit Objekten zu tun ist, die sowohl synchrone als auch asynchrone Kontextmanagerprotokolle implementieren. Schließlich hat er die fehleranfällige Semantik im Umgang mit Ausnahmen, die dazu führte, dass contextlib.nested() zugunsten der Mehrargumenten-with-Anweisung veraltet wurde.

Syntax: Erlaubt async with sync_cm, async_cm:

Ein früher Entwurf dieses Vorschlags verwendete async with für die gesamte Anweisung beim Mischen von Kontextmanagern, *wenn* mindestens ein asynchroner Kontextmanager vorhanden war.

# Rejected approach
async with (
    acquire_lock(),
    open('config.json') as f,  # actually sync, surprise!
):
    ...

Das Erfordernis eines asynchronen Kontextmanagers erhält die Syntax/Scheduler-Verbindung, aber auf Kosten der Festlegung unsichtbarer Einschränkungen für zukünftige Codeänderungen. Das Entfernen eines von mehreren Kontextmanagern könnte zu Laufzeitfehlern führen, wenn dieser zufällig der letzte asynchrone Kontextmanager war!

Explizit ist besser als implizit.

Syntax: Verbietet Einzeilen-with async ...

Unsere vorgeschlagene Syntax könnte eingeschränkt werden, z. B. indem async nur als erstes Token von Zeilen in einer eingeklammerten Multi-Kontext-with-Anweisung platziert wird. So wird es tatsächlich von uns empfohlen, und wir erwarten, dass die meisten Verwendungen diesem Muster folgen werden.

Während eine Option, entweder async with ctx(): oder with async ctx(): zu schreiben, aufgrund von Mehrdeutigkeiten einige geringfügige Verwirrung stiften mag, denken wir, dass die Erzwingung eines bevorzugten Stils durch die Syntax Python schwieriger zu erlernen machen würde, und bevorzugen daher einfache syntaktische Regeln plus Community-Konventionen, wie sie zu verwenden sind.

Zur Veranschaulichung halten wir es nicht für offensichtlich, ab welchem Punkt (falls überhaupt) in den folgenden Codebeispielen die Syntax nicht mehr zulässig sein sollte.

with (
    sync_context() as foo,
    async a_context() as bar,
): ...

with (
    sync_context() as foo,
    async a_context()
): ...

with (
    # sync_context() as foo,
    async a_context()
): ...

with (async a_context()): ...

with async a_context(): ...

Danksagungen

Vielen Dank an Rob Rolls für den Vorschlag with async. Vielen Dank auch an die vielen anderen Personen, mit denen wir dieses Problem und mögliche Lösungen bei den PyCon 2025 Sprints, auf Discourse und bei der Arbeit diskutiert haben.


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

Zuletzt geändert: 2025-09-27 10:52:42 GMT