CodeGym /Kurse /Swift SELF /Reentrancy: warum await

Reentrancy: warum await

Swift SELF
Level 70, Lektion 2
Verfügbar

1. Reentranz von Akteuren und interleaving

Wenn Sie zum ersten Mal „actor“ gehoert haben, zeichnet das Gehirn oft ein Bild: „Innerhalb eines Akteurs laeuft alles strikt der Reihe nach, also wird jede Methode des Akteurs vollstaendig ausgefuehrt, ohne Ueberraschungen.“ Das ist fast wahr... aber mit einem kleinen (und sehr teuren) Zusatz in Kleingedrucktem: bis zum ersten await.

Swift-Akteure geben die „Illusion von Single-Threading“ — auf einem Akteur werden nicht zwei Codefragmente gleichzeitig ausgefuehrt, aber sie koennen sich an Suspension Points abwechseln. Das ist Reentranz: Wenn eine Akteursmethode angehalten wird, kann der Akteur beginnen, eine andere Nachricht zu verarbeiten, und spaeter zur ersten zurueckkehren. Genau deshalb duerfen Sie nach await nicht davon ausgehen, dass die Welt unveraendert geblieben ist.

Lassen Sie uns die Terminologie moeglichst alltagstauglich festhalten:

  • Data race (Datenrennen) — wenn zwei Threads/Tasks gleichzeitig auf denselben Speicher ohne Regeln zugreifen. Akteure in Swift schuetzen genau davor sehr gut.
  • Logical race (logisches Rennen) — wenn die Daten formal nicht „kaputt“ sind, die Logik aber falsch wurde, weil sich der Zustand „zwischen Pruefung und Aktion“ geaendert hat. Und genau davor schuetzt ein Akteur nicht automatisch, wenn Sie selbst eine „Luecke“ in die Invariante auf await legen.

Warum genau await ein Umschaltpunkt ist

Fast immer moechte man await als „na ja, einfach auf das Ergebnis warten“ verstehen. Aber in Swift ist die Bedeutung tiefer: await ist eine explizite Markierung moeglicher Unterbrechung. Die Sprache zwingt Sie absichtlich, await zu schreiben, damit Sie mit den Augen die Stellen sehen, an denen die Ausfuehrung unterbrochen und spaeter fortgesetzt werden kann.

Und hier ist der Schluesselgedanke: eine Unterbrechung zerstoert die Atomaritaet. Wenn Sie vor await die Haelfte der Arbeit erledigt haben und darauf gehofft haben, die zweite Haelfte spaeter einfach „fertig zu machen“, dann sagen Sie dem Runtime-System bei await im Grunde: „Ich bin kurz weg, ihr koennt andere bedienen.“

Damit das nicht wie Philosophie klingt, stellen wir uns einen Akteur als kleine Kaffeebar mit einem Barista vor:

  • Waerend der Barista Cappuccino macht und nicht abgelenkt wird, fuehrt er die Rezeptschritte nacheinander aus.
  • Aber wenn der Barista bis zum Schritt „warten, bis die Muehle fertig gemahlen hat“ kommt (das ist unser await), kann er sich umdrehen und eine andere Bestellung annehmen.
  • Wenn er zurueckkommt, muss er sich nicht daran erinnern, dass auf dem Tisch schon eine Tasse genau von der vorherigen Bestellung steht — sofern Sie keine klaren Markierungen und Regeln hinterlassen haben.

Wie interleaving aussieht

Jetzt ganz genau: In einem Akteur gibt es keine Situation, in der „zwei Methoden parallel auf demselben Akteur ausgefuehrt werden“. Das ist wichtig: Der Akteur erhaelt die Speicherintegritaet. Aber es gibt eine Situation, in der Methode A zu laufen beginnt, bis zu await kommt, angehalten wird, dann Methode B ausgefuehrt wird und danach Methode A fortgesetzt wird. Genau das nennt man interleaving.

Es ist sehr hilfreich, sich das als Timeline vorzustellen. Hier ist ein Schema, in dem wir keine Threads zeichnen, sondern Ausfuehrungsabschnitte:

sequenceDiagram
    participant T1 as Task 1
    participant A as Actor
    participant T2 as Task 2

    T1->>A: methodA() starts
    Note over A: A macht Schritte 1..N
    Note over A: await in methodA → Unterbrechung
    T2->>A: methodB() runs while A is suspended
    Note over A: methodB aendert den Zustand des Akteurs
    T1->>A: methodA() resumes
    Note over A: methodA macht weiter, aber der Zustand ist schon anders

