Swift-Actor-Reentranz und Reservierungen über await hinweg testen
Zwei Exportanfragen auf einem Cloud-Mac durchlaufen dieselbe Prüfung in einem Actor – und erhalten trotzdem beide den letzten freien Platz. Dahinter muss kein Data Race stecken: Die erste Anfrage prüft die Kapazität und wartet anschließend auf eine asynchrone Vorbereitung. Währenddessen kommt die zweite Anfrage zum Zug. Zu prüfen ist daher die fachliche Invariante über await hinweg, nicht bloß, ob der Actor Variablenzugriffe serialisiert.
Zuerst die Kapazitätsinvariante festlegen
Angenommen, ein Exportdienst darf höchstens einen Auftrag gleichzeitig auf einen temporären Arbeitsbereich zugreifen lassen. Zur Belegung zählen sowohl Aufträge in Vorbereitung als auch laufende Aufträge – nicht nur solche, deren Vorbereitung bereits abgeschlossen ist. Andernfalls wird eine langsame Vorbereitung zum Schlupfloch in der Kapazitätsbegrenzung.
Gefährlich ist die Reihenfolge „freien Platz prüfen, auf Vorbereitung warten, Belegung erhöhen“. Während des Wartens kann der Actor andere Aufrufe bearbeiten. Das Prüfergebnis beschreibt deshalb nicht mehr unbedingt den Zustand bei der Fortsetzung. Richtig ist, Prüfung und Reservierung zuerst in einem Abschnitt ohne Unterbrechungspunkt abzuschließen und erst danach die asynchrone Operation auszuführen.
Actor-Isolation schützt den Zugriff auf den Zustand. Sie macht aber nicht die gesamte asynchrone Methode zu einer ununterbrechbaren Transaktion. Bei jedem
awaitist zu prüfen: Gilt die Bedingung, auf die ich mich verlasse, auch nach der Fortsetzung noch?
In diesem Beispiel werden Aufrufe bei ausgeschöpfter Kapazität sofort abgewiesen; eine Warteschlange gibt es nicht. Warteschlangenverhalten, Fairness und Zeitlimits gehören zu einem eigenen Vertrag. Unbegrenzte Wiederholungsversuche durch den Aufrufer ersetzen ihn nicht.
Die Reservierung mit einem eigenen Token zuordnen
Erstelle auf dem Cloud-Mac ein macOS-Projekt namens ActorQuota und füge ein XCTest-Unit-Test-Target hinzu. Ersetze das Standardbeispiel in der Testdatei durch den folgenden Code. Geprüft wird gemeinsam nutzbare Swift-Concurrency-Logik; ein iOS-Simulator muss dafür nicht gestartet werden.
import Foundation
import XCTest
enum QuotaError: Error {
case full
}
actor ReservationPool {
private let capacity: Int
private var active: Set<UUID> = []
init(capacity: Int) {
precondition(capacity > 0)
self.capacity = capacity
}
var count: Int { active.count }
func run(
_ operation: @Sendable () async throws -> Void
) async throws {
try Task.checkCancellation()
guard active.count < capacity else {
throw QuotaError.full
}
let token = UUID()
active.insert(token)
defer { active.remove(token) }
try await operation()
try Task.checkCancellation()
}
}
Zwischen Prüfung und Einfügen steht kein await. Andere Aufrufe können sich daher nicht dazwischenschieben. Jeder Aufruf besitzt seinen eigenen Token und gibt beim Verlassen nur die eigene Reservierung frei. So kann ein Fehlerpfad nicht versehentlich die Belegung eines anderen Aufrufs verringern.
defer greift sowohl bei normaler Rückkehr als auch beim Werfen eines Fehlers, ist aber kein Allheilmittel für die Ressourcenverwaltung. Die übergebene Operation darf erst zurückkehren, wenn die eigentliche Arbeit beendet ist. Startet sie einen Task mit unabhängigem Lebenszyklus und kehrt sofort zurück, wird der Platz weiterhin zu früh freigegeben. Der Aufrufvertrag muss deshalb zusammen mit der Implementierung geprüft werden.
Den Reentranzablauf steuern statt auf Wartezeiten zu hoffen
Baue in die erste Operation einen steuerbaren Haltepunkt ein. Der zweite Aufruf muss nach der Reservierung und vor der Freigabe stattfinden. Eine feste Schlafdauer schätzt den Ausführungsfortschritt nur ab; bei anderer Rechnerlast kann der Test dann zufällig bestehen.
Ergänze dieselbe Datei um die folgende einmalig verwendbare Test-Fixture und die Testklasse. Die Fixture darf bereits vor dem Warten geöffnet werden. So führt ein zu früh eintreffendes Signal nicht zu dauerhaftem Warten.
actor PauseGate {
private var opened = false
private var waiter: CheckedContinuation<Void, Never>?
func pause() async {
if opened { return }
await withCheckedContinuation { continuation in
waiter = continuation
}
}
func open() {
opened = true
waiter?.resume()
waiter = nil
}
}
final class ReservationTests: XCTestCase, @unchecked Sendable {
func testRejectsOverlapAndReleases() async throws {
let pool = ReservationPool(capacity: 1)
let gate = PauseGate()
let entered = expectation(description: "Operation entered")
let first = Task {
try await pool.run {
entered.fulfill()
await gate.pause()
}
}
await fulfillment(of: [entered], timeout: 2.0)
let during = await pool.count
XCTAssertEqual(during, 1)
do {
try await pool.run {}
XCTFail("Overlapping operation was accepted")
} catch QuotaError.full {
}
await gate.open()
try await first.value
let after = await pool.count
XCTAssertEqual(after, 0)
try await pool.run {}
}
}
Ist die Erwartung erfüllt, hat der erste Aufruf den Operationskörper erreicht. Die Reservierung muss zu diesem Zeitpunkt also bestehen. Die zwei Sekunden sind die maximale Wartezeit bis zum Erreichen der Fixture, kein Leistungskriterium. Die Fixture unterstützt nur einen wartenden Aufruf; sie darf nicht unverändert als Broadcast-Komponente im Produktivbetrieb eingesetzt werden.
Fehler und Abbruch getrennt prüfen
Ergänze einen Fehlerfall: Die Operation wirft sofort einen eigens für den Test definierten Fehler. Fange ihn ab, prüfe den Fehlertyp und lies anschließend count. Der Wert muss null sein, und der nächste Aufruf muss ebenfalls erfolgreich sein. Wer nur prüft, ob ein Fehler aufgetreten ist, übersieht den Fall „fachliche Operation fehlgeschlagen, Reservierung nicht freigegeben“.
Verwende für den Abbruchtest denselben Haltepunkt: Warte, bis die erste Operation begonnen hat, und rufe dann first.cancel() auf. Die jetzt ausgelesene Belegung muss weiterhin eins betragen. Öffne anschließend die Fixture und warte auf das Task-Ergebnis. Es muss ein CancellationError auftreten; am Ende muss die Belegung wieder null sein.
Diese Semantik ist bewusst konservativ. Ein Abbruch sendet lediglich ein kooperatives Signal; die Pausen-Fixture in diesem Beispiel wird dadurch nicht automatisch fortgesetzt. Würde die Kapazität freigegeben, bevor die zugrunde liegende Operation beendet ist, könnten sich ein neuer und der alte Auftrag überschneiden.
Unterstützt die tatsächliche Exportschnittstelle einen Abbruch, muss das Abbruchsignal an sie weitergereicht und ihre Bestätigung des Abschlusses abgewartet werden. Hat die Operation bereits irreversible Nebenwirkungen erzeugt, kann auch die abschließende Abbruchprüfung das Ergebnis nicht rückgängig machen. Dafür müssen ein Commit-Punkt und eine Möglichkeit zur Ergebnisabfrage gesondert definiert werden. „Abbruchfehler geworfen“ darf nicht mit „es ist nichts passiert“ gleichgesetzt werden.
In den Build einbinden und ein Gegenbeispiel behalten
Führe im Projektstamm den folgenden Befehl aus. Projektname und gemeinsam genutztes Scheme müssen den vorherigen Einrichtungsschritten entsprechen; die Testklasse gehört in das Target ActorQuotaTests.
set -euo pipefail
xcodebuild \
-project ActorQuota.xcodeproj \
-scheme ActorQuota \
-destination 'platform=macOS' \
-only-testing:ActorQuotaTests/ReservationTests \
test
Halte zunächst das erfolgreiche Testergebnis der korrekten Implementierung fest. Verschiebe dann in einem temporären Branch die Reservierung absichtlich hinter die asynchrone Operation. Der Überlappungstest muss fehlschlagen. Besteht er weiterhin, deckt er die Lücke zwischen Prüfung und Belegung nicht tatsächlich ab.
Prüfe abschließend vier Punkte: Die Reservierung besteht bereits vor dem Warten; ein abgewiesener Aufruf verändert die Belegung nicht; im Fehlerfall wird nur der eigene Token freigegeben; nach einem Abbruch wird die Kapazität erst zurückgegeben, wenn die tatsächliche Operation beendet ist. Setze die Kapazität danach auf zwei, halte zwei Operationen an und weise eine dritte ab. So lässt sich prüfen, ob die Begrenzung nicht versehentlich als boolescher Schalter fest eingebaut wurde.
Committe diese Tests zusammen mit der Ressourcenpool-Implementierung. Bei späteren Umbauten der asynchronen Vorbereitung schützen sie die tatsächliche Kapazitätsgrenze besser als die bloße Aussage: „Hier wird ein Actor verwendet, also ist es sicher.“
Häufig gestellte Fragen
Warum kann ein Actor sein Kontingent überschreiten?
An einem await kann ein weiterer Aufruf ausgeführt werden. Wird die Kapazität nur geprüft und noch nicht reserviert, sehen beide Aufrufe denselben freien Platz.
Soll ein Abbruch die Reservierung sofort freigeben?
Nur wenn die Operation die Ressource tatsächlich nicht mehr nutzt. Solange sie weiterläuft, muss die Reservierung bestehen bleiben, um eine Doppelbelegung zu verhindern.
Ein eigener Mac für deinen Workflow
Builds, Tests und Experimente mit nativem macOS durchführen. Vergleiche zuerst Ausstattung und Abrechnungszeiträume und wähle dann den passenden physischen Mac.