zurück zum Artikel

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

Stefan Baumgartner
Ferris Talk – Neuigkeiten zu Rust. Eine Heise-Kolumne von Rainer Stropek und Stefan Baumgartner für Rustaceans

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?

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. Schematische Infografik der Runtime für die Programmiersprache Rust.

Der Tokio Stack

(Bild: Projekt Tokio [16])

Tokios Fundament besteht aus folgenden Bausteinen:

Nicht in Abbildung 1 enthalten, aber ebenso wichtig sind Axum [18] und Warp [19] 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 [20], 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 [21]).

// 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 [22]) 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 [23].

Tokio Tasks haben ein paar interessante Eigenschaften. So sind sie zum Beispiel ‘static bound. ‘static bezeichnet eine besondere Lifetime, die angibt, dass sämtliche Referenzen für die Ausführung der Aufgabe Gültigkeit besitzen. In diesem Fall bedeutet das, dass Tasks keine Referenzen zu Daten außerhalb ihrer Einheit besitzen dürfen. Mit dem Schlüsselwort move kann der Task allerdings die Ownership über solche Daten übernehmen.

Des Weiteren sind Tasks Send bound. Send ist ein spezieller Trait in Rust, der signalisiert, dass sich die darin enthaltenen Daten sicher zu verschiedenen Threads senden lassen [24]. Nachdem zum Zeitpunkt des Absendens an die Tokio Runtime noch nicht klar ist, in welchem Thread der Task landet, müssen alle Daten innerhalb des Tasks auch Send sein.

Diese Bedingung kann zu spannenden Fehlern führen. Folgendes Beispiel "spawnt" (erzeugt) einen Task, der einen Reference Counter anlegt. Reference Counter (kurz: Rc) sind nötig, um Rust explizit mitzuteilen, dass mehrere Teile der Applikation Ownership über die darunterliegenden Daten übernehmen können. Ein Reference Counter zählt mit jedem .clone-Aufruf eine Referenz hoch, mit jedem Verschwinden der Referenz wieder herunter. Diese Referenzen können den Besitzer wechseln, lassen allerdings die Ownership über das Original unangetastet. Reference Counter sind allerdings nicht Send, das heißt, man kann sie nicht zwischen Threads hin- und herschicken.

use tokio::task::yield_now;
use std::rc::Rc;

#[tokio::main]
async fn main() {
    tokio::spawn(async {
        let rc = Rc::new("hello");

        yield_now().await;

        println!("{}", rc); // ERROR
    });
}

yield_now() zwingt die Tokio Runtime, den Task wieder zurück zum Scheduler zu schicken. Dadurch gibt es die Möglichkeit, dass die folgende Zeile mit der Ausgabe des Reference Counters auf einem anderen Thread landet. Da Reference Counter nicht Send, also nicht threadsicher sind, wirft Rust zur Übersetzungszeit einen Fehler.

Das heißt allerdings nicht, dass Reference Counter gar nicht zulässig wären. In der folgenden Anpassung ist die Nutzung von Rc in einem Block gekapselt. Rust versteht, dass sämtliche Referenzen auf Rc nach dem Block verschwinden. Es wird also nie passieren, dass die Daten auf einem anderen Thread landen. Dieses Codestück kompiliert:

use tokio::task::yield_now;
use std::rc::Rc;

#[tokio::main]
async fn main() {
    tokio::spawn(async {
        {
            let rc = Rc::new("hello");
            println!("{}", rc);
        }

        yield_now().await;
    });
}

Es gibt in der Runtime auch noch andere Workarounds für ähnliche Situationen:

Unter den Zero Cost Abstractions von Rust können diese Bedingungen zu Beginn verwirrend wirken und zu Fehlern führen. Die Funktionsweise der Runtimes zu kennen und zu verstehen, hilft auch bei der Fehlerbehandlung.

Neben äquivalenten asynchronen Implementierungen der relevanten Standardbibliothek-Structs gibt es auch Werkzeuge, die die Kommunikation zwischen Tasks vereinfachen. Dazu zählen die Channels, die in Tokio in verschiedenen Varianten vorliegen.

Channels erlauben das Verschicken von Nachrichten zwischen Sender (oder Transmitter, tx) und Empfänger (Receiver, rx). Einer der bekanntesten, da auch in der Standardbibliothek verfügbaren Channels ist "Multiple Producer, Single Consumer (MPSC)". Wie der Name sagt, können hier mehrere Produzenten Resultate an einen Empfänger schicken, wie in diesem Beispiel:

async fn main() -> io::Result<()> {
    let mut socket = TcpStream::connect("www.example.com:1234").await?;
    let (tx, mut rx) = mpsc::channel(100); // 1

    for _ in 0..10 {
       let tx = tx.clone(); // 2

        tokio::spawn(async move { // 3
            tx.send(&b"data to write"[..]).await.unwrap();
        });
    }

    drop(tx); // 4

    while let Some(res) = rx.recv().await { // 5
        socket.write_all(res).await?;
    }

    Ok(())
}

Im Detail ist der Ablauf wie folgt:

  1. Das Erzeugen eines Channels (hier mit einem Maximum an 100 gleichzeitigen Nachrichten) erstellt Sender (tx) und Empfänger (rx).
  2. Über einen .clone-Aufruf lassen sich neue Sender erzeugen. Dieser kann ebenfalls an den gleichen Empfänger rx senden. So entstehen Multiple Producers.
  3. Jede Schleifeniteration legt einen neuen Task ab. Der Task übernimmt Ownership über den neu erstellten Sender beziehungsweise Produzenten. Der Produzent schickt Daten an den Empfänger. Nach diesem Schritt bestimmen die Ownership-Regeln von Rust, dass der Speicher für Sender tx freizugeben ist.
  4. Nach dem Erzeugen aller Tasks bleibt der ursprüngliche Sender übrig. Er erhält nun die explizite Freigabe. Intern führt Rust darüber Buch, wie viele Produzenten noch an den Receiver senden können.
  5. Das hat Einfluss auf den nächsten Schritt, der die Nachrichten aller Produzenten abarbeitet. Sobald alle Produzenten freigegeben sind, gibt der Aufruf auf rx.recv() keine Ergebnisse mehr zurück. Das Programm kann also terminieren.

MPSC-Channels erlauben eine elegante Kommunikation zwischen Tasks. Dank Ownership-Regeln gibt es auch keine Überraschung, welche Produzenten noch Nachrichten schicken können. Rust nimmt seinen Anwendern hier wieder einmal viele Aufgaben ab.

Neben den bekannten MPSC-Channels gibt es noch folgende Varianten in Tokio:

Beispiele dazu gibt es in den Schulungsunterlagen zum Workshop "Netzwerkapplikationen mit Rust und Tokio [25]", der im Rahmen der betterCode() Rust im Herbst 2021 stattfand.

Ein weiterer, nicht ganz unwichtiger Punkt sind Makros, die dabei helfen, genauere Kontrolle über die Ausführung von Futures zu erlangen. Eines davon ist join!(), das die Ergebnisse aller übergebenen Futures zurückgibt.

Ein anderes Makro, das des Öfteren zum Einsatz kommt, ist select!. Hier können mehrere Futures an Tokio geschickt werden. Tokio gibt allerdings nur das Resultat der Future zurück, die als erste fertig ist. Alle anderen werden abgebrochen. Im folgenden Beispiel warten wir hier auf Ergebnisse eines Streams oder lassen die Schleife mittels Timeout unterbrechen, sollte sie zu lange dauern.

let mut stream = stream::iter(vec![1, 2, 3]);
let mut delay = time::delay_for(Duration::from_secs(1));

loop {
    tokio::select! {
        maybe_v = stream.next() => {
            if let Some(v) = maybe_v {
                println!("got = {}", v);
            } else {
                break;
            }
        }
        _ = &mut delay => {
            println!("timeout");
            break;
        }
    }
}

Als Beispiel dient eine TCP-Chat-Applikation, die sich auf GitHub einsehen lässt [26]. Sie wartet über select! darauf, ob zuerst eine Nachricht empfangen oder gesendet wurde. Eine solche Logik wäre ohne select! schwer umzusetzen. Wer hier an das Schlüsselwort select und die Goroutines aus Go denkt, liegt richtig. Vieles davon ist direkt von Go inspiriert.

Was wir hier sehen, ist nur ein kleiner Ausschnitt eines ausgesprochen umfangreichen Ökosystems, das für die Entwicklung asynchroner Programme kaum Wünsche offen lässt. Beinahe alle Webframeworks aus dem Rust-Universum bauen auf Tokio auf. Auch in Rust geschriebene JavaScript-Runtimes wie Deno setzen auf die Effektivität von Tokio.

Wer mehr über Tokio erfahren will, dem sei eine Vorstellung von den Tokio-Entwicklern bei der letzten AWS-Re:Invent-Konferenz [27] ans Herz gelegt. In einer Sitzung des Rust Linz Meetups stelle ich Tokio und Warp vor und zeige, wie man damit einen Websocket-Chat implementiert [28]. Zudem empfiehlt sich ein Blick in "Mini Redis". Hier zeigen die Tokio-Entwickler, wie man einen Redis-Klon implementiert [29], der nicht nur hochperformant, sondern auch richtig elegant geschrieben ist.

Tokio eignet sich für vieles, allerdings gibt es auch Aufgaben, bei denen man mit anderen Werkzeugen besser bedient ist. Das stellt die Projekt-Website detailliert dar [30]. Für alle anderen asynchronen Aufgaben hat sich Tokio mittlerweile als De-facto-Standard etabliert und ist somit eine sehr sichere Wahl.

Ferris Talk – Neuigkeiten zu Rust. Kolumnist:
Stefan Baumgartner, Dynatrace.at, Rust Meetup Linz, Autor der Ferris Talks, der Kolumne über die Programmiersprache Rust bei Heise Developer

Stefan Baumgartner, Autor von Ferris Talk #5

