3 Punkte von GN⁺ 2025-10-05 | 1 Kommentare | Auf WhatsApp teilen
  • 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_join sowie 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 await oder poll fort
  • Futures in Rust sind passiv (inert); ohne explizites poll/await wird 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/await nicht 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-send beim Droppen das Risiko von Nachrichtenverlust (also keine Cancel Safety)
  • 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> auf None gesetzt wird) und das Future dann über ein await abgebrochen wird, bleibt der fehlerhafte Zustand bestehen
  • In realen Produktionssystemen (z. B. bei Oxides sled-Zustandsverwaltung) traten an await-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 Typ Result in _ ü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 (in select-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: reserve und 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 mit write_all ist bei Abbruch instabil, write_all_buf kann mithilfe eines Buffer-Cursors den Fortschritt nachverfolgen
    • In einer Schleife lässt sich mit write_all_buf ein teilweise fortgeschrittener Zustand sicher wieder aufnehmen

Futures so betreiben, dass Abbruch vermieden wird

  • Future Pinning: In select-Schleifen usw. werden Futures per Pin fixiert, 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
  • Tasks verwenden: Führt man ein Future per tokio::spawn o. Ä. 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

 
GN⁺ 2025-10-05
Hacker-News-Kommentare
  • Ich fand das Beispiel mit Timeouts bei send/recv sehr interessant; dabei wird klar, dass in Sprachen, in denen Futures sofort ohne vorheriges Polling laufen, eher die umgekehrte Situation auftreten kann. Wenn man bei send ein Timeout setzt, kann die Nachricht auch nach dem Timeout noch gesendet werden, geht aber nicht verloren und ist daher sicher. Setzt man dagegen bei recv ein 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 per peek sicher anzusehen.
    • Ich denke, genau das ist der Kern von Cancellation-Safety.
    • Guter Hinweis, finde ich.
  • Ich möchte ein paar Dinge vorstellen, die ich zu diesem Thema geschrieben habe.
    • Ich habe 2020 einen Vorschlag geschrieben, nach dem async-Funktionen zwingend bis zum Ende ausgeführt werden müssen; darin ist auch Graceful Cancellation enthalten, und ich denke bis heute, dass noch keine bessere Idee aufgetaucht ist: Link zum Vorschlag
    • Es gibt auch einen Vorschlag für Unified Cancellation quer über sync und async Rust hinweg ("A case for CancellationTokens"): Link zum gist
    • Und es gibt auch eine tatsächliche Implementierung davon: min_cancel_token
  • Ich verstehe nicht ganz, warum es ein Problem sein soll, dass Futures abgebrochen werden. Futures sind keine Tasks, und auch der Artikel räumt das intern ein. Wenn das so ist, ist es dann nicht ganz normal, dass ein Future nicht bis zum Ende läuft? Und ich verstehe nicht, warum das problematisch sein soll. Im Beispiel wird von einem "cancel unsafe" Future gesprochen, aber ich glaube, der Kern ist eher ein Missverständnis zwischen Erwartung und Realität.
    • Beispiel 1: Eines der try_join-Elemente wird wegen eines Fehlers abgebrochen
    • Beispiel 2: Beim Abbruch werden Daten nicht geschrieben
      In 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.
    • Stimmt! Tatsächlich hat das bei Oxide zu vielen Bugs geführt. Wenn man wirklich versteht, dass Futures passiv sind und an jedem await-Punkt jederzeit abgebrochen werden können, bleibt am Ende vor allem eine Frage der Details und Techniken.
  • Ich fand diesen Vortrag auf der RustConf wirklich sehr unterhaltsam. Die Unterscheidung zwischen Cancel Safety und Cancel Correctness ist extrem nützlich, und es ist toll, dass der Vortrag auch als Blogpost verfügbar ist. Vorträge sind gut, aber als Blog aufbereitet lässt sich so etwas viel leichter teilen und nachschlagen.
    • Mir gefällt der Ausdruck "cancel correctness", weil er den Kontext von Cancellation gut einfängt. Den Begriff "cancel safety" mag ich dagegen nicht besonders. Er passt auch nicht wirklich zur Rust-Bedeutung von Safety und wirkt unnötig wertend. Safe/unsafe impliziert, dass etwas besser oder schlechter sei, obwohl die Wünschbarkeit des Cancel-Verhaltens vom Kontext abhängt. Zum Beispiel wird ein Future, das auf einen gespawnten Task wartet, als "cancellation safe" bezeichnet; wenn der Task aber nach drop weiterläuft, können sich unnötige Arbeiten ansammeln und Locks oder Ports belegt bleiben, was problematisch sein kann. Ein Spawn-Handle, das den Task bei drop stoppt, würde dagegen als "cancellation unsafe" gelten, ist aber für das Cleanup abhängiger Tasks ein sehr wichtiges Muster.
    • Ich stimme zu, der Blogpost ist leichter zu lesen und besser.
  • Der Inhalt von https://sunshowers.io/posts/cancelling-async-rust/#the-pain-of-tokio-mutexes fand ich besonders interessant; ich glaube, so einen Fehler könnte ich auch leicht machen.
    • Obwohl ich Go-Entwickler bin, hilft mir das ebenfalls. In Rust helfen die Tools strenger, aber bei Goroutines, Channels, select und anderen Concurrency-Primitives tappt man in Go leicht in dieselben Fallen.
  • Im ersten Beispiel ist unklar, welches Verhalten überhaupt gewünscht ist. Wenn die Queue voll ist, muss man sich zwischen Verwerfen, Warten und Panic entscheiden. Ein Timeout auf blockierendes Verhalten zu setzen dient meist der Deadlock-Erkennung. Der Code sagt, dass „nicht alle Nachrichten in den Channel gehen“, aber natürlich ist das so, wenn Ressourcen fehlen. Was ist also das Ziel? Sauberes Herunterfahren des Programms? Das ist in Thread-Umgebungen ziemlich schwierig und auch in async nicht einfach. Ein realer Use Case ist eher der Nachrichtenaustausch mit einer Gegenstelle, bei dem man den eigenen Zustand aufräumt, wenn die andere Seite wegfällt.
    • Im Idealfall würde man Nachrichten in einem Puffer behalten wollen, bis im Channel wieder Platz frei wird. Das wird im späteren Teil des Vortrags unter "What can be done" behandelt.
    • Im Beispiel selbst steckt die Antwort: Der Code, der loggt, wenn fünf Sekunden lang kein Platz frei ist, dient der Diagnose, birgt aber unauffällig das Risiko von Datenverlust. Es wirkt etwas konstruiert, aber in der Praxis hängt man solche "Warum funktioniert das nicht?"-Hilfscodes schnell überall ins System.
    • Zur Info: Die Autorin dieses Artikels verwendet die Pronomen they/she: about
  • Man muss immer im Hinterkopf behalten, dass await jederzeit ein potenzieller Rückkehrpunkt ist. Deshalb sollte man vermeiden, zwischen zwei Aktionen, die zwingend atomar zusammen ausgeführt werden müssen, ein await zu setzen.
    • Mich würde interessieren, wie das in der Praxis genau Probleme verursacht, zum Beispiel:
      async fn a() {
        b().await
      }
      async fn b() {
        c().await
        d().await
      }
      async fn c() {}
      async fn d() {}
      
      Wie genau kann es in diesem Code dazu kommen, dass d nicht aufgerufen wird? Passiert die Cancellation in c? Oder weil in a weiter oben etwas passiert?
    • Ist das dann nicht ein bisschen gefährlich? Natürlich lässt sich das nicht immer vermeiden, aber wenn es in einer "critical section" zwei 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?
  • Rusts Future kann, ä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 einer struct verwalten. 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 Reddit
  • Wirklich ein großartiger Vortrag! Ich bin totaler Anfänger, und ich hätte mir gewünscht, dass im SOP gleich zu Beginn betont worden wäre, dass man ein Future nicht einfach canceln kann. .await besitzt das Future, daher kann man kein drop() darauf aufrufen, und weil Futures lazy sind, war mir nicht klar, wie Cancellation nach .await funktioniert. Später habe ich mir select! und Abortable() angeschaut und es verstanden, aber falls der Vortrag noch einmal gehalten wird, wäre ein entsprechender Hinweis direkt am Anfang perfekt.
    • Frage: Wofür steht SOP hier?
  • Das Timing ist wirklich gut, denn ich habe heute gerade in den Doc-Comment einer neuen Funktion geschrieben: "Diese Funktion ist cancel safe". Dabei dachte ich mir, ich wünschte, async drop wäre endlich möglich.
    • Welche Funktion ist das? Ich bin neugierig, ob du dazu etwas mehr erzählen kannst.