Ferris Talk #4: Asynchrone Programmierung in Rust
Futures und Asynchronität stehen im Fokus der vierten Folge der Ferris Talks: Rust bietet Syntax und Primitives für asynchrones Programmieren.
- Stefan Baumgartner
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.
Warum asynchrones Rust?
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. Wir verweisen also an dieser Stelle gerne auf eine Aufzeichnung ihres Vortrags "Concurrency, Data Correctness and Rust".
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.