12k
All articles

async/await in JavaScript vs. Rust vs. Python

Vergleichen Sie async/await in JavaScript, Rust und Python: Erfahren Sie, wann Funktionen starten, was ohne await geschieht und wie Aufgaben parallel laufen.

OpenReplay Team
OpenReplay Team
async/await in JavaScript vs. Rust vs. Python

Eine asynchrone JavaScript-Funktion beginnt in dem Moment zu laufen, in dem Sie sie aufrufen. Eine Rust-async fn oder eine Python-async def tut dagegen überhaupt nichts, bis sie entweder per await erwartet oder zur Ausführung eingeplant wird.

Dieser Unterschied beim Startzeitpunkt asynchroner Arbeit führt zu einem altbekannten Bug. Die Rust- oder Python-Portierung von funktionierendem TypeScript-Code führt die Funktion entweder nie aus oder arbeitet Aufrufe nacheinander ab, obwohl Sie erwartet hatten, dass sie sich überlappen. Dieser Artikel führt dasselbe kleine Snippet in Node.js, in Rust mit Tokio und in Python 3.11+ aus und zeigt jeweils die Ausgabereihenfolge. Vorausgesetzt werden Kenntnisse in asynchronem JavaScript. Wenn Sie sich zuerst das nötige Hintergrundwissen aneignen möchten, lesen Sie die OpenReplay-Leitfäden zu Best Practices für asynchrone Programmierung in JavaScript und zu Tipps, Tricks und Fallstricken bei asynchronem JavaScript.

Das Wichtigste in Kürze

  • Beim Aufruf einer async-Funktion in JavaScript wird ihr Rumpf bis zum ersten await synchron ausgeführt. Code vor diesem await läuft also sofort.
  • Der Aufruf einer Rust-async fn liefert ein Future zurück und führt nichts vom Rumpf aus, bis das Future per .await erwartet oder an eine Runtime wie Tokio übergeben wird.
  • Der Aufruf einer Python-async def-Funktion liefert ein Coroutine-Objekt zurück, das nichts ausführt, bis es per await erwartet oder in einen Task verpackt wird.
  • Wenn Sie zwei asynchrone Aufrufe erzeugen und sie anschließend nacheinander per await erwarten, überlappt sich die Arbeit in JavaScript, läuft in Rust und Python jedoch sequenziell ab.
  • JavaScript-Runtimes bringen eine integrierte Event Loop mit. Rust benötigt einen Executor wie #[tokio::main], und Python startet seine Loop mit asyncio.run().

Ein Snippet, drei Sprachen, drei Ausgaben

Jede der folgenden Versionen definiert dieselbe Funktion: Ausgabe, await auf einen 10-ms-Sleep, erneute Ausgabe. Anschließend wird die Funktion jeweils ohne await aufgerufen. Nur JavaScript gibt a: start aus.

Die Beispiele beziehen sich auf ein Node.js-LTS-Release, Tokio 1.x und Python 3.11 oder neuer.

JavaScript (Node.js)

async function work(label) {
  console.log(`${label}: start`);
  await new Promise((r) => setTimeout(r, 10));
  console.log(`${label}: end`);
}

work("a"); // no await
console.log("main: after call");
a: start
main: after call
a: end

Rust (Tokio) mit tokio = { version = "1", features = ["macros", "rt", "time"] }

use std::time::Duration;

async fn work(label: &'static str) {
    println!("{}: start", label);
    tokio::time::sleep(Duration::from_millis(10)).await;
    println!("{}: end", label);
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    work("a"); // future created, never awaited
    println!("main: after call");
}
main: after call

Python 3.11+

import asyncio

async def work(label):
    print(f"{label}: start")
    await asyncio.sleep(0.01)
    print(f"{label}: end")

async def main():
    work("a")  # coroutine created, never awaited
    print("main: after call")

asyncio.run(main())
main: after call

Warum läuft async/await in Rust erst beim Awaiten?

Der Unterschied zwischen async/await in JavaScript, Rust und Python liegt darin, was der Aufruf einer async-Funktion zurückgibt. JavaScript liefert ein Promise für Arbeit, die bereits begonnen hat. Rust und Python liefern ein Objekt, das Arbeit repräsentiert, die noch nicht begonnen hat.

  • JavaScript: Eine async function läuft synchron bis zu ihrem ersten await. Das zurückgegebene Promise gehört zu einem Rumpf, der bereits ausgeführt wird.
  • Rust: Eine async fn gibt impl Future zurück. Der Future-Trait beschreibt einen Wert, der nur dann Fortschritte macht, wenn er gepollt wird. Nichts im Rumpf wird ausgeführt, bis Sie .await schreiben oder das Future beispielsweise an tokio::spawn übergeben.
  • Python: Der Aufruf einer async def-Funktion liefert ein Coroutine-Objekt. Die asyncio-Dokumentation stellt unmissverständlich klar, dass der Aufruf einer Coroutine diese nicht zur Ausführung einplant.