Dieses Modell ist das Herz des Themas: Wenn eine actor-isolated Funktion angehalten wird, erlaubt Reentranz, dass andere Arbeit auf dem Akteur ausgefuehrt wird, bevor die urspruengliche Funktion fortgesetzt wird; dabei kann sich der Zustand „ueber await hinweg“ aendern.

2. Beispiele fuer logische Rennen

Mini-Demo: ein „unschuldiger“ Zaehler

Jetzt machen wir ein Beispiel, das Sie buchstaeblich in eine SwiftPM-Sandbox einfuegen und mit eigenen Augen beobachten koennen. Wir erstellen den Akteur Counter, bei dem eine Methode zwei Inkremente ausfuehrt, aber dazwischen liegt await Task.sleep. Dieses await wird absichtlich gebraucht, um dem System die Chance zu geben, einen anderen Aufruf „hineinzuschieben“.

import Foundation

actor Counter {
    private var value = 0

    func addTwoSlowly() async {
        value += 1
        try? await Task.sleep(nanoseconds: 100_000_000) // suspension point
        value += 1
    }

    func reset() { value = 0 }
    func get() -> Int { value }
}

Auf den ersten Blick sieht alles ordentlich aus: „Na ja, wir addieren einfach zwei, was soll da schon schiefgehen?“ Und jetzt der Startcode (kurz und auf den Punkt):

import Foundation

@main
struct Demo {
    static func main() async {
        let c = Counter()
        Task { await c.addTwoSlowly() }
        Task { await c.reset() }
        print(await c.get()) // kann 0 oder 1 sein (und das ist schon ein Grund zum Nachdenken)
    }
}

Was hier wichtig ist. Der Fehler liegt nicht darin, dass Counter „schlecht“ waere oder dass der Akteur „nicht schuetzt“. Der Akteur schuetzt den Speicher, ja. Aber nach dem ersten Inkrement wurde die Methode addTwoSlowly() unterbrochen, und reset() bekam die Chance, „zwischen zwei Zeilen“ von addTwoSlowly() ausgefuehrt zu werden. Das ist eine reine Illustration von interleaving auf await.

Muster „check → await → act“

Jetzt kommen wir zum haeufigsten realen Szenario. Es taucht bei Bankueberweisungen, Lagerbestandsreserven, Request-Limitern, Import-Queues auf — und auch in CLI-Projekten, wenn man nicht aufpasst.

Das Muster ist im Kern so aufgebaut:

1) Sie pruefen eine Bedingung auf dem Akteurszustand;
2) Sie machen await;
3) Sie machen weiter, als ob die Bedingung immer noch wahr waere.

Das Problem ist, dass die Bedingung in der Zwischenzeit falsch geworden sein koennte, waehrend Sie „geschlafen“ haben.

Hier ein Beispiel mit einem „Lager“:

import Foundation

actor Stock {
    private var items: Int = 10

    func takeIfPossible(_ count: Int) async -> Bool {
        guard items >= count else { return false }
        try? await Task.sleep(nanoseconds: 50_000_000) // hier koennen wir "unterbrochen" werden
        items -= count // logisches Risiko
        return true
    }
}

Das sieht legitim aus, kompiliert und „funktioniert“ sogar haeufig auf dem lokalen Rechner. Aber logisch gesehen oeffnen Sie dem System ein Fenster: Zwischen Pruefung und Aenderung des Zustands koennte ein anderer Aufruf auf demselben Akteur ausgefuehrt worden sein.

Der minimale Schutz, wenn await unvermeidlich ist, ist eine erneute Pruefung nach await:

import Foundation

actor Stock {
    private var items: Int = 10

    func takeSafely(_ count: Int) async -> Bool {
        guard items >= count else { return false }
        try? await Task.sleep(nanoseconds: 50_000_000)
        guard items >= count else { return false } // erneute Pruefung
        items -= count
        return true
    }
}

Das ist keine „magische Wunderwaffe“, aber es ist eine sehr ehrliche Regel: Wenn Sie verstehen, dass await andere Arbeit hineinlassen koennte, dann muessen auch Ihre Annahmen erneut geprueft werden.