lebt und arbeitet als Software-Architekt und Entwickler bei Dynatrace im österreichischen Linz mit Schwerpunkt auf Webentwicklung, Serverless und Cloud-basierte Architekturen.

Für den Smashing Magazine Verlag veröffentlichte er 2020 mit “TypeScript in 50 Lessons” sein zweites Buch [31], seine Onlinepräsenz fettblog.eu [32] enthält Artikel, Tutorials und Guides zum Thema TypeScript, Rust, React, und Software Engineering im Allgemeinen.

Stefan organisiert Meetups und Konferenzen, wie Stahlstadt.js [33], die DevOne, ScriptConf, Rust Meetup Linz [34], und das legendäre Technologieplauscherl [35]. Außerdem ist er regelmäßig Gastgeber im Working Draft [36], dem deutschsprachigen Podcast über Webtechnologien. Wenn noch ein wenig Freizeit bleibt, genießt er italienische Pasta, belgisches Bier und britischen Rock.

(sih [37])


URL dieses Artikels:
https://www.heise.de/-6341018

Links in diesem Artikel:
[1] https://www.heise.de/hintergrund/Ferris-Talk-1-Iteratoren-in-Rust-6175409.html
[2] https://www.heise.de/hintergrund/Ferris-Talk-2-Abstraktionen-ohne-Mehraufwand-Traits-in-Rust-6185053.html
[3] https://www.heise.de/hintergrund/Ferris-Talk-3-Neue-Rust-Edition-2021-ist-da-mit-Disjoint-Capture-in-Closures-6222248.html
[4] https://www.heise.de/hintergrund/Ferris-Talk-4-Asynchrone-Programmierung-in-Rust-6299096.html
[5] https://www.heise.de/hintergrund/Ferris-Talk-5-Tokio-als-asynchrone-Laufzeitumgebung-ist-ein-Fast-Alleskoenner-6341018.html
[6] https://www.heise.de/hintergrund/Ferris-Talk-6-Ein-neuer-Trick-fuer-die-Formatstrings-in-Rust-6505377.html
[7] https://www.heise.de/hintergrund/Ferris-Talk-7-Vom-Ungetuem-zur-Goldrose-eine-kleine-Rust-Refactoring-Story-6658167.html
[8] https://www.heise.de/hintergrund/Ferris-Talk-8-Wasm-loves-Rust-WebAssembly-und-Rust-jenseits-des-Browsers-7064040.html
[9] https://www.heise.de/hintergrund/Ferris-Talk-9-Vom-Builder-Pattern-und-anderen-Typestate-Abenteuern-7134143.html
[10] https://www.heise.de/hintergrund/Ferris-Talk-10-Constant-Fun-mit-Rust-const-fn-7162074.html
[11] https://www.heise.de/hintergrund/Ferris-Talk-11-Memory-Management-Speichermanagement-in-Rust-mit-Ownership-7195773.html
[12] https://www.heise.de/hintergrund/Ferris-Talk-12-Web-APIs-mit-Rust-erstellen-7321340.html
[13] https://www.heise.de/hintergrund/Ferris-Talk-13-Rust-Web-APIs-und-Mocking-mit-Axum-7457143.html
[14] https://www.heise.de/hintergrund/Ferris-Talk-14-Rust-bekommt-endlich-asynchrone-Methoden-in-Traits-8929334.html
[15] https://www.heise.de/hintergrund/Ferris-Talk-15-Bedingte-Kompilierung-in-Rust-9337115.html
[16] https://tokio.rs/
[17] https://docs.rs/tokio/latest/tokio/#feature-flags
[18] https://github.com/tokio-rs/axum
[19] https://github.com/seanmonstar/warp
[20] https://www.heise.de/hintergrund/Ferris-Talk-4-Asynchrone-Programmierung-in-Rust-6299096.html
[21] https://www.heise.de/hintergrund/Ferris-Talk-4-Asynchrone-Programmierung-in-Rust-6299096.html
[22] https://www.heise.de/hintergrund/Ferris-Talk-3-Neue-Rust-Edition-2021-ist-da-mit-Disjoint-Capture-in-Closures-6222248.html
[23] https://www.heise.de/Datenschutzerklaerung-der-Heise-Medien-GmbH-Co-KG-4860.html
[24] https://doc.rust-lang.org/std/marker/trait.Send.html
[25] https://fettblog.eu/slides/network-applications-with-tokio-and-rust/
[26] https://github.com/ddprrt/tokio-tcp-chat
[27] https://www.youtube.com/watch?v=MZyleK8elP
[28] https://www.youtube.com/watch?v=fuiFycJpCBw
[29] https://github.com/tokio-rs/mini-redis
[30] https://tokio.rs/tokio/tutorial
[31] https://typescript-book.com/
[32] https://fettblog.eu/
[33] https://stahlstadt.js.org/
[34] https://rust-linz.at/
[35] https://technologieplauscherl.at/
[36] https://workingdraft.de/
[37] mailto:sih@ix.de