Zum Inhalt springen

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.

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:

Terminal window
cargo new async_demo
cd async_demo
cargo add tokio --features full

Damit 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.

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 Kaffee
▶ Selbst ausprobieren

Hier gibt es drei neue Dinge:

  • async fn erzeugt eine Funktion, die mittendrin pausieren und später weitermachen kann.
  • .await bedeutet „warte, bis das hier fertig ist – und lass währenddessen andere Aufgaben laufen“. Du kannst .await nur innerhalb einer async-Funktion benutzen.
  • #[tokio::main] startet die tokio-Runtime und führt dein async fn main darauf aus. Ohne dieses Attribut kann main nicht async sein.

tokio::time::sleep ist die Async-Version von „kurz warten“. Es pausiert nur diese Aufgabe, nicht den ganzen Thread.

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 gestartet
Hallo aus dem Inneren des Futures!
Fertig
▶ Selbst ausprobieren

Stell 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.

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 Bestellungen
▶ Selbst ausprobieren

Wü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.

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 heruntergeladen
Datei 2 heruntergeladen
Datei 3 heruntergeladen
▶ Selbst ausprobieren

Schau 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.

.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 them

Die 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).

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.

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).await lä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.

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 Kaffee
▶ Selbst ausprobieren

Der 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.

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 = 30
▶ Selbst ausprobieren

Alle vier Tasks starten sofort und schlafen gleichzeitig. Deshalb dauert das etwa 40 ms und nicht 10 + 20 + 30 + 40 = 100 ms.

  • 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 full hinzu und markiere main mit #[tokio::main].
  • Eine async fn gibt ein Future zurück. Futures sind faul – nichts läuft, bis du .await benutzt.
  • Mit tokio::join! wartest du auf mehrere Futures gleichzeitig, mit tokio::spawn startest du Tasks und bekommst ein JoinHandle.
  • Benutze in Async-Code nie std::thread::sleep – nimm tokio::time::sleep(...).await.
  • Async ist fürs Warten da, nicht für schwere CPU-Arbeit. Dafür nimmst du Threads.

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