Beispiel: LibraryCLI und ein „verwechselter“ Import

Lassen Sie uns die Idee auf den CLI-Kontext uebertragen. Stellen Sie sich vor, wir haben einen Akteur, der den Import koordiniert: Er speichert currentImportID, um zu protokollieren, „welches Buch wir gerade ziehen“, und gibt diesen Wert am Ende zurueck (zum Beispiel fuer einen schoenen Bericht).

Und genau hier laesst sich sehr leicht fast richtiger, aber falscher Code schreiben: Der Akteur schreibt einen Wert in eine gespeicherte Eigenschaft, dann await, dann gibt er die gespeicherte Eigenschaft zurueck und bekommt eine Ueberraschung.

Hier absichtlich eine schlechte Version:

import Foundation

actor ImportCoordinator {
    private var currentImportID: String? = nil

    func importBook(id: String) async -> String? {
        currentImportID = id
        try? await Task.sleep(nanoseconds: 80_000_000) // simulieren das Netzwerk
        return currentImportID // kann eine andere id sein
    }
}

Warum kann das passieren? Weil zwei parallele Importe sich abwechseln koennen:

  • Import A schrieb currentImportID = "A", kam zu await und „schlief ein“;
  • Import B schrieb currentImportID = "B", kam zu await und „schlief ein“;
  • Import A wachte auf und gab currentImportID zurueck, aber dort stand bereits "B".

Das ist kein Data Race (der Speicher wird nicht beschaedigt), das ist ein Race der Bedeutung.

Die richtige Version lautet: „Speichere das, was zu einer konkreten Operation gehoert, nicht in einem gemeinsamen Akteursfeld, wenn danach await folgt.“ Wir verwenden eine lokale Konstante:

import Foundation

actor ImportCoordinator {
    func importBook(id: String) async -> String {
        let importingID = id          // snapshot fuer diese Operation
        try? await Task.sleep(nanoseconds: 80_000_000)
        return importingID            // wir geben immer das zurueck, was wir importieren wollten
    }
}

Achten Sie darauf: Wir haben nicht „Parallelitaet verboten“, das System nicht langsamer gemacht und keinen Lock erfunden. Wir haben nur damit aufgehort, die Annahme zu machen: „Mein Feld gehoert immer noch zu mir.“

3. Praxis: wie man actor-Code schreibt und debuggt

Regeln, die ein await ueberleben

Jetzt moechte man gern eine „universelle Anleitung fuer alle Faelle“ haben, aber in der Konkurrenz gilt leider das Gesetz der Schmerz-Erhaltung: Entweder Sie sind diszipliniert, oder ein Bug-Report am Freitagabend diszipliniert Sie.

Deshalb statt Schlagwoertern — ein paar praktische Techniken, wir fassen sie in einer Tabelle zusammen.

Situation Warum das bei await gefaehrlich ist Wie man es robust macht
Sie schreiben den „Kontext der Operation“ in eine Stored Property, machen dann await, und verwenden danach die Stored Property In der Zwischenzeit kann ein anderer Aufruf dieses Feld neu schreiben Halten Sie den Operationskontext in lokalen let fest (Snapshot)
Sie machen „Pruefung → await → Zustand aendern“ Die Bedingung koennte veraltet sein, die Invariante koennte verletzt werden Setzen Sie await nicht zwischen check und mutate; wenn das nicht geht, pruefen Sie nach await erneut
Sie geben mutable Referenzobjekte nach aussen weiter Von aussen koennen sie parallel geaendert werden (und Sie haben keine Kontrolle mehr) Geben Sie Value-Snapshots (struct) oder berechnete Werte zurueck
Sie schreiben eine lange Akteursmethode mit mehreren await und viel Logik Es ist schwer zu verstehen, welche Annahmen nach jedem await noch gelten Teilen Sie in Schritte auf, fixieren Sie Daten lokal, minimieren Sie die „Lebensdauer“ von Annahmen

Warum erlaubt ein Akteur solche Wechsel ueberhaupt? Weil das das Risiko von Deadlocks senkt und sinnlose Serialisierung reduziert: Waerend der Akteur auf Netzwerk oder Disk wartet, kann er andere Anfragen bedienen.

Debugging-Technik: Logs fuer interleaving