Das Forschungspapier von Gray, Krishnamurthi und Crichton beschreibt diese Designentscheidung als Dimension „Eagerness“ des Async-Designs mit drei Optionen: Lazy, Eager und Semi-Eager. In der Praxis startet JavaScript die Arbeit an der Aufrufstelle, während die beiden anderen Sprachen abwarten.

Was passiert, wenn Sie das await vergessen?

Alle drei Sprachen erlauben es, await zu vergessen, scheitern dann aber auf unterschiedliche Weise. JavaScript erledigt die Arbeit trotzdem; das Risiko besteht also in einem Fehler, den niemand abfängt. Rust und Python überspringen die Arbeit komplett und weisen Sie darauf hin.

JavaScript. Der Rumpf wird ausgeführt. Wird das Promise jedoch rejected und behandelt niemand die Rejection, wertet Node dies als Fehler. Der Standardmodus von --unhandled-rejections ist throw: Die Rejection wird als nicht abgefangene Exception geworfen und beendet den Prozess, sofern kein Handler registriert ist:

async function save() { throw new Error("disk full"); }
save(); // no await, no .catch()

Rust. Der Compiler warnt bereits zur Build-Zeit über den Lint unused_must_use:

warning: unused implementer of `Future` that must be used
  = note: futures do nothing unless you `.await` or poll them

Python. Wenn die nicht erwartete Coroutine von der Garbage Collection entfernt wird, gibt CPython eine Warnung auf stderr aus. Der asyncio-Leitfaden behandelt dies im Abschnitt zum Erkennen nie erwarteter Coroutinen:

RuntimeWarning: coroutine 'work' was never awaited

Die Lösung ist in jeder Sprache dieselbe: await work("a") in JavaScript und Python bzw. work("a").await in Rust. Mit dieser Änderung geben alle drei a: start, a: end, main: after call aus.

Wie führen Sie asynchrone Arbeit nebenläufig aus?

In JavaScript genügt der Aufruf der Funktionen, um Nebenläufigkeit zu erreichen. In Rust und Python müssen Sie diese explizit über einen Kombinator oder einen Task anfordern.

Das Muster „erst erzeugen, dann erwarten“ verdeutlicht den Unterschied:

const pa = work("a"), pb = work("b");
await pa; await pb;          // JS: a: start, b: start, a: end, b: end
let fa = work("a"); let fb = work("b");
fa.await; fb.await;          // Rust: a: start, a: end, b: start, b: end
ca = work("a"); cb = work("b")
await ca; await cb           # Python: a: start, a: end, b: start, b: end

Weder das Rust-Future noch die Python-Coroutine startet, bevor es bzw. sie erwartet wird. Jeder Aufruf läuft daher erst, wenn der vorherige abgeschlossen ist. Die Lösungen:

SpracheAuf alle wartenJetzt starten, später erwarten
JavaScriptPromise.allEinfach die Funktion aufrufen
Rusttokio::join!tokio::spawn
Pythonasyncio.gather, asyncio.TaskGroupasyncio.create_task

tokio::join! führt mehrere Futures nebenläufig innerhalb des aktuellen Tasks aus. tokio::spawn übergibt das Future dagegen als separaten Task an die Runtime und liefert ein JoinHandle zurück, das Sie später erwarten können. Ein gespawntes Future muss Send + 'static sein – deshalb nimmt work ein &'static str entgegen.

asyncio.TaskGroup wurde in Python 3.11 eingeführt. Beim Verlassen des async with-Blocks wartet es auf alle Tasks, und wenn ein Task eine Exception auslöst, bricht es die übrigen ab:

async with asyncio.TaskGroup() as tg:
    tg.create_task(work("a"))
    tg.create_task(work("b"))

Selbst das Spawnen eines Tasks ist nicht so eager wie ein JavaScript-Aufruf. Der Rumpf eines gespawnten Tasks läuft erst, wenn der Aufrufer die Kontrolle abgibt:

let h = tokio::spawn(work("a"));
println!("main: after spawn");
h.await.unwrap();
t = asyncio.create_task(work("a"))
print("main: after create_task")
await t

Beide geben zuerst main: after ... aus und anschließend a: start und a: end. JavaScript gibt a: start noch vor der nächsten Zeile des Aufrufers aus. Eine Einschränkung: Die Reihenfolge in Rust gilt für current_thread. Bei einer Multi-Thread-Runtime kann ein anderer Worker den gespawnten Task sofort übernehmen, sodass sich die Zeilen anders verschränken können.

Woher kommt die Event Loop?

JavaScript-Runtimes bringen eine integrierte Event Loop mit, während Sie sie in Rust und Python selbst starten müssen: in Rust über einen Executor wie #[tokio::main], in Python über asyncio.run().

  • JavaScript: Node.js und Browser betreiben bereits eine Event Loop, bevor Ihr Code startet. Der OpenReplay-Artikel zu Event Loop und Call Stack in JavaScript erklärt, wie sie funktioniert. In Node funktioniert Top-Level-await in ES-Modulen (.mjs-Dateien oder "type": "module" in package.json).
  • Rust: Die Standardbibliothek enthält keinen Executor. #[tokio::main] bettet async fn main in eine Tokio-Runtime ein. Standardmäßig wird die Multi-Thread-Runtime verwendet (die das Feature rt-multi-thread erfordert); mit flavor = "current_thread" wechseln Sie zu einer Single-Thread-Runtime.
  • Python: async def ist Teil der Sprache und erfordert keinen Import. Um eine Coroutine auszuführen, benötigen Sie jedoch eine Loop: asyncio.run() erstellt eine, führt Ihre Top-Level-Coroutine aus und schließt die Loop anschließend wieder.

Direkter Vergleich

JavaScriptRust (Tokio)Python (asyncio)
Aufruf einer async-FunktionLäuft bis zum ersten awaitLiefert ein inaktives FutureLiefert eine inaktive Coroutine
Vergessenes awaitArbeit wird ausgeführt; Rejection kann Node zum Absturz bringenCompiler-Warnung, keine AusführungRuntimeWarning, keine Ausführung
NebenläufigkeitPromise.alljoin! / spawngather / TaskGroup / create_task
RuntimeIntegriertExecutor wählenasyncio.run()

Fazit

Die async/await-Syntax sieht in JavaScript, Rust und Python gleich aus, doch die drei Sprachen sind sich uneinig darüber, wann die Arbeit beginnt. JavaScript startet sie beim Aufruf. Rust und Python starten sie erst, wenn Sie das Ergebnis erwarten oder die Ausführung explizit einplanen. Prüfen Sie beim Portieren von asynchronem Code jede Aufrufstelle, an der das Ergebnis nicht unmittelbar erwartet wird. Ersetzen Sie diese Aufrufe in Rust durch tokio::join! oder tokio::spawn. Verwenden Sie in Python asyncio.gather, TaskGroup oder create_task.

FAQs

Können asyncio-Tasks in Python eager starten wie async-Funktionen in JavaScript?

Ja, als Opt-in. Python 3.12 hat asyncio.eager_task_factory eingeführt, das Sie mit loop.set_task_factory(asyncio.eager_task_factory) installieren. Danach beginnt ein neuer Task sofort innerhalb des create_task-Aufrufs mit der Ausführung seiner Coroutine, und zwar bis zur ersten Stelle, an der sie blockiert. Wird die Coroutine ohne Blockieren abgeschlossen, landet sie nie in der Event Loop. Lazy Scheduling bleibt der Standard, und der Wechsel zu Eager Tasks kann die Ausführungsreihenfolge verändern.

Was passiert mit einem tokio::spawn-Task, wenn Sie sein JoinHandle verwerfen?

Der Task läuft weiter. Durch das Verwerfen eines JoinHandle wird der Task abgekoppelt: Er setzt seine Arbeit im Hintergrund fort, sein Rückgabewert geht jedoch verloren, und nichts kann mehr per Join auf ihn warten. Das unterscheidet sich von einem nicht erwarteten Future aus einem async-fn-Aufruf, das überhaupt nie ausgeführt wird. Um einen gespawnten Task zu stoppen, rufen Sie abort() auf dem JoinHandle auf. Das Erwarten eines abgebrochenen Tasks liefert einen JoinError, der den Abbruch meldet.

Worin unterscheiden sich asyncio.gather und asyncio.TaskGroup, wenn ein Task fehlschlägt?

asyncio.gather bricht die anderen Tasks nicht ab, wenn einer eine Exception auslöst. Mit dem Standardwert return_exceptions=False erreicht die erste Exception den wartenden Code sofort, während die übrigen Tasks weiterlaufen. Mit return_exceptions=True werden Exceptions stattdessen in der Ergebnisliste zurückgegeben. asyncio.TaskGroup, eingeführt in Python 3.11, bricht die verbleibenden Tasks ab, wenn einer fehlschlägt, sodass fehlgeschlagene Arbeit nicht unbemerkt weiterläuft.

Warum kann ein Fire-and-Forget-Task in asyncio verschwinden, bevor er fertig ist?

Die asyncio-Event-Loop hält nur schwache Referenzen auf Tasks. Die Garbage Collection kann einen mit asyncio.create_task erzeugten Task daher mitten in der Ausführung entfernen, wenn Ihr Code ihn nirgends speichert. Die Python-Dokumentation empfiehlt, eine Referenz zu behalten – beispielsweise, indem Sie jeden Task einem Set hinzufügen und task.add_done_callback(background_tasks.discard) aufrufen. Eine asyncio.TaskGroup umgeht das Problem, da sie eine starke Referenz auf jeden Task hält und diese erwartet.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.