zurück zum Artikel

Ferris Talk #4: Asynchrone Programmierung in Rust

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

Futures und Asynchronität stehen im Fokus der vierten Folge der Ferris Talks: Rust bietet Syntax und Primitives für asynchrones Programmieren.

In der aktuellen Kolumne geht es um die Grundlagen der asynchronen Programmierung in Rust. Auf den ersten Blick funktioniert sie ganz ähnlich wie mit JavaScript oder C#, ist aber im Detail – wieder einmal – deutlich anders. Wie bei vielen Elementen in Rust ist es sinnvoll, sich mit den Unterschieden im Detail auseinanderzusetzen, beispielsweise für den Austausch mit dem Compiler und zum Verständnis der zahlreichen Fehlermeldungen.

Ferris Talks – die Kolumne für Rustaceans

Computer sind vor allem dann schnell, wenn sie viele Dinge gleichzeitig erledigen können – entweder verteilt über mehrere (logische wie physische) CPUs oder auf einer einzigen CPU, wenn man diese besonders gut ausnutzt.

Für den ersten Fall gibt es vom Betriebssystem unterstützte Threads, mit denen Entwicklerinnen und Entwickler Teile des Programmcodes gezielt auf eine CPU schicken können, um später die Ergebnisse wieder zusammenzuführen. Rusts Standardbibliothek stellt die dafür notwendigen Strukturen und Methoden bereit.

Folgendes Programm soll zufällige Lottozahlen für unterschiedliche Lottosysteme ausgeben. Das Beispiel ist konstruiert, enthält aber alles, was für einen Threading-Rundumschlag nötig ist.

fn main() {
    let lottos = Mutex::new(Vec::<Lotto>::new()); // 1
    let lottos = Arc::new(lottos); // 2
    let mut handles = Vec::new();
    let pairs = [(6, 45), (5, 50), (2, 12)];

    for (take, from) in pairs {
        let lottos = Arc::clone(&lottos); // 3
        let handle = thread::spawn(move || { // 4
            let lotto = Lotto::new(take, from);
            lottos.lock().unwrap().push(lotto); // 5
        });

        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap(); // 6
    }

    for lotto in lottos.lock().unwrap().iter() {
        println!("{:?}", lotto);
    }
}

Funktionsweise des Programms, Zeile für Zeile aufgeschlüsselt:

    Den Ausgangspunkt bilden beispielsweise Mutexes, die Speicher auszeichnen, den wiederum mehrere Threads (einander ausschließend) bearbeiten können.Der Arc oder Atomic Reference Counter zählt mit, wie viele Threads nun tatsächlich darauf zugreifen müssen.Mit jedem Clone-Aufruf führt der Atomic Reference Counter Buch, wie viele Klone auf die Daten zugreifen dürfen. Jeder Klon erhöht den internen Zählerstand um einen Punkt. Bei der Freigabe verlassen die Klone den Scope und der Counter zählt entsprechend wieder nach unten, bis alle Klone verschwunden sind und die Originaldaten freigegeben werden. Jeder Klon lässt sich in einen Thread packen und die Ownership wird dabei mit übergeben. So lassen sich die Ownership-Regeln von Rust umsetzen.Um die Ownership der angelegten Variablen an den Thread zu übergeben, ist eine move-Closure notwendig, wie Rainer Stropek bereits in der letzten Ausgabe ausführlich erklärt hat [1]. Hier legen wir einen neuen Thread an, der die Tasks ausführen soll.Zeile 5 sperrt den Mutex und ermöglicht es, die geteilte Datenstruktur mit dem Resultat zu füllen.Die letzte Zeile führt alle Threads zusammen. Jetzt lassen sich im nächsten Schritt die Ergebnisse auslesen.

Was hier in knappen Schritten beschrieben ist, hat Katharina Fey auf der letzten betterCode() Rust ausführlich erklärt [16]. Wir verweisen also an dieser Stelle gerne auf eine Aufzeichnung ihres Vortrags "Concurrency, Data Correctness and Rust [17]".

