Umgang mit Abbrüchen in asynchronem Rust
(sunshowers.io)- Die Abbruchbehandlung in einer asynchronen Rust-Umgebung ist praktisch, kann bei falscher Handhabung aber zu unerwarteten Bugs und Schwierigkeiten führen
- In synchronem Rust sind explizite Flag-Prüfungen oder das Beenden des Prozesses nötig, während in asynchronem Rust ein Abbruch sehr einfach möglich ist, indem man einfach das Future droppt
- Cancel Safety und Cancel Correctness sind unterschiedliche Konzepte, und der Abbruch eines einzelnen Future kann Probleme im gesamten System verursachen
- Wichtige problematische Muster im Zusammenhang mit Abbrüchen sind Tokio mutex, das
select-Makro,try_joinsowie Fehler bei der Verwendung von Futures - Eine perfekte Lösung gibt es nicht, aber durch den Einsatz von cancel-sicheren APIs, das Pinnen von Futures und die Trennung in Tasks lassen sich durch Abbrüche verursachte Probleme verringern
Einleitung
- Dieser Beitrag basiert auf einem RustConf-2025-Vortrag über Abbruchbehandlung (cancellation) in asynchronem Rust
- In typischem asynchronem Rust-Code zeigt sich oft, dass beim Hinzufügen eines Timeouts zu einer Nachrichten-Empfangs- oder Sendeschleife Nachrichten verloren gehen können
- Behandelt werden Abbruchprobleme und reale Bug-Beispiele aus großen produktiven Systemen, die async Rust einsetzen, etwa bei Oxide Computer Company
- Der Text besteht aus drei Teilen: 1) das Konzept des Abbruchs, 2) die Analyse von Abbrüchen, 3) praktische Lösungsansätze
- Der Autor hat durch Rust Signal Handling, die Entwicklung von cargo-nextest usw. sowohl die Vorteile als auch die Schwierigkeiten von asynchronem Rust kennengelernt
1. Was ist ein Abbruch?
Bedeutung des Abbruchs
- Abbruch (cancellation) bedeutet, dass eine asynchrone Aufgabe gestartet und dann unterwegs beendet wird
- Beispiele: große Downloads/Netzwerkanfragen, partielles Lesen von Dateien usw., die unterwegs abgebrochen werden können
Methoden zum Abbrechen in synchronem Rust
- Üblicherweise gibt es Verfahren wie das periodische Prüfen auf Abbruch über atomare Flags, die Nutzung spezieller Ausnahmen (
panic) oder das erzwungene Beenden des gesamten Prozesses - Einige Frameworks (z. B. Salsa) verwenden Panic-Payloads, aber das funktioniert nicht auf allen Rust-Plattformen (insbesondere in Wasm-Umgebungen)
- Nur einen einzelnen Thread zwangsweise zu beenden, ist wegen der Sicherheitsgarantien von Rust und der Mutex-Struktur nicht erlaubt
- Kurz gesagt: In synchronem Rust gibt es kein universelles und sicheres Abbruchprotokoll
Asynchrones Rust: Was ist ein Future?
- Ein Future ist eine vom Rust-Compiler erzeugte Zustandsmaschine (state machine) und letztlich nur einfache Daten im Speicher
- Es wird nicht allein durch seine Erzeugung ausgeführt, sondern schreitet nur bei einem Aufruf von
awaitoderpollfort - Futures in Rust sind passiv (inert); ohne explizites
poll/awaitwird keinerlei Arbeit ausgeführt - Das steht im Gegensatz zu Go/JavaScript/C#, wo mit der Erzeugung eines Future die Ausführung sofort beginnt
Das Abbruchprotokoll in asynchronem Rust
- Ein Future abzubrechen bedeutet schlicht, es zu droppen oder
poll/awaitnicht weiter darauf aufzurufen - Da es sich um eine Zustandsmaschine handelt, kann ein Future jederzeit verworfen werden
- In asynchronem Rust ist Abbruch daher sehr mächtig und zugleich sehr leicht anwendbar
- Allerdings wird ein Future dadurch zu leicht stillschweigend gedroppt, wodurch auch Kind-Futures entsprechend dem Ownership-Modell kaskadierend abgebrochen werden
- Deshalb ist Abbruch ein nichtlokales (non-local) Phänomen, das sich auf die gesamte Aufrufkette auswirkt
2. Analyse von Abbrüchen
Cancel Safety und Cancel Correctness
- Cancel Safety: die Eigenschaft eines einzelnen Future, ohne Nebenwirkungen sicher abgebrochen werden zu können
- Beispiel: Tokios
sleep-Future ist cancel-sicher - Dagegen besteht bei Tokios MPSC-
sendbeim Droppen das Risiko von Nachrichtenverlust (also keine Cancel Safety)
- Beispiel: Tokios
- Cancel Correctness: eine globale Eigenschaft, bei der das Gesamtsystem auch bei Abbrüchen seine wesentlichen Eigenschaften beibehält
- Wenn ein nicht cancel-sicheres Future im System nicht vorhanden ist, gibt es kein Correctness-Problem
- Probleme entstehen nur dann, wenn ein nicht cancel-sicheres Future tatsächlich abgebrochen werden kann
- Gehen durch einen Abbruch Daten verloren, werden Invarianten verletzt oder Cleanup-Schritte ausgelassen, ist Cancel Correctness verletzt
Die Schwierigkeiten mit Tokio mutex
- Ein Tokio mutex arbeitet, indem ein Lock geholt, Daten angepasst und danach wieder freigegeben werden
- Problem: Wenn innerhalb des Locks ein Zustand vorübergehend verletzt wird (z. B.
Option<T>aufNonegesetzt wird) und das Future dann über einawaitabgebrochen wird, bleibt der fehlerhafte Zustand bestehen - In realen Produktionssystemen (z. B. bei Oxides
sled-Zustandsverwaltung) traten anawait-Punkten durch Abbrüche instabile Zustände auf - Damit wird deutlich, dass Abbruch bei der Zustandsverwaltung in asynchronem Code eine sehr gefährliche Fehlerquelle ist
Typische Abbruchmuster und Beispiele
- Future-Aufruf ohne
.await: Rust warnt bei ungenutzten Futures, aber wenn ein Rückgabewert vom TypResultin_übernommen wird, gibt es keine Warnung (neuere Clippy-Lints sind nötig) - Try-Operationen wie
try_join: Wenn ein Future fehlschlägt, werden die übrigen abgebrochen (was in realen Service-Stop-Logiken zu Bugs führen kann) select-Makro: Mehrere Futures werden parallel verarbeitet, und alle außer dem fertig gewordenen Future werden abgebrochen (inselect-Schleifen ist das Risiko von Datenverlust besonders hoch)- Diese Muster sind zwar in der Dokumentation erwähnt, in der Praxis können asynchrone Abbrüche aber an sehr vielen Stellen implizit auftreten
3. Was kann man tun?
- Für Probleme rund um Cancel Correctness gibt es bislang keine grundlegende und vollständige Lösung
- Praktisch lässt sich die Wahrscheinlichkeit von Abbruchfehlern aber mit den folgenden Methoden verringern
In cancel-sichere Futures umstrukturieren
- Beispiel MPSC
send:reserveund der eigentliche Versand (send) werden getrennt, um partielle Cancel Safety zu erreichen- Das Reservieren kann abgebrochen werden, ohne dass die betreffende Nachricht verloren geht
- Nach dem Erhalt eines Permit kann ohne Abbruchssorgen gesendet werden
AsyncWrite::write_all: Das Schreiben des gesamten Puffers mitwrite_allist bei Abbruch instabil,write_all_bufkann mithilfe eines Buffer-Cursors den Fortschritt nachverfolgen- In einer Schleife lässt sich mit
write_all_bufein teilweise fortgeschrittener Zustand sicher wieder aufnehmen
- In einer Schleife lässt sich mit
Futures so betreiben, dass Abbruch vermieden wird
- Future Pinning: In
select-Schleifen usw. werden Futures perPinfixiert, sodass sie nicht abgebrochen werden und per Referenz gepollt werden können- Beispiel: Wenn ein
reserve-Future wiederverwendet wird, bleibt seine Warteschlangenposition für die Reservierung erhalten
- Beispiel: Wenn ein
- Tasks verwenden: Führt man ein Future per
tokio::spawno. Ä. als Task aus, wird der Task selbst vom Runtime getrennt verwaltet und nicht zwangsweise abgebrochen, auch wenn der Handle gedroppt wird- In Oxides Dropshot-HTTP-Server werden etwa einzelne Requests als separate Tasks ausgeführt, sodass ihre Verarbeitung auch bei getrennten Client-Verbindungen garantiert abgeschlossen werden kann
Systematische Lösung?
- Auf der Ebene von safe Rust ist man derzeit noch eingeschränkt, aber es gibt diskutierte Ansätze
- Async drop: Erlaubt das Ausführen von asynchronem Cleanup-Code beim Abbruch eines Future
- Lineare Typen (linear types): Erzwingen beim Drop die Ausführung bestimmter Logik oder markieren bestimmte Futures als nicht abbrechbar
- Beide Ansätze sind in der Umsetzung schwierig
Fazit und Empfehlungen
- Man muss grundlegend verstehen, dass Futures passiv sind
- Die Konzepte Cancel Safety und Cancel Correctness sollten bekannt sein
- Wichtige Bug-Beispiele und Code-Muster rund um Abbrüche sollten erkannt werden, damit sich Gegenmaßnahmen früh vorbereiten lassen
- Einige praktische Empfehlungen
- Die Verwendung von Tokio mutex möglichst vermeiden und Alternativen prüfen
- APIs für partielle Fertigstellung oder cancel-sichere APIs entwerfen bzw. verwenden
- Für nicht cancel-sichere Futures unbedingt eine Codestruktur wählen, die ihre Fertigstellung garantiert
- Darüber hinaus lohnt sich die Beschäftigung mit vertiefenden Themen wie Cooperative Cancellation, Actor-Modell, Structured Concurrency, Panic Safety und Mutex Poisoning
- Weiteres Material findet sich unter sunshowers/cancelling-async-rust
Vielen Dank fürs Lesen. Der Autor dankt den Kollegen bei Oxide für die Durchsicht des Vortrags und der Begleitmaterialien sowie für ihr Feedback.
1 Kommentare
Hacker-News-Kommentare
send/recvsehr interessant; dabei wird klar, dass in Sprachen, in denen Futures sofort ohne vorheriges Polling laufen, eher die umgekehrte Situation auftreten kann. Wenn man beisendein Timeout setzt, kann die Nachricht auch nach dem Timeout noch gesendet werden, geht aber nicht verloren und ist daher sicher. Setzt man dagegen beirecvein Timeout, kann es passieren, dass eine Nachricht aus dem Channel gelesen wird und dann das Timeout ausgewählt wird, wodurch die Nachricht einfach verworfen wird und das Ganze unsicher wird. Die Lösung ist, entweder auf das Timeout oder darauf zu selektieren, dass im Channel „etwas verfügbar ist“, und im zweiten Fall die Daten perpeeksicher anzusehen.try_join-Elemente wird wegen eines Fehlers abgebrochenIn all diesen Fällen ist es doch normales Verhalten, dass die Arbeit nicht abgeschlossen wird, weil der Context abgebrochen wurde. Wenn die Arbeit unbedingt beendet werden muss, kann man sie in einen unabhängigen Task auslagern. Ich frage mich, ob ich hier eine wichtige Nuance übersehe. Meinem Verständnis nach ist es gerade die Absicht des Designs von Futures, dass Arbeit durch Cancellation verloren gehen kann. Es wäre gut, das Problem noch einmal einzuordnen.
await-Punkt jederzeit abgebrochen werden können, bleibt am Ende vor allem eine Frage der Details und Techniken.dropweiterläuft, können sich unnötige Arbeiten ansammeln und Locks oder Ports belegt bleiben, was problematisch sein kann. Ein Spawn-Handle, das den Task beidropstoppt, würde dagegen als "cancellation unsafe" gelten, ist aber für das Cleanup abhängiger Tasks ein sehr wichtiges Muster.selectund anderen Concurrency-Primitives tappt man in Go leicht in dieselben Fallen.awaitjederzeit ein potenzieller Rückkehrpunkt ist. Deshalb sollte man vermeiden, zwischen zwei Aktionen, die zwingend atomar zusammen ausgeführt werden müssen, einawaitzu setzen.dnicht aufgerufen wird? Passiert die Cancellation inc? Oder weil inaweiter oben etwas passiert?awaits gibt, kann dazwischen pausiert werden, obwohl der Code am Ende eigentlich unbedingt weiterlaufen müsste. Wenn man zum Beispiel nach einer DB-Änderung zwingend noch einen Audit-Log schreiben muss, damit beides wirklich ausgeführt wird: Gibt es dann außer einem Kommentar wie "do not cancel" keine Lösung?Futurekann, ähnlich wie Move Semantics in C++, nach seinem Ende in einen ungültigen Zustand geraten. Rust hat ein stackloses Coroutine-Design, daher muss man bei einer Poll-basierten async-Struktur den Zustand direkt in einerstructverwalten. All das sind häufige Stolperfallen. Und in aktuellem async Rust ist Cancellation eine weitere Variable im State Management. Als ich die Bibliothek mea (Make Easy Async) entwickelt habe, habe ich Cancel Safety immer dokumentiert, wenn sie nicht trivial war. Außerdem erinnere ich mich an einen Fall, in dem unbedachte async Cancellation Probleme im I/O-Stack verursacht hat: mea Fall auf RedditFuturenicht einfach canceln kann..awaitbesitzt das Future, daher kann man keindrop()darauf aufrufen, und weil Futures lazy sind, war mir nicht klar, wie Cancellation nach.awaitfunktioniert. Später habe ich mirselect!undAbortable()angeschaut und es verstanden, aber falls der Vortrag noch einmal gehalten wird, wäre ein entsprechender Hinweis direkt am Anfang perfekt.dropwäre endlich möglich.