IO intensive Arbeit

Wir wollen uns mit async vertraut machen. Viele IO intensive Arbeiten machen zu müssen ist einer der wichtigsten Anwendungsfälle für async. Zum Bespiel mehrere Netzwerkanfragen machen. Das werden wir hier simulieren um zu sehen, wie es effizient erledigt werden kann.

Projektstruktur

Erstelle zuerst ein neues Rust Projekt:

cargo new async_io
cd async_io

Als nächstes fügen wir die Bibliotheken tokio hinzu. Das ist eine asynchrone Laufzeit. Rust's async System ist unabhängig von der Laufzeit, eine solche muss man sich deswegen separat besorgen. Die Dokumentation von tokio findest du hier: docs.rs/tokio

cargo add tokio --features full

Tokio hat eine grosse Zahl verschiedener Features, die man wahlweise aktivieren oder deaktivieren kann. Zum Experimentieren ist es am einfachsten alles zu aktivieren, darum --features full.

Simulieren einer Netzwerkanfrage

Zuerst verwenden wir noch kein async, um das Problem aufzuzeigen. Unsere make_request Funktion wartet einfach eine Sekunde. Fünf davon werden in Sequenz ausgeführt:

use std::time::{Duration, Instant};

fn make_request(id: u8) {
    std::thread::sleep(Duration::from_secs(1));
    println!("request {id} done");
}

fn main() {
    let start_time = Instant::now();

    for i in 1..=5 {
        make_request(i);
    }

    println!("total time: {}", start_time.elapsed().as_secs())
}

Wie zu erwarten braucht dieses Programm fünf Sekunden. Mit async geht das besser.

Ein gescheiterter Versuch mit async

Eine naive Übersetzung in async könnte so aussehen:

use std::time::{Duration, Instant};

async fn make_request(id: u8) {
    tokio::time::sleep(Duration::from_secs(1)).await;
    println!("request {id} done");
}

#[tokio::main]
async fn main() {
    let start_time = Instant::now();

    for i in 1..=5 {
        make_request(i).await;
    }

    println!("total time: {}", start_time.elapsed().as_secs())
}

Die Funktion make_request ist nun async und verwended tokio's sleep Funktion, welche async-kompatibel ist. Leider läuft dieses Programm gleich langsam, wie das erste.

Das Problem ist, dass .await sequenziell aufgeführt wird. Normalerweise macht das Sinn, wenn man das Ergebnis einer asynchronen Operation für den nächsten Schritt benötigt. Wir möchten allerdings, dass unsere fünf Anfragen nebenläufig ausgeführt werden.

Zweiter Versuch

Für Nebenläufigkeit git es tokio::spawn. Wir ersetzen das in der Schleife und schauen was passiert:

for i in 1..=5 {
    tokio::spawn(make_request(i));
}

Dieses Programm beendet leider sofort wieder, ohne dass eine der Anfragen abschliessen konnte. Das ist leider immer noch nicht, was wir wollen.

Aller Guten Dinge sind Drei

Ähnlich wie bei Threads werden nebenläufige async Tasks abgebrochen, wenn die main Funktion fertig ist. Und wiederum ähnlich wie bei Threads können wir darauf warten, bis alle Tasks beendet sind. Zu diesem Zweck bekommt man von tokio::spawn einen JoinHandle zurück.

let mut task_handles = Vec::new();
for i in 1..=5 {
    task_handles.push(tokio::spawn(make_request(i)));
}
for handle in task_handles {
    handle.await.unwrap();
}

Das funktioniert nun endlich. Die Tasks werden nebenläufig ausgeführt und die main Funktion wartet, bis alle Tasks fertig sind. Das Programm braucht insgesamt eine Sekunde um alle Tasks auszuführen und die einzelnen Tasks werden vermutlich in zufälliger Reihenfolge fertig werden.

Ein kleines Detail

Um deine CPU Kerne maximal auszunutzen verwendet tokio standardmässig einen Threadpool und verteilt deine Tasks darauf. Nun könnte man meinen: Das hört sich an nach "threads with extra steps!"

Eine gute Beobachtung, aber das ist absichtlich so. async Tasks haben eine ähnliche benutzerfreundlichkeit wie Threads, sind aber effizienter:

  1. Sie sind kooperativ geschedulet. Das heisst, sie werden nicht zu ungünstigen Zeitpunkten von der Laufzeit unterbrochen (wie es bei Threads vorkommt).
  2. Sie haben viel weniger Overhead als Threads. Ein Thread bekommt vom Betriebsystem seinen eigenen Stack und jeder Wechsel vom einen Thread zum anderen geht per Kontextswitch über das Betriebssystem. Das ist relativ viel Zeit und Raum, den so ein Thread in Anspruch nimmt. Tasks hingegen haben keinen eigenen Stack und können ohne Kontextswitch geschedulet werden.

Und letztlich kann man tokio auch explizit sagen, dass man nur einen Thread verwenden will:

#[tokio::main(flavor = "current_thread")]
async fn main() {
    // ...
}

Auch dieses Programm brauch eine Sekunde für die fünf simulierten Netzwerkanfragen - mit nur einem Thread.

Fazit

Für gewisse Anwendungsbereiche ist async ein hervorragendes System um maximale Performance zu erreichen.