Konkurrierende Bugs sind schwer zu fassen, weil sie nur „manchmal“ auftreten. Deshalb ist es nuetzlich, bewusst ein Interleaving-Fenster zu erzeugen und mit Logs zu zeigen, dass die Reihenfolge wirklich anders sein kann, als Sie erwartet haben.

Hier ist ein minimaler Akteur, der Schritte ausgibt:

import Foundation

actor Tracer {
    private var state = "idle"

    func doWork(name: String) async {
        state = "start \(name)"
        print(state) // zum Beispiel: start A
        try? await Task.sleep(nanoseconds: 50_000_000)
        print(state) // kann start B ausgeben
    }
}

Und der Start:

import Foundation

@main
struct Demo2 {
    static func main() async {
        let t = Tracer()
        Task { await t.doWork(name: "A") }
        Task { await t.doWork(name: "B") }
    }
}

Dieses Beispiel „fixt“ nichts — es hilft dem Gehirn, die Realitaet zu akzeptieren: await schneidet die Ausfuehrung wirklich auseinander, und der Akteur kann wirklich auf andere Arbeit umschalten.

4. Typische Fehler

Fehler Nr. 1: annehmen, dass ein Akteur jede Methode „atomar“ macht.
Die Illusion „Na ja, Akteur, also wird die ganze Methode vollstaendig ausgefuehrt“ ist sehr natuerlich, besonders nach Erfahrung mit einspurigen Queues. Aber Reentranz bedeutet: Eine Akteursmethode kann bei await unterbrochen werden, und andere Arbeit kann sich bis zu ihrer Fortsetzung dazwischen schieben. Das ist kein Bug der Akteure — das ist ihr Modell.

Fehler Nr. 2: den „Kontext der Operation“ in einer Stored Property speichern und ihn nach await verwenden.
Der haeufigste Fall sind currentTask, currentID, lastRequest, mode, currentUser. Wenn dieses Feld einen konkreten Methodenaufruf beschreibt und nicht den stabilen Zustand des Akteurs, wird es fast garantiert zur Quelle eines logischen Rennens. Ein einfacher Trick hilft: Snapshot in eine lokale let und mit ihm arbeiten.

Fehler Nr. 3: „check → await → act“ schreiben und auf Glueck hoffen.
Solcher Code besteht Tests manchmal monatelang, bis echte Last oder ein paralleles fetch-many auftaucht. Das Problem ist hier nicht, dass der Zustand „kaputtgeht“, sondern dass Ihre Annahmen veraltet sind. Wenn await unvermeidlich ist, pruefen Sie Bedingungen nach await erneut oder aendern Sie das Design so, dass der kritische Teil ohne Unterbrechungen bleibt.

Fehler Nr. 4: zu lange Akteursmethoden mit mehreren await bauen, ohne die Verantwortung zu trennen.
Je laenger die Methode und je mehr potentielle Suspension Points darin sind, desto schwerer ist es, die Invarianten im Kopf zu behalten. In der Praxis wird daraus Code, den Sie nicht anfassen wollen: „Ich refaktoriere lieber nicht, sonst geht es kaputt.“ Besser ist es, in Schritte zu zerlegen und klar zu verstehen, welche Daten „ueber“ await hinweg leben und welche erneut geholt oder geprueft werden muessen.

Fehler Nr. 5: denken, dass „wenn es kein Data Race gibt, dann ist alles sicher“.
Akteure verhindern Data Races auf ihrem Zustand tatsaechlich, aber logische Rennen liegen in Ihrer Verantwortung. Sobald Sie eine wichtige Annahme „zwischen die Zeilen“ gelegt und dort await eingefuegt haben, haben Sie die Tuere fuer interleaving selbst geoeffnet. Wichtig ist nicht, davor Angst zu haben, sondern den Code so zu entwerfen, dass Annahmen kurzlebig und pruefbar sind.

1
Aufgabe
Swift SELF, Level 70, Lektion 2
Gesperrt
Nach der Ausführung
Nach der Ausführung
1
Aufgabe
Swift SELF, Level 70, Lektion 2
Gesperrt
Verlorener Kontext
Verlorener Kontext
1
Aufgabe
Swift SELF, Level 70, Lektion 2
Gesperrt
Lager und Warten
Lager und Warten
1
Aufgabe
Swift SELF, Level 70, Lektion 2
Gesperrt
Nummernreservierung
Nummernreservierung
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION