Swift-Zeitlimits testen: Abbruch und Ende der Task-Gruppe

Swift-Zeitlimits testen: Abbruch und Ende der Task-Gruppe

Bei der zeitzonenübergreifenden Übergabe von asynchronem Code auf einem Cloud-Mac lässt sich ein Fehler besonders schwer reproduzieren: Die Oberfläche meldet bereits eine Zeitüberschreitung, doch der Test läuft weiter. Meist liegt das nicht an einem defekten Timer, sondern daran, dass die zugrunde liegende Operation nicht auf den Abbruch reagiert. Arbeit und Timer in einer Task-Gruppe gegeneinander antreten zu lassen, schafft lediglich ein Wettrennen. Beliebiger Code lässt sich damit nicht zwangsweise unterbrechen.

Im Folgenden schaffen wir mit Swifts strukturierter Nebenläufigkeit eine ausführbare Grundlage und prüfen anschließend Ergebnis, Abbruch und tatsächliches Ende getrennt. Die Beispiele setzen eine Toolchain mit Unterstützung für Duration und Task.sleep(for:) voraus; für die Ausführung auf der Kommandozeile ist macOS 13 oder neuer erforderlich. Bei der Übergabe im Team sollte die tatsächlich verwendete Compilerversion dokumentiert werden.

Zuerst den Zeitlimit-Vertrag festlegen, dann den Timer schreiben

Der Vertrag besteht aus drei Regeln: Ist die Arbeit zuerst fertig, wird ihr Ergebnis zurückgegeben. Wird zuerst der Abschluss des Timer-Zweigs beobachtet, wird ein Zeitüberschreitungsfehler ausgelöst. Wird die übergeordnete Task abgebrochen, wird dieser Abbruch weitergegeben und nicht in eine fachliche Zeitüberschreitung umgewandelt. Werden beide Zweige nahezu gleichzeitig fertig, gibt es keine Zusage, welcher gewinnt.

Außerdem muss zwischen relativer Wartezeit und einer strikten Deadline unterschieden werden. Die Zeitmessung im folgenden Beispiel beginnt, sobald der Timer-Zweig seine Warteoperation ausführt. Sie setzt keine absolute Obergrenze für die Antwortzeit ab Eintritt in den Aufruf. Auch Scheduling-Verzögerungen lassen sich nicht durch eine besonders kurze Wartezeit beseitigen.

Das Auslösen des Zeitlimits, das Senden der Abbruchanforderung und das Ende der zugrunde liegenden Operation sind drei verschiedene Ereignisse. Erst wenn das letzte eingetreten ist, kann die Task-Gruppe enden.

Muss der Aufruf aus fachlichen Gründen beim Erreichen der Deadline sofort zurückkehren, lässt sich nicht zugleich behaupten, dass sämtliche Arbeit weiterhin an diese Task-Gruppe gebunden ist. Dafür muss entweder die Abbruchschnittstelle der zugrunde liegenden Operation neu gestaltet oder nicht unterbrechbare Arbeit in einen separaten Prozess ausgelagert werden, dessen Lebenszyklus explizit verwaltet wird.

Eine Race-Funktion mit austauschbarer Wartelogik implementieren

Speichern Sie den folgenden Inhalt als Timeout.swift. Die Warte-Closure kann sowohl eine echte Zeitquelle verwenden als auch im Test durch ein steuerbares Gate ersetzt werden. Das Arbeitsergebnis muss die Anforderungen für die Übergabe zwischen Tasks erfüllen.

import Foundation

enum TimeoutFailure: Error {
    case expired
}

func raceTimeout<Value: Sendable>(
    wait: @escaping @Sendable () async throws -> Void,
    operation: @escaping @Sendable () async throws -> Value
) async throws -> Value {
    try Task.checkCancellation()

    return try await withThrowingTaskGroup(of: Value.self) { group in
        defer { group.cancelAll() }

        group.addTask {
            try Task.checkCancellation()
            return try await operation()
        }

        group.addTask {
            try Task.checkCancellation()
            try await wait()
            try Task.checkCancellation()
            throw TimeoutFailure.expired
        }

        guard let value = try await group.next() else {
            throw CancellationError()
        }

        try Task.checkCancellation()
        return value
    }
}

Prüfen Sie zunächst mit diesem Compileraufruf die Typen und Nebenläufigkeitsanforderungen, bevor Sie den Code in die Tests des Projekts einbinden:

xcrun swiftc -swift-version 6 -strict-concurrency=complete \
  -parse-as-library -emit-module \
  -module-name Timeout Timeout.swift \
  -emit-module-path /tmp/Timeout.swiftmodule

Ein Aufruf mit echter Zeitmessung sieht so aus. Der aufrufende Code muss sich in einem asynchronen Kontext befinden:

let result = try await raceTimeout(
    wait: { try await Task.sleep(for: .seconds(2)) },
    operation: {
        try await Task.sleep(for: .milliseconds(20))
        return "ready"
    }
)

defer stellt sicher, dass sowohl bei Erfolg als auch im Fehlerfall der Abbruch des verbleibenden Zweigs angefordert wird. Die Warteoperation des Timers wirft beim Abbruch einen Fehler. Unterdrücken Sie diesen nicht mit try?: Andernfalls kann der Timer-Zweig weiterlaufen und fälschlich eine Zeitüberschreitung melden.

Die Reihenfolge mit Gates festlegen, statt auf Wartezeiten zu vertrauen

Die zentralen Unit-Tests sollten nicht nach dem Muster „kurz warten und dann raten, welcher Zweig zuerst läuft“ aufgebaut sein. Verwenden Sie zwei Test-Gates, die den Abschluss der Arbeit und des Timers getrennt steuern. Zusätzliche Startsignale bestätigen, dass beide Zweige ihre Warteposition erreicht haben, bevor das gewünschte Gate freigegeben wird.

Die Gates sollten den Abbruchzustand festhalten und wartende Tasks über einen Cancellation-Handler freigeben. Werden intern Continuations verwendet, muss sichergestellt sein, dass jede genau einmal fortgesetzt wird. Auch der Fall, dass der Abbruch vor der Registrierung des wartenden Tasks eintrifft, muss abgedeckt sein. Andernfalls kann schon die Testinfrastruktur selbst einen Hänger verursachen.

Mindestens diese vier Gruppen von Assertions sollten vorhanden sein:

Jede Gruppe muss nicht nur das Ergebnis des Aufrufs prüfen, sondern auch auf die Endmarkierungen beider Zweige warten. Ein korrektes Ergebnis bei noch laufenden Kind-Tasks reicht nicht aus, um den Test als bestanden zu werten.

Gezielt nachweisen, dass ignorierter Abbruch das Ende blockiert

Ergänzen Sie ein Arbeits-Gate, das Abbruchanforderungen absichtlich ignoriert, sich aber vom Test freigeben lässt. Lassen Sie zunächst den Timer-Zweig gewinnen. Prüfen Sie dann, dass die Arbeit eine Abbruchanforderung erhalten hat, der äußere Aufruf aber noch nicht abgeschlossen ist. Geben Sie zuletzt das Arbeits-Gate frei und bestätigen Sie, dass der äußere Aufruf erst jetzt den Zeitüberschreitungsfehler wirft.

Dieser Test belegt die Wartesemantik strukturierter Nebenläufigkeit; er ist keine Empfehlung, Abbruchanforderungen zu ignorieren. Der Test muss eine verlässliche Freigabe als Absicherung vorsehen. Simulieren Sie den Hänger weder mit einer Endlosschleife noch so, dass nach einer fehlgeschlagenen Assertion eine Continuation zurückbleibt, die nie fortgesetzt wird.

Prüfen Sie Produktionscode entlang der gesamten Aufrufkette: Rufen lange Schleifen regelmäßig Task.checkCancellation() auf? Betätigen Callback-Adapter den Abbruch-Handle der zugrunde liegenden Operation? Können blockierende Aufrufe zurückkehren? Eine einmalige Prüfung beim Eintritt in die Operation deckt lang laufende Arbeit im weiteren Verlauf nicht ab.

Nachweise für das Ende an die nächste Schicht übergeben

Bei der Ausführung auf einem Cloud-Mac gehören Commit-Kennung, Compilerversion, Testfallname, siegreicher Zweig, beobachteter Abbruch und beide Endmarkierungen in die Testanhänge. Berechnen Sie Laufzeiten mit einer monotonen Uhr, statt die Wanduhrzeiten auf den Rechnern verschiedener Teammitglieder voneinander abzuziehen.

Tests mit einer echten Uhr prüfen lediglich, ob die Warte-Closure für den Produktivbetrieb korrekt eingebunden ist. Lassen Sie Spielraum für das Scheduling und verlangen Sie keinen Abschluss auf eine bestimmte Millisekunde genau. Zusätzlich braucht der Testprozess eine unabhängige maximale Laufzeit: Eine Task-Gruppe, die nicht endet, kann sich nicht mithilfe ihrer eigenen Timer-Kind-Task retten.

Prüfen Sie abschließend, dass die Fehlerbehandlung nicht jeden CancellationError in eine Zeitüberschreitung umwandelt, die Warte-Closure keine Fehler unterdrückt und auf Fehlerpfaden keine Endnachweise fehlen. Die Übergabe ist nicht schon dann abgeschlossen, wenn „der Timer ausgelöst hat“, sondern erst dann, wenn das Ergebnis nachvollziehbar ist, der Abbruch beobachtet werden kann und die verbleibende Arbeit beendet ist.

Häufig gestellte Fragen

Kehrt die Funktion sofort zurück, wenn die Timer-Task wirft?

Nicht zwingend. Vor dem Verlassen ihres Gültigkeitsbereichs wartet die Gruppe auf alle Kind-Tasks. cancelAll fordert den Abbruch nur an und beendet unkooperative Arbeit nicht gewaltsam.

Muss jeder Test mehrere Sekunden warten?

Nein. Injizieren Sie eine steuerbare Wartefunktion und bestimmen Sie die Abschlussreihenfolge mit Testschranken. Wenige Tests mit echter Uhr reichen aus; dafür gilt ein separates Ausführungslimit.

Den passenden Cloud-Mac für Ihre nächste Aufgabe wählen

Wählen Sie einen Standort in der Nähe des Hauptstandorts Ihres Teams und nutzen Sie einen exklusiven physischen Mac mini. Für den Remote-Desktop im Browser benötigen Sie einen aktuellen Browser und eine stabile Internetverbindung. Prüfen Sie Konfiguration, Abrechnungszeitraum und Verfügbarkeit vor der Bestellung.

Die nächste Verbindung in Ihrer Nähe planen

Prüfen Sie zunächst Konfiguration und Laufzeit. Wählen Sie anschließend einen Standort in der Nähe der Region, in der Ihr Team hauptsächlich arbeitet.