Rust bietet für das Verteilen von Aufgaben auf mehrere CPUs über Threads die nötigen Kontrollstrukturen in der Standardbibliothek. Allerdings ist diese Art der Aufgabenteilung nicht immer optimal. Im folgenden Beispiel gilt es einen HTTP-Request abzuschicken – das Programmstück $(LEhttps://www.oreilly.com/library/view/programming-rust-2nd/9781492052586/:ist dem Buch .

fn request(host: &str, port: u16, path: &str)-> 
  std::io::Result<String> {
    let mut socket = 
      net::TcpStream::connect((host, port))?; // Hier

    let request = 
      format!("GET {} HTTP/1.1\r\nHost: {}\r\n\r\n", path, host);
    socket.write_all(request.as_bytes())?; // Hier
    socket.shutdown(net::Shutdown::Write)?;

    let mut response = String::new();
    socket.read_to_string(&mut response)?; // Hier

    Ok(response)
}

Zuerst ist eine TCP-Verbindung zu öffnen, dann geht die Anfrage in einem knapp gehaltenen HTTP-Request raus und wartet darauf, dass die Ergebnisse zurückkommen. Diese Wartezeit mag uns Menschen nicht so lang erscheinen, im Maßstab des CPU-Takts handelt es sich allerdings beinahe um Ewigkeiten. Zeitfenster jedenfalls, in denen sich die CPU regelrecht langweilt und eigentlich schon für andere Dinge frei sein sollte. Was wir erreichen wollen, ist dreierlei:

    Die optimale Ausnutzung von Threads und CPUs,eine Schreibweise, die an die herkömmliche synchrone Programmierung heranreicht sowiedie Möglichkeit, das Programmiermodell in unterschiedlichen Szenarien effektiv einzusetzen.

Um diese Ziele zu erreichen, fällt einiges an Verwaltungsaufwand an. Rust muss den bestehenden Programmcode in kleine Einheiten zerlegen, um im Falle einer Wartezeit eine dieser Einheiten vor der Fortführung der nächsten auf den Thread zu legen. Wartet man also beim Aufbau der TCP-Verbindung im obigen Beispiel auf eine Antwort (Einheit A1), lässt sich gleichzeitig eine komplett unabhängige Einheit B1 von einem anderen Teil der Software ausführen, bevor auf der dann hergestellten Verbindung Daten zu schreiben sind (Einheit A2).

Neben der Zerteilung in unterschiedliche Ausführungseinheiten ist allerdings auch eine Laufzeitumgebung nötig, die das Verteilen der Tasks übernimmt.

Es folgt ein Blick darauf, wie Rust mit den beiden Problemen umgeht.

Um die als Task bezeichneten Einheiten zu erstellen, hat Rust das Konzept der Futures angenommen und in einem Future Trait abstrahiert. Die Future beschreibt einen zukünftigen Wert (daher auch der Name), bei dem nachzufragen ist, ob es ihn bereits gibt.

trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> 
      Poll<Self::Output>;
}

Der Output legt dabei den Typ dieses Wertes fest, die poll-Methode kommt mit einem Context daher (dazu später mehr) und gibt eine Ausprägung des Poll-Enums zurück.

enum Poll<T> {
    Ready(T),
    Pending
}

Dieses Poll-Enum hat zwei mögliche Ausprägungen: Entweder hat die Operation, die über die Future abstrahiert wird, schon einen Wert hervorgebracht, dann lässt er sich jetzt zurückgeben. Oder aber man wartet noch, bis die Operation im Hintergrund abgearbeitet worden ist (Pending). Wichtig ist, dass bei der Ausführung der poll-Methode sofort ein Wert zurückkommen soll.

Eine Ausführungslaufzeitumgebung für Futures dieser Art kann jetzt kontinuierlich alle bestehenden Futures durchlaufen und so lange den Status abklappern, bis alle Futures von Pending auf Ready geschaltet haben. Man kann sich allerdings vorstellen, dass das recht ineffektiv ist. Effektiver wäre es, einen Waker zu benachrichtigen, der bei einem Ereignis wie “TCP Socket steht!” oder “Timer Event vom Betriebssystem!” die Laufzeitumgebung regelrecht aufweckt, um nach neuen Werten zu fragen.

Um also aus dem Codestück eine Funktion zu machen, die sich optimal auf einen Thread legen lässt und von solch einer Laufzeitumgebung ausgeführt wird, muss sie eine Future zurückgeben.

Aus

fn read_to_string(&mut self, buf: &mut String) -> Result<usize>;

wird also

fn read_to_string(&mut self, buf: &mut String) -> 
  Future<Output = Result<usize>>;

Oder mit dem nötigen Syntaxzucker:

async fn read_to_string(&mut self, buf: &mut String) -> 
  Result<usize>;

Beim Aufruf dieser Funktion wird nur eine Future konstruiert und zurückgegeben. Erst wenn wir mit dem Polling anfangen, wird auch tatsächlich Programmcode ausgeführt. Für das Polling gibt es auch etwas Syntax, um das Ganze zu versüßen. Mit einem angehängten .await geben wir Rust bekannt, dass diese Future bitte dem Executor zu übergeben ist.

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 [18].

Apropos Executor und Ausführung, wie geht das eigentlich? Um das zu veranschaulichen, bietet es sich an, eine dieser Futures zu implementieren. Im folgenden Beispiel gilt es einen Delay von einer gewissen Anzahl an Millisekunden als Future zu abstrahieren, dem "Hello World" der asynchronen Programmierung. Das Ziel war eigentlich, einen bestehenden Thread optimal auszunutzen und nicht im Hintergrund neue Threads als Ausweichmöglichkeit zu erzeugen. Dazu kommen Ereignisse des Betriebssystems ins Spiel, die an dieser Stelle der Einfachheit halber außen vor bleiben. Zur Veranschaulichung genügt die vereinfachte Darstellung allemal.

struct Delay {
    when: Instant,
}

impl Future for Delay {
    type Output = &'static str;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>)
        -> Poll<&'static str> {
        if Instant::now() >= self.when {
            Poll::Ready("done")
        } else {
            let waker = cx.waker().clone();
            let when = self.when;

           thread::spawn(move || {
                let now = Instant::now();

                if now < when {
                    thread::sleep(when - now);
                }
                waker.wake();
            });
            Poll::Pending
        }
    }
}

Gegeben ist ein Struct namens Delay, das mit einem Instant befüllt wird. Dieses Struct implementiert nun den Future Trait. Als Ausgabe gibt es einen Status-String. In der poll-Methode ist Folgendes der nächste Schritt: Sobald die vorgegebene Zeitspanne vergangen ist (der aktuelle Zeitpunkt ist also größer als oder gleich wie der angegebene), dann ist die Funktion mit Poll::Ready bereit und gibt einen Rückgabewert des bestimmten Typs zurück. Wie bereits gesagt, retourniert eine Future Ergebnisse sofort.

Falls die nötige Zeitspanne noch nicht vergangen ist, bietet sich folgendes Vorgehen an: einen Hintergrund-Thread absetzen, den wir für die angegebene Zeit schlafen lassen. Wenn der Thread aus seinem Schlaf zurückkommt, ruft er den über den Context mitgebrachten Waker auf. Wie erwähnt würde das in einer echten Runtime über Runtime-interne Konstrukte funktionieren, die bei Ereignissen des Betriebssystem-Timers reagieren.

Im Hintergrund passiert Folgendes:

    Mit der Future besteht eine Ausführungseinheit, die sich an den Executor einer asynchronen Laufzeitumgebung übergeben lässt.Die Übergabe dieser Einheit findet meist über einen Spawner statt.Dieser Spawner legt die Future auf die Verarbeitungs-Queue des Executors.Dort wird gepollt. Die Future kann nun bereits einen Wert über den Ready State zurückgeben, sofern es schon einen gibt.Lässt das Ergebnis allerdings noch auf sich warten, ist der Status Pending zurückzugeben, und der Executor führt so lange weiter Tasks aus, bis die Future bekannt gibt, dass sie wieder abrufbar ist.Der Executor pollt die Future, hat jetzt einen Wert und gibt ihn an das Programm zurück.

Entwickler und Entwicklerinnen brauchen von all dem nichts zu wissen, denn für sie reicht es, wenn sie bekanntgeben, über welche Laufzeitumgebung sie arbeiten wollen. Mit .await müssen sie dann auf die Ergebnisse warten.

#[tokio::main]
async fn main() {
    let when = Instant::now() + Duration::from_millis(10);
    let future = Delay { when };

    let out = future.await;
    assert_eq!(out, "done");
}

Im obigen Beispiel kommt Tokio zum Einsatz [19], eine populäre asynchrone Laufzeitumgebung. Die Main-Funktion lässt sich mit einem entsprechenden Makro aufpolieren, und in Zeile 3 ruft das Programm die Delay Future mit .await auf.

Hier ist ein Moment zum Innehalten erreicht, denn ein Detail lässt grübeln: Da ist ja nicht nur die eine Future, die aufgerufen wird. Auch die Main-Funktion wird nun mit dem async-Schlüsselwort ausgezeichnet. Das heißt, dass auch diese Funktion nun eine Future zurückgibt. Aber welche bloß?

Kommen nun async/await für die Auszeichnung der Funktionen zum Einsatz, behandelt Rust diese Await Statements wie eine Future, die einen Zustandsautomaten implementiert. Dazu wird bei jedem .await ein Schnitt gemacht. Alles vor future.await ist ein Schritt im Zustandsautomaten, und alles bis zum nächsten .await ist ein weiterer Schritt. Das .await selbst wird über die Abfrage der eigentlichen Future abgebildet und gibt entsprechend ein Poll-Ergebnis zurück.

Den Zustandsautomaten kann man sich als automatisch generierten Code vorstellen, und solch eine automatisch generierte Future kann folgendermaßen aussehen:

enum MainFuture {
    // Initialized, never polled
    State0,
    // Waiting on `Delay`, i.e. the `future.await` line.
    State1(Delay),
    // The future has completed.
    Terminated,
}

Die Implementierung dazu wäre:

impl Future for MainFuture {
    type Output = ();

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>)
        -> Poll<()>
    {
        use MainFuture::*;

        loop {
            match *self {
                State0 => {
                    let when = Instant::now() +
                        Duration::from_millis(10);
                    let future = Delay { when };
                    *self = State1(future);
                }
                State1(ref mut my_future) => {
                    match Pin::new(my_future).poll(cx) {
                        Poll::Ready(out) => {
                            assert_eq!(out, "done");
                            *self = Terminated;
                            return Poll::Ready(());
                        }
                        Poll::Pending => {
                            return Poll::Pending;
                        }
                    }
                }
                Terminated => {
                    panic!("future polled after completion")
                }
            }
        }
    }
}

Wie lobenswert ist dagegen doch der Syntaxzucker! Allerdings sieht man recht eindeutig, dass es sich auch bei den async/await-Auszeichnungen "nur" um gewöhnliche Futures handelt, dem ursprünglichen Konzept treu bleibend.

Bevor man jedoch eine Future ausführen kann, muss diese sich frei im Speicher bewegen können. Sobald es zur Ausführung kommt, darf sie nicht mehr im Speicher bewegt werden, weil der Ausführungs-Thread Zugriff auf die Daten braucht. Der Pin<T> Smart Pointer garantiert, dass die Future an ihrem Platz bleibt.

Im Vergleich zu dem früheren Beispiel mit dem "billigen" HTTP-Request sieht eine asynchrone Version folgendermaßen aus:

async fn request(host: &str, port: u16, path: &str) -> 
  std::io::Result<String> {
    // from Tokio (1):
    let mut socket =  net::TcpStream::connect((host, port)).await?;

    let request = 
      format!("GET {} HTTP/1.1\r\nHost: {}\r\n\r\n", path, host);
    socket.write_all(request.as_bytes()).await?; // from Tokio (2)
    socket.shutdown(net::Shutdown::Write)?;

    let mut response = String::new();
    socket.read_to_string(&mut response).await?; // from Tokio (3)

    Ok(response)
}

Das async-Schlüsselwort macht aus "request" eine Funktion, die Futures baut. Die blockierenden Varianten aus der Standardbibliothek werden gegen die nicht-blockierenden Äquivalente aus einer asynchronen Laufzeitumgebung ausgetauscht. Jedes await-Statement trennt Codeblöcke als Zustände im Automaten. Die IO Structs von Tokio reagieren dabei auf Betriebssystemereignisse.

Das Ausführen des obigen Programmstücks spielt sich nun wie folgt ab:

    Wenn wir request aufrufen, kommt eine Future A heraus, die es an eine asynchrone Laufzeitumgebung weiterzugeben gilt.Diese Umgebung pollt Future A. Damit wird die Funktion TcpStream::connect aufgerufen (1), die Future B zurückgibt.Future B wird gepollt und es passiert zunächst offenbar nichts. Tatsächlich kommt allerdings Poll::Pending zurück und Future B stellt sicher, dass sie wieder aufgerufen wird, sobald ein TCP-Socket steht.Future A gibt auch Poll::Pending zurück, somit steht die Runtime.Irgendwann ist Future B bereit und der TCP-Socket steht. Future B weckt die Runtime auf. Future A wird gepollt, die pollt wiederum Future B, bekommt jetzt allerdings ein Poll::Ready mitsamt TCP Socket als Ergebnis zurück. Damit erreicht Future A den nächsten Zustand, und kann weiter Code ausführen.write_all wird aufgerufen, und gibt eine Future C zurück (2).Nun beginnt das gleiche Spiel: Future C gibt Poll::Pending zurück, und die Runtime stoppt. Nach einiger Zeit wird die Runtime aufgeweckt. A wird gepollt, C wird gepollt. C retourniert Poll::Ready, Future A erreicht den nächsten Zustand.Read_to_string wird aufgerufen (3) und Future D kommt als Antwort zurück.Future D wird gepollt. Ab hier gibt es nichts Neues zu berichten: Der nun bekannte Vorgang wiederholt sich.

Am Ende des Prozesses erreicht auch Future A den Zustand der Bereitschaft (Ready) und es sind keine weiteren Durchgänge mehr notwendig. Unsere request-Funktion ist beendet, allerdings diesmal in einer asynchronen Laufzeitumgebung.

Im obigen Beispiel ist zunächst kein direkter Nutzen davon erkennbar. Im Gegenteil, das Einführen einer asynchronen Laufzeitumgebung führt sogar zu zusätzlichem Overhead. Spannend wird es hingegen ab dem Punkt, wenn die Wartezeiten sich nutzen lassen und das Absetzen weiterer Requests möglich ist: Dann landet der nächste Aufbau einer TCP-Verbindung genau zwischen den Ausführungseinheiten der geschilderten Funktion, und die Kapazitäten der CPUs lassen sich vollständig ausschöpfen.

Der kleine Exkurs in Sachen Futures und Asynchronität in Rust soll vor allem eines veranschaulichen: Rust bietet Syntax und Primitives, um für asynchrone Programmierung gerüstet zu sein. Allerdings bedarf es einer Laufzeitumgebung, die zusätzlich zu installieren ist, um tatsächlich etwas damit anzufangen.

Diese Laufzeitumgebung ist nicht in Rust enthalten. Tokio, async-std oder glommio sind einige Beispiele unterschiedlicher Implementierungen asynchroner Laufzeitumgebungen. Entwickler und Entwicklerinnen haben hier die Qual der Wahl.

Tokio gilt als populärste asnychrone Runtime und ist der Platzhirsch, wenn es hauptsächlich um Netzwerkapplikationen geht. Wer sich mit Microservices, Web-Frameworks und Cloud-Anwendungen auseinandersetzt, kommt an Tokio und dem gesamten Stack nicht vorbei. Async-std hat sich zum Ziel gesetzt, die zugänglichste asynchrone Runtime zu sein [20], die vollständige Äquivalente zur bestehenden Rust-Standardbibliothek anbietet. Glommio ist noch relativ neu in der Riege der asynchronen Rust-Runtimes und baut auf die Linux-Kernel-Schnittstelle io_uring auf. Für andere Architekturen ist das ungeeignet, aber dafür auf Linux potenziell schneller.

In der Theorie funktioniert der hier geschriebene Programmcode mit jeglicher Runtime, solange man nicht die speziellen Features der jeweiligen Bibliotheken nutzt. Das erlaubt es auch, die für den eigenen Anwendungsfall beste Laufzeitumgebung auszuwählen. Auf limitierter Hardware ist das eventuell eine andere als für Netzwerkanwendungen, die in der Cloud laufen.

Apropos: Für das Cloud-Szenario ist Tokio prima geeignet. Dazu konnten Interessierte sich im Rahmen der betterCode() Rust umfassend informieren [21], und wenn noch Fragen offen sind, gerne auch bei mir privat [22]. Oder ihr geduldet euch bis zu einem der folgenden Ferris Talks, wenn wir uns die Tokio-Runtime genauer ansehen.

Ferris Talk – Neuigkeiten zu Rust. Die Kolumnisten:
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 #4

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 [23], seine Onlinepräsenz fettblog.eu [24] enthält Artikel, Tutorials und Guides zum Thema TypeScript, Rust, React, und Software Engineering im Allgemeinen.

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

(sih [29])


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

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://rust.bettercode.eu/veranstaltung-13467-se-0-concurrency-data-correctness-and-rust.html
[17] https://vimeo.com/ondemand/bettercoderust2021/
[18] https://www.heise.de/Datenschutzerklaerung-der-Heise-Medien-GmbH-Co-KG-4860.html
[19] https://tokio.rs
[20] https://async.rs/
[21] https://rust.bettercode.eu/veranstaltung-13654-se-0-netzwerk-applikationen-mit-dem-tokio-stack.html
[22] https://fettblog.eu
[23] https://typescript-book.com/
[24] https://fettblog.eu/
[25] https://stahlstadt.js.org/
[26] https://rust-linz.at/
[27] https://technologieplauscherl.at/
[28] https://workingdraft.de/
[29] mailto:sih@ix.de