Mit den Themen asynchrone Programmierweise und Futures bin ich selbst schon vor Jahren bei C# und Javascript in Berührung gekommen, aber habe damals kein ernsthaftes Verständnis dafür erlangt. Jetzt bin ich bei Rust wieder mit diesen Themen in Berührung gekommen und habe einiges mehr an Informationen dazu gelesen. Deshalb folgt hier die Ordnung meiner Gedanken, die gern auch als Erläuterung zum Thema für andere dienen soll.
Prozesse, Threads und Tasks zur Arbeitsverteilung
Beim Aufruf einer Funktion geht man im ersten Ansatz immer davon aus, dass diese so lange rechnet, bis sie das Ergebnis ermittelt hat. Durch die Interaktion mit externen Komponenten (Netzwerk, Festplatten, Peripheriegeräte) kann es aber dazu kommen, dass die Arbeit einer Funktion an einer Stelle nicht fortgesetzt werden kann. Der einfachste Lösungsansatz für diese Situation ist Warten (technisch spinning genannt). Aber dann liegt im schlimmsten Fall die CPU brach, obwohl andere Prozesse gern die Rechenleistung nutzen würden.
Innerhalb des Betriebssystemkerns ist dieses Problem schon lange gelöst: der Prozess-Scheduler sorgt dafür, dass Prozesse, die nicht rechnen können, in einen Wartezustand versetzt werden und andere Prozesse die CPU zugeteilt bekommen. Dieses Zurückstellen (engl. suspending) der Prozesse ist auch grundsätzlich für die Verteilung der CPU-Nutzung notwendig, um Multiprozesssysteme ermöglichen zu können.
Die Frage der CPU-Teilung gibt es aber nicht nur für das System, sondern auch innerhalb eines Prozesses, wenn komplexere Prozesse gleichzeitig verschiedene Aufgaben (z. B. GUI aktualisieren, Eingaben verarbeiten, Drucken) erledigen müssen. Die CPU-Zeit, die einem Prozess vom Betriebssystem zugeteilt wird, soll nicht mit Warten vergeudet werden. Klassische Auswirkungen dieses Wartens sind zum Beispiel Fenster, die nicht aktualisiert werden, wenn sie vergrößert werden, während Daten geschrieben werden.
Eine Lösung für diese mehrfachen Berechnungen bietet das Betriebssystem in Form von Threads, auch Posix-Threads genannt. Ein Prozess kann mit dem Betriebssystem mehrere Ausführungszustände vereinbaren und das Betriebssystem kümmert sich um die Koordinierung der Ressourcen für diese Prozessteile. Threads also ein Art schlanke Prozessen innerhalb von Prozessen.
Für einige Aufgaben sind diese Threads jedoch immer noch zu schwergewichtig und es wurden leichtgewichtige Threads, sogenannte Green-Threads oder Fibers, entwickelt. Für diese läuft innerhalb des Prozesses ein kleiner Scheduler und verteilt die Rechenleistung an die jeweiligen Threads, die dem Prozess vom Betriebssystem zugeordnet wurde. Dies verkleinert den Ressourcenverbrauch vor allem beim Wechsel zwischen den Threads, weil hierfür der Weg nicht immer über den Betriebssystemkern läuft, sondern nur innerhalb des Prozesses die Entscheidung fällt; zur Erinnerung: der Wechsel zwischen Prozessen und Betriebssystemkern, Context-Switch genannt, benötigt viele Rechenzyklen, die beim Thread-Wechsel oft unnötig sind.
Für einen Thread, egal ob vom Betriebssystem oder ein Green-Thread, wird der gegenwärtige Ausführungszustand immer durch den Stack und Verweis auf die entsprechende Codeposition gesichert. Diese Zustände zu sichern und wiederherzustellen ist unter einigen Umständen auch noch sehr aufwändig.
Eine andere Form der Programmierung ist deshalb, das Koordinieren von Ressourcen noch ein Stück tiefer zu ziehen und sich innerhalb der Programmlogik darum zu kümmern. Dies kann man strukturiert mit Zustandmaschinen (engl. state machines) erreichen, die für alle Aufgaben (engl. Tasks) sichern, bis wohin die Berechnung erfolgt ist und welche Daten dafür alles notwendig sind. Durch genau einen solchen Ansatz wurde der Webserver Nginx damals zum schnellsten und ressourcenschonensten Webserver.
Dies ist auch die Richtung der Anforderungen an aktuelle Programme, insbesondere Server: viele Anfragen/Aufgaben unter größtmöglicher Ausnutzung der CPU gleichzeitig bearbeiten, wobei die Abarbeitung durch Interaktion mit anderen Komponenten (Netzwerk-Client, Datenbank, Festplatte) ins Stocken geraten kann.
Wie oben erläutert bietet das Betriebssystem zwei Wege: für jede Anfrage (1) einen eigenen Prozess (mittels fork) starten oder (2) (mittels pthread_create) einen Thread starten.
Um den Aufwand der Verwaltung durch das Betriebssystem zu vermeiden, kann die Koordination zwischen den Anfragen auch innerhalb eines Prozesses geschehen: (3) über einen Scheduler im Prozess (VM oder Runtim-Environment) oder (4) durch die Koordinierung innerhalb der Programmlogik mithilfe eines endlichen Automaten.
Die letzte Form erfordert keine Koordinierungsinstanz zur Laufzeit, aber bedarf der Mitwirkung der Funktionen. Bei Windows 3.1 gab es ein ähnliches Prinzip auf Prozessebene, das kooperative Prozess-Scheduling: Wenn eine Bearbeitung ins Stocken gerät, gibt die Funktion die CPU freiwillig an ihren Aufrufer zurück. Bei den Varianten (1)–(3) ist diese Kooperation nicht notwendig, da der Scheduler die Macht besitzt, die ungenutzte CPU weiterzugeben.
Diese notwendige Unterstützung der Funktion erklärt auch die speziellen Schlüsselworte async und await. Mit async wird eine Funktion oder ein Block gekennzeichnet, in dem genau dieses kooperative Verhalten umgesetzt ist und für den ein endlicher Automat vom Compiler erstellt werden muss, damit die Ausführung an bestimmten Stellen – die durch await gekennzeichnet sind – unterbrochen und später fortgesetzt werden kann. Bei Rust sind – ebenso wie der ?-Operator – async und await nur Vereinfachungen für die Programmierung (semantic sugar), denn ein solcher endlicher Automaten könnte auch von Hand programmiert werden.
Aus der Beschreibung zwischen Green-Threads (3) und freiwilligen Zurückgeben (4) ergibt sich auch die unterschiedliche Ausführungsstruktur: Bei Green-Threads wird das Programm ganz normal ausgeführt und eine Funktion ruft die nächste auf, bis ein Punkt erreicht wird, an dem die Abarbeitung warten muss. Der komplette Thread-Zustand (der Stack) bleibt dann für die spätere Fortsetzung erhalten.
Bei einem endlichen Automaten blockiert eine Funktion nicht, wenn die Fortführung nicht möglich ist, sondern packt die relevanten Information für die spätere Fortsetzung und eine Markierung für die aktuelle Position zusammen und übergibt diese Informationen an ihren Aufrufer. Wenn die Fortsetzung irgendwann möglich ist, ruft der Aufrufer die Funktion mit den Informationen wieder auf und diese setzt ihre Arbeit fort. Der Programm-Stack wird also kontinuierlich auf- und abgebaut, je nach aktuellen Ausführungszustand des laufenden Programms; aber nur für die tatsächlich stattfindenden Berechnungen und nicht zusätzlich für die wartenden.
Future
Genau diese Informationen des endlichen Automaten über den Ausführungszustand einer Funktion bilden das Future-Objekt bei Rust, das von den async-Funktionen zurückgeben wird. Die Funktionen werden für den endlichen Automaten auch in zwei Teile umgewandelt: Initialisierung des endlichen Automaten, also des Future-Objekts, und die Ausführung. Der eigentliche Code aus dem Quelltext steckt im zweiten Teil und jeder Funktionsaufruf wird zum ersten Teil, der Initialisierung des Future-Objekts. Deshalb muss auch mit await die Ausführung erst angestoßen werden, weil allein bei der Initialisierung keine Berechnung erfolgt.
Der eigentliche Treiber für die Berechnung ist die Methode Future.poll. Es erfolgt immer zuerst der Aufruf der async-Funktion, die (sofort ohne etwas zu berechnen) das Zustandsobjekt im Startzustand zurück gibt. Danach wird für dieses Zustandsobjekt wiederholt die Methode poll aufgerufen, um einen Schritt weiterzurechnen, bis ggf. die Berechnung ins Stocken gerät und unterbrochen wird, also poll beendet wird. Schematisch sieht das Ganze so aus:
let mut future = func();
while let Poll::Pending = future.poll() {
// sleep
}
Für richtigen Code wird es mehr als eine Aufgabe geben und Aufgaben werden auch verschachtelt sein. Da die Kooperation der Funktionen nur funktioniert, wenn die Funktionen nicht warten, eben nicht sleep aufrufen, sieht der Code eher so aus:
fn example() -> impl Future {
enum MyFuture {
Init,
S1(impl Future, impl Future),
S2(u32, impl Future),
Done(u32),
};
impl Future for MyFuture {
fn poll(&mut self) -> Poll {
match self {
MyFuture::Init => {
*self = MyFuture::S1(func_1(), func_2());
Poll::Pending
}
MyFuture::S1(future_1, future_2) => {
if let Poll::Ready(val) = future_1.poll() {
*self = MyFuture::S2(val, future_2);
} else if let Poll::Ready(val) = future_2.poll() {
*self = MyFuture::S2(val, future_1);
}
Poll::Pending
}
MyFuture::S2(val_1, future_2) => {
if let Poll::Ready(val_2) = future_2.poll() {
let result = val_1 + val_2;
*self = MyFuture::Done(result);
Poll::Ready(result)
} else {
Poll::Pending
}
}
MyFuture::Done(val) => Poll::Ready(val),
}
}
}
return MyFuture;
}
Context
Wenn Aufgaben wiederum von weiteren Aufgaben abhängen, kann ein sehr komplexes Gebilde mit vielen Aufgaben entstehen. Ursprünglich wurde diese Arbeitsweise im Rahmen der Entwicklung der Bibliothek Tokio begonnen und man konnte auf damit über eine Million Anfragen pro Sekunde bearbeiten; grundlegend ist die Tendenz sehr viele Aufgaben zu bearbeiten. Damit das Programm aber nicht regelmäßig durch eine riesige Liste offener Aufgaben gehen und poll aufrufen muss, wird dem poll-Aufruf ein Context mitgegeben, über den die Aufgabe eine effizientere Rückmeldung (Context.waker) über ihre Arbeitsfähigkeit melden kann.
In der Interaktion mit dem Betriebssystem muss sich nämlich etwas ändern. Die Aufrufe von Systemfunktionen dürfen nicht mehr blockieren bzw. müssen asynchrone Varianten verwendet werden. Beim Aufruf von read oder write wird das Betriebssystem die notwendigen Operationen (Netzwerkkommunikation, Festplattenbefehle) also nur anstoßen, aber nicht auf den Abschluss warten. Gegebenenfalls muss ein solcher Aufruf mehrfach ausgeführt werden, bis das Ergebnis tatsächlich zur Verfügung steht. Damit dies nicht sinnlos oft passiert, haben sich auf Betriebssystemebene bereits einige Mechanismen wie select und epoll etabliert und genau darauf greifen die Rust-Funktionen zurück.
Wenn also Daten einer Netzwerkanfrage gelesen werden sollen, ruft die Funktion read auf. Liefert dieses EAGAIN oder EWOULDBLOCK, wird mithilfe von Context.waker für das Future-Objekt der Funktion der File-Descriptor für den Open-Aufruf in die Liste für select eingetragen. Wenn Daten verfügbar sind, die Arbeit also fortgesetzt werden kann, wird select signalisieren, dass es für den File-Descriptor Daten gibt, dieser kann einem bestimmten Future-Objekt zugeordnet werden und diese Aufgabe wird dann angestoßen.
Es entsteht also ein sehr effizientes Zusammenspiel der Aufgaben bei der Ausführung, aber der Programmierer wird im einfachsten Fall nur in Form von async zum Kennzeichnen der besonderen Funktionen und await zur Kennzeichnung von Unterbrechungsstellen damit konfrontiert. Es gibt aber auch noch wesentlich mehr Möglichkeiten bis eben hin zur händischen Programmierung der poll-Funktion.
Ich hoffe, ich konnte in dem Beitrag das Ziel für die Entwicklung der asynchronen Programmierweise verdeutlichen und wie die einzelnen Teile zusammenspielen bzw. weshalb sie so gestaltet sind. Richtigen Code und noch noch mehr Informationen gibt es in den folgenden Ressourcen:
Ankündigung der Futures-Bibliothek von 2016: Zero-cost futures in Rust
Zwei Beiträge darüber, wie Rust den Code mithilfe von Generatoren in den endlichen Automaten überführt: How Rust optimizes async/await I – Tyler Mandry, Teil II
Buch zu asynchroner Programmierung in Rust: Getting Started - Asynchronous Programming in Rust
Vortrag (YouTube-Video) grundlegend über async/await: RustLatam 2019 - Without Boats: Zero-Cost Async IO
YouTube-Video über die Anfänge der Entwicklung: RustFest Zürich 2017 – Tokio: How we hit 88mph by Alex Crichton; enthält viele technische Details
ein sehr langes YouTube-Video in dem erklärt wird, die Futures ausgeführt werden: The What and How of Futures and async/await in Rust
Implementation der Standardbibliothek mit async: async_std, Anleitung: Introduction - Async programming in Rust with async-std
Eine Erläuterung der Arbeitsweise von Nginx für die Bearbeitung von Anfragen: Boosting NGINX Performance 9x with Thread Pools; Nginx arbeitet eben nicht komplett asynchron, sondern nur bei der Verbindungsannahme