Ferris Talk #5: Tokio als asynchrone Laufzeitumgebung ist ein Fast-Alleskönner

Der Tokio Stack hat sich als De-facto-Standard für asynchrones Programmieren in Rust etabliert. Für welche Aufgaben ist er gut geeignet, für welche weniger?

In Pocket speichern vorlesen Druckansicht 16 Kommentare lesen
Ferris Talk – Neuigkeiten zu Rust. Eine Heise-Kolumne von Rainer Stropek und Stefan Baumgartner für Rustaceans
Lesezeit: 14 Min.
Von
  • Stefan Baumgartner
Inhaltsverzeichnis

Die letzte Ausgabe des Ferris Talks hat ausführlich die Eigenheiten asynchroner Programmierung in Rust beleuchtet. Konstrukte wie Futures, async/await und Polling standen im Fokus. Dabei kam ein Thema immer wieder zum Vorschein, an dem man bei asynchroner Programmierung in Rust einfach nicht vorbeikommt: die Laufzeitumgebung.

Wenn es um das Ausführen asynchroner Aufgaben geht, nimmt Rust in der Welt der Programmiersprachen eine Sonderstellung ein. Anders als Go, C# oder JavaScript gibt Rust Entwicklern und Entwicklerinnen nur Syntax und Primitive zur asynchronen Programmierung an die Hand, allerdings keine Möglichkeit, die daraus entstehenden Futures auszuführen.

Ferris Talks – die Kolumne für Rustaceans

Nach dem Motto "No Size Fits All" hat das Rust-Team sich dazu entschlossen, die Auswahl der Laufzeitumgebung für asynchronen Code den Developern zu überlassen, um sie nicht mit einem einzigen Concurrency-Modell zu bevormunden (wie es bei Go der Fall ist) oder sie per Design zu limitieren (JavaScript wäre dafür ein Beispiel). Die Anforderungen auf beispielsweise Mikrocontrollern sind andere als für hochfrequentierte Netzwerk-Proxies.

Eine der populärsten Laufzeitumgebungen für asynchrones Rust ist Tokio, der ursprüngliche Ideeninkubator dafür. Mozilla hat ihn mit großem Engagement vor einigen Jahren aus der Taufe gehoben. Mittlerweile liegt der Fokus von Tokio eindeutig auf Netzwerkapplikationen, weshalb es im Bereich der Webapplikationen, Microservices und Cloud-Infrastruktur äußerst beliebt ist. Die Hauptkontributoren sind mittlerweile auch bei Amazon Web Services (AWS) gelandet, was dem Projekt zusätzlich Enterprise-Fahrwasser gibt.

Wer Tokio sagt, meint ein ganzes Ökosystem, wie die Abbildung veranschaulicht.

Der Tokio Stack

(Bild: Projekt Tokio)

Tokios Fundament besteht aus folgenden Bausteinen:

  • Mio steht für "Metal IO" und abstrahiert grundlegende Betriebssystem-Events (wie epoll, kqueue, IOCP) zur einfacheren Verwendung in anderen Bibliotheken. Prägnant sind hier das Typsystem von Rust und die damit entstehenden Zero-Cost Abstractions.
  • Die Tokio Runtime ist die tatsächliche Laufzeitumgebung, die auf Mio aufbaut. Die Runtime implementiert Timer, Exekutoren, Polling Queues und Scheduler, um Futures in die Ausführung zu schicken. Dabei gibt es unterschiedliche Features, die sich mit Hilfe von Feature Flags ein- und ausschalten lassen. Eines der Features ist die Wahl zwischen der Single-Threaded-Ausführung (ähnlich wie bei JavaScript) oder dem Ausführen auf mehreren Threads (ähnlich wie bei Go), wobei im zweiten Fall ein Work-Stealing-Algorithmus die Implementierung vornimmt.
  • Hyper und Tonic sind auf Tokio aufbauende korrekte Implementierungen von HTTP beziehungsweise gRPC und damit entscheidend für die Entwicklung von Cloud-Anwendungen. Wo bislang Tokio noch als General-Purpose-Runtime fungiert, haben Hyper und Tonic konkrete Anwendungsgebiete wie Web-Applikationen und Microservices.
  • Beide Crates bauen dabei auf Tower auf, einer Abstraktionsschicht für Request/Response. Damit löst Tokio Dinge wie Load Balancing, Backpressure, Timeouts und einiges mehr. Die darin enthaltenen Traits lassen sich von eigenen Structs implementieren. Eigene Services spielen dadurch gut mit dem Rest des Ökosystems zusammen.
  • Zu guter Letzt gibt es noch Hilfsbibliotheken für sauberes Monitoring und das threadsichere Senden von Bytes.

Nicht in Abbildung 1 enthalten, aber ebenso wichtig sind Axum und Warp als Frameworks für Webanwendungen.

Ab hier widmet sich der Artikel den Grundlagen der Tokio Runtime. Auch wer sich inhaltlich auf anderen Abstraktionsebenen bewegt, benötigt dieses Wissen, um die Ausführung der Futures zu verstehen. Das folgende Listing beschreibt ein "Hello World" in Tokio:

use tokio::{
    io::{self, AsyncWriteExt},
    net::TcpListener,
};

#[tokio::main] // 1
async fn main() -> io::Result<()> {
    let listener = TcpListener::bind("localhost:8001").await?; // 2

    loop {
        let (mut socket, addr) = listener.accept().await?;
        println!("Listening to {}", addr);

        tokio::spawn(async move { socket.write_all("Hello World!\n\r".as_bytes()).await }); // 3
    }
}

Das kurze Programm erzeugt einen neuen TCP Listener und hört auf ankommende Verbindungen. Sobald eine Verbindung aufgebaut ist, lässt sich eine "Hallo, Welt!"-Nachricht absetzen. Im Detail ist das Vorgehen wie folgt:

// 1: Gleich zu Beginn fällt das dekorierende tokio::main-Makro auf. Dieses Makro ist ausschließlich auf der main-Methode anwendbar und erzeugt eine Tokio Runtime, die je nach Feature Flags unterschiedliche Ausprägungen hat. Die darauf folgende, asynchrone main-Methode (dabei gilt es, das async-Schlüsselwort zu beachten) wird in ein Future gepackt. Das Makro erzeugt dabei Code, der ungefähr so aussieht:

fn main() {
    let mut rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        // Inhalt der main-Methode
    })
}

Wir erinnern uns an Ferris Talk #4, der Befehl block_on startet auch das Abfragen der Futures.

// 2: Für grundlegende IO-Konstrukte liefert Tokio zur Standardbibliothek äquivalente Strukturen. In diesem Fall ersetzt ein tokio::net::TcpListener den regulären std::net::TcpListener. Das Interface ist identisch, allerdings blockieren die Aufrufe nicht, sondern liefern Futures, auf die mit .await zu warten ist (siehe Ferris Talk #4).

// 3: Tokio Tasks sind sogenannte "Green Threads". Das bedeutet, dass nicht das Betriebssystem das Ausführen der Tasks auf Prozessorkernen steuert, sondern eine Laufzeitumgebung sich um die Verteilung kümmert. Das passiert zum Beispiel an der Stelle, wo tokio::spawn einen asynchronen Block (Future!) an die Tokio Runtime sendet. Mit dem Schlüsselwort move übernimmt der darin enthaltene Block die Ownership über alle nötigen Variablen (siehe Ferris Talk #3) und kann so über den Socket Daten schicken. Das Interface erinnert an das Spawnen eines neuen Threads. Tatsächlich übernimmt aber die Tokio Runtime die Verteilung der Aufgaben. Als Entwickler oder Entwicklerin weiß man nicht, auf welchem Thread der Task schlussendlich landet.

Der große Unterschied zwischen dem Spawning eines Tasks und der regulären Ausführung ist, dass an dieser Stelle die Schleife nicht blockiert wird und somit neue Verbindungen annehmen kann. Der Task innerhalb von task::spawn wird auf eine Task Queue gelegt, genauso wie die Zeile listener.accept() der nächsten Schleifeniteration. Von Tokio erzeugte Worker Threads ziehen Aufgaben von dieser Task Queue und arbeiten sie ab. Events des Betriebssystems entscheiden nun, welcher Task als erstes zur Abarbeitung bereit steht.

Empfohlener redaktioneller Inhalt

Mit Ihrer Zustimmmung wird hier ein externes YouTube-Video (Google Ireland Limited) geladen.

Ich bin damit einverstanden, dass mir externe Inhalte angezeigt werden. Damit können personenbezogene Daten an Drittplattformen (Google Ireland Limited) übermittelt werden. Mehr dazu in unserer Datenschutzerklärung.