Async/Await in Rust
Stell dir ein Restaurant vor. Auf der Seite Nebenläufigkeit hast du Threads kennengelernt. Das ist so, als würdest du für jeden Tisch einen eigenen Kellner einstellen. Das funktioniert, aber Kellner sind teuer, und die meiste Zeit stehen sie nur herum und warten auf die Küche.
Async ist wie ein Kellner, der viele Bestellungen aufnimmt. Er nimmt die Bestellung von Tisch 1 auf, gibt sie an die Küche weiter und geht zu Tisch 2, während das Essen kocht. Niemand steht untätig herum. Genau das ist die Idee hinter async und .await in Rust: Während eine Aufgabe wartet (auf das Netzwerk, eine Datei, einen Timer), darf eine andere Aufgabe laufen.
Async spielt seine Stärken aus, wenn dein Programm viel Zeit mit Warten verbringt – bei Webservern, API-Clients, Chat-Apps oder Bots. Auf dieser Seite verwenden wir tokio, die beliebteste Async-Runtime für Rust.
tokio einrichten
Abschnitt betitelt „tokio einrichten“Die Standardbibliothek von Rust bringt die Schlüsselwörter async und .await mit, aber nicht den „Motor“, der Async-Code tatsächlich ausführt. Dieser Motor heißt Runtime (Laufzeitumgebung), und tokio ist die Runtime, die die meisten Projekte nutzen. So fügst du sie zu einem neuen Projekt hinzu:
cargo new async_democd async_democargo add tokio --features fullDamit landet diese Zeile in deiner Cargo.toml:
[dependencies]tokio = { version = "1", features = ["full"] }Das Feature full schaltet alles ein (Timer, Tasks, Netzwerk und mehr). Solange du lernst, ist das die einfachste Wahl. Die Buttons „Selbst ausprobieren“ auf dieser Seite funktionieren übrigens auch – im Rust Playground ist tokio schon eingebaut.
Dein erstes Async-Programm
Abschnitt betitelt „Dein erstes Async-Programm“use std::time::Duration;
async fn make_coffee() -> String { tokio::time::sleep(Duration::from_millis(20)).await; String::from("Kaffee")}
#[tokio::main]async fn main() { println!("Bestelle ..."); let drink = make_coffee().await; println!("Hier ist dein {}", drink);}Ausgabe:
Bestelle ...Hier ist dein KaffeeHier gibt es drei neue Dinge:
async fnerzeugt eine Funktion, die mittendrin pausieren und später weitermachen kann..awaitbedeutet „warte, bis das hier fertig ist – und lass währenddessen andere Aufgaben laufen“. Du kannst.awaitnur innerhalb einerasync-Funktion benutzen.#[tokio::main]startet die tokio-Runtime und führt deinasync fn maindarauf aus. Ohne dieses Attribut kannmainnichtasyncsein.
tokio::time::sleep ist die Async-Version von „kurz warten“. Es pausiert nur diese Aufgabe, nicht den ganzen Thread.
Futures sind faul
Abschnitt betitelt „Futures sind faul“Jetzt kommt der Teil, der die meisten überrascht: Wenn du eine async fn aufrufst, wird sie nicht ausgeführt. Du bekommst stattdessen ein Future zurück – einen Wert, der für „Arbeit, die später passiert“ steht. Es passiert nichts, bis du das Future mit .await abwartest:
async fn say_hello() { println!("Hallo aus dem Inneren des Futures!");}
#[tokio::main]async fn main() { let future = say_hello(); // noch läuft nichts println!("Future erstellt, aber noch nicht gestartet");
future.await; // jetzt läuft es println!("Fertig");}Ausgabe:
Future erstellt, aber noch nicht gestartetHallo aus dem Inneren des Futures!FertigStell dir ein Future wie einen Bestellzettel vor. Nur weil der Zettel geschrieben ist, wird noch nichts gekocht. Erst wenn du ihn an die Küche gibst (.await), passiert etwas.
Aufgaben gleichzeitig ausführen mit join!
Abschnitt betitelt „Aufgaben gleichzeitig ausführen mit join!“Bisher haben wir immer nur auf eine Sache gewartet. Richtig stark wird Async, wenn du auf mehrere Dinge gleichzeitig wartest. tokio::join! führt mehrere Futures zusammen aus und wartet, bis alle fertig sind. Die Ergebnisse bekommst du als Tupel:
use std::time::Duration;use tokio::time::sleep;
async fn fetch_user() -> String { sleep(Duration::from_millis(30)).await; // tu so, als wäre das ein Netzwerkaufruf String::from("Ana")}
async fn fetch_order_count() -> u32 { sleep(Duration::from_millis(20)).await; 3}
#[tokio::main]async fn main() { let (user, orders) = tokio::join!(fetch_user(), fetch_order_count()); println!("{} hat {} Bestellungen", user, orders);}Ausgabe:
Ana hat 3 BestellungenWürdest du die beiden nacheinander mit .await abwarten, läge die Wartezeit insgesamt bei etwa 30 + 20 = 50 ms. Mit join! laufen beide Wartezeiten gleichzeitig ab, also dauert es nur etwa 30 ms – so lange wie das langsamste Future. Bei echten Netzwerkaufrufen summiert sich dieser Unterschied schnell.
Tasks starten mit tokio::spawn
Abschnitt betitelt „Tasks starten mit tokio::spawn“join! ist super, wenn du eine feste Anzahl von Futures hast. Bei einer ganzen Liste von Jobs nimmst du tokio::spawn. Es übergibt ein Future als eigenen Task (Aufgabe) an die Runtime, startet ihn sofort und gibt dir ein JoinHandle zurück. Später wartest du mit .await auf das Handle und bekommst das Ergebnis:
use std::time::Duration;use tokio::time::sleep;
async fn download(id: u64) -> String { sleep(Duration::from_millis(50 - id * 10)).await; format!("Datei {} heruntergeladen", id)}
#[tokio::main]async fn main() { let mut handles = Vec::new();
for id in 1..=3 { let handle = tokio::spawn(download(id)); // läuft ab sofort handles.push(handle); }
for handle in handles { let result = handle.await.unwrap(); println!("{}", result); }}Ausgabe:
Datei 1 heruntergeladenDatei 2 heruntergeladenDatei 3 heruntergeladenSchau genau hin: Datei 3 hat die kürzeste Wartezeit, ist also eigentlich zuerst fertig. Wir geben die Ergebnisse aber in der Reihenfolge aus, in der wir auf die Handles warten. Deshalb lautet die Ausgabe immer 1, 2, 3. Alle drei Downloads sind trotzdem gleichzeitig gelaufen.
Und warum .unwrap()? Wenn du auf ein JoinHandle wartest, bekommst du ein Result, weil der Task in eine Panic geraten sein könnte. In dem Fall bekommst du ein Err, statt dass dein ganzes Programm abstürzt. Das sieht thread::spawn und .join() von der Seite Nebenläufigkeit sehr ähnlich – und das ist Absicht.
Häufige Fehler
Abschnitt betitelt „Häufige Fehler“.await vergessen. Weil Futures faul sind, passiert die Arbeit nie, wenn du .await vergisst. Wenn du den Wert benutzt, hält dich der Compiler auf:
async fn get_number() -> u32 { 5}
#[tokio::main]async fn main() { let n = get_number(); // .await vergessen println!("{}", n + 1);}error[E0369]: cannot add `{integer}` to `impl Future<Output = u32>` --> src/main.rs:8:22 |8 | println!("{}", n + 1); | - ^ - {integer} | | | impl Future<Output = u32>Die Meldung sagt: n ist ein Future, das irgendwann einen u32 liefert – aber eben kein u32. Die Lösung ist let n = get_number().await;. Wenn die Funktion nichts zurückgibt und du sie einfach aufrufst, etwa save_file();, bekommst du nur eine Warnung. Lies also deine Warnungen:
warning: unused implementer of `Future` that must be used |7 | save_file(); | ^^^^^^^^^^^ | = note: futures do nothing unless you `.await` or poll themDie Notiz am Ende erinnert dich: Futures tun nichts, solange du sie nicht mit .await abwartest.
std::thread::sleep in Async-Code benutzen. Dieser Fehler kompiliert ohne Probleme, und genau das macht ihn so tückisch:
async fn slow_task() { std::thread::sleep(std::time::Duration::from_secs(1)); // blockiert den ganzen Thread!}std::thread::sleep friert den gesamten Thread ein. Die Runtime kann nicht zu anderen Tasks wechseln, also steht alles auf diesem Thread still – wie ein Kellner, der an einem Tisch im Stehen einschläft. Benutze in Async-Code immer tokio::time::sleep(...).await. Dasselbe gilt für andere langsame, blockierende Arbeit, zum Beispiel das Lesen riesiger Dateien mit std::fs. Dafür hat tokio Async-Versionen (tokio::fs), oder du nimmst tokio::task::spawn_blocking.
Erwarten, dass Async rechenintensive Arbeit schneller macht. Async hilft, wenn Tasks warten. Es hilft nicht, wenn Tasks beschäftigt sind, etwa beim Skalieren von Bildern oder beim Zahlenrechnen. Ein Kellner kocht nicht schneller, nur weil er mehr Bestellungen aufnimmt. Für schwere CPU-Arbeit nimmst du Threads (oder ein Crate wie rayon).
Async oder Threads?
Abschnitt betitelt „Async oder Threads?“| Nimm Async, wenn … | Nimm Threads, wenn … |
|---|---|
| die meiste Zeit mit Warten vergeht (Netzwerk, Datenbank, Timer) | die Arbeit aus schweren Berechnungen besteht |
| du Tausende Tasks gleichzeitig brauchst (wie bei einem Webserver) | nur ein paar Dinge parallel laufen müssen |
| du Async-Crates wie reqwest oder axum verwendest | du keine zusätzliche Runtime als Abhängigkeit willst |
Du musst dich nicht für eins entscheiden. Viele echte Programme nutzen Async fürs Netzwerk und Threads für die schwere Arbeit.
Sollen Tasks sich gegenseitig Nachrichten schicken? Dafür hat tokio einen eigenen Channel, tokio::sync::mpsc. Er funktioniert wie der aus der Standardbibliothek auf der Seite Channels – nur schreibst du rx.recv().await. So blockiert das Warten auf eine Nachricht nicht den Thread.
Was man damit baut
Abschnitt betitelt „Was man damit baut“Der größte Teil des Async-Ökosystems in Rust baut auf tokio auf. Zwei Crates wirst du überall sehen:
- reqwest (auf Englisch) – ein HTTP-Client, mit dem du Web-APIs aufrufst. Mit
reqwest::get(url).awaitlädst du eine Seite. - axum (auf Englisch) – ein Web-Framework für Server und APIs, entwickelt vom tokio-Team.
Beide passen wunderbar zu serde_json, um JSON zu lesen und zu schreiben.
Übungen
Abschnitt betitelt „Übungen“1. Frühstück, alles auf einmal
Abschnitt betitelt „1. Frühstück, alles auf einmal“Schreib drei Async-Funktionen: make_toast (schläft 30 ms), fry_eggs (schläft 20 ms) und brew_coffee (schläft 10 ms). Jede gibt einen String mit dem Namen des Essens zurück. Führ alle drei gleichzeitig aus und gib aus, was fertig ist. Tipp: tokio::join! nimmt mehrere Futures und gibt dir ein Tupel mit ihren Ergebnissen zurück.
Lösung anzeigen
use std::time::Duration;use tokio::time::sleep;
async fn make_toast() -> String { sleep(Duration::from_millis(30)).await; String::from("Toast")}
async fn fry_eggs() -> String { sleep(Duration::from_millis(20)).await; String::from("Spiegeleier")}
async fn brew_coffee() -> String { sleep(Duration::from_millis(10)).await; String::from("Kaffee")}
#[tokio::main]async fn main() { let (toast, eggs, coffee) = tokio::join!(make_toast(), fry_eggs(), brew_coffee()); println!("Frühstück: {}, {} und {}", toast, eggs, coffee);}Ausgabe:
Frühstück: Toast, Spiegeleier und KaffeeDer Kaffee ist zuerst fertig, aber das Tupel kommt immer in der Reihenfolge zurück, in der du die Futures aufgeschrieben hast. Das ganze Frühstück dauert etwa 30 ms (so lange wie das langsamste Gericht), nicht 60 ms.
2. Quadratzahlen im Hintergrund
Abschnitt betitelt „2. Quadratzahlen im Hintergrund“Schreib async fn square_later(n: u64) -> u64. Die Funktion schläft 10 * n Millisekunden und gibt dann n * n zurück. Starte in main für jede Zahl von 1 bis 4 einen Task, warte danach auf alle und gib die Summe aus. Tipp: Leg jedes Handle von tokio::spawn(...) in einen Vec, geh dann in einer Schleife über die Handles und addiere handle.await.unwrap().
Lösung anzeigen
use std::time::Duration;use tokio::time::sleep;
async fn square_later(n: u64) -> u64 { sleep(Duration::from_millis(10 * n)).await; n * n}
#[tokio::main]async fn main() { let mut handles = Vec::new(); for n in 1..=4 { handles.push(tokio::spawn(square_later(n))); }
let mut total = 0; for handle in handles { total += handle.await.unwrap(); } println!("Summe = {total}");}Ausgabe:
Summe = 30Alle vier Tasks starten sofort und schlafen gleichzeitig. Deshalb dauert das etwa 40 ms und nicht 10 + 20 + 30 + 40 = 100 ms.
Zusammenfassung
Abschnitt betitelt „Zusammenfassung“- Mit Async kann ein Thread viele Tasks bearbeiten, indem er zwischen ihnen wechselt, während sie warten.
- Rust braucht eine Runtime, um Async-Code auszuführen. Füg tokio mit
cargo add tokio --features fullhinzu und markieremainmit#[tokio::main]. - Eine
async fngibt ein Future zurück. Futures sind faul – nichts läuft, bis du.awaitbenutzt. - Mit
tokio::join!wartest du auf mehrere Futures gleichzeitig, mittokio::spawnstartest du Tasks und bekommst einJoinHandle. - Benutze in Async-Code nie
std::thread::sleep– nimmtokio::time::sleep(...).await. - Async ist fürs Warten da, nicht für schwere CPU-Arbeit. Dafür nimmst du Threads.
Kurzer Check
Abschnitt betitelt „Kurzer Check“Kurzer Check
1. Was passiert, wenn du eine async fn ohne .await aufrufst?
2. Was macht #[tokio::main]?
3. Zwei Futures warten 30 ms und 20 ms. Wie lange dauert tokio::join! mit beiden ungefähr?
4. Warum ist std::thread::sleep in Async-Code ein Problem?
5. Für welche Aufgabe passt Async am besten?
Punkte: 0 / 5
Weiterlesen
Abschnitt betitelt „Weiterlesen“- Grundlagen der asynchronen Programmierung – Das Rust-Buch: das offizielle Kapitel über Futures,
async,.awaitund Streams. - Das tokio-Tutorial (auf Englisch): eine praktische Anleitung, in der du einen kleinen Redis-ähnlichen Server baust – mit Tasks, geteiltem Zustand und Channels.
- tokio auf docs.rs (auf Englisch): die API-Referenz für
tokio::spawn,tokio::time,tokio::syncund den Rest. - Asynchronous Programming in Rust (das Async-Buch) (auf Englisch): ein tieferer Blick darauf, wie Futures und Executors unter der Haube funktionieren.