Swift TaskLocal auf dem Cloud-Mac: Vererbung und Isolation testen

Swift TaskLocal auf dem Cloud-Mac: Vererbung und Isolation testen

Dieselbe Anfrage durchläuft drei asynchrone Phasen – Download, Parsing und Schreiben auf den Datenträger –, doch ihre Kennung taucht nur sporadisch in den Logs auf. Wird die Kennung stattdessen in einer globalen Variablen gespeichert, vermischen sich bei parallelen Tests plötzlich die Werte. Wer solche Probleme auf einem Cloud-Mac untersucht, sollte nicht zuerst die Thread-Anzahl ändern. Zu prüfen ist vielmehr, an welche Tasks der Kontext weitergegeben wird und an welcher Grenze seine Weitergabe endet.

Dieser Artikel prüft TaskLocal mit einem eigenständigen Swift-Paket, ohne Verbindung zu Anwendungsdiensten und ohne Simulator. Im Mittelpunkt steht die Task-Semantik, nicht die Zuordnung zu Threads: Wechselt derselbe Task den ausführenden Thread, darf dadurch kein tasklokaler Wert verloren gehen.

Zuerst die Regeln für die Kontextweitergabe festlegen

Die Anfragekennung wird als optionaler String mit dem Standardwert nil definiert. Fehlt ein Anfragekontext, macht dieser Standardwert eine unterbliebene Weitergabe leichter sichtbar als eine automatisch erzeugte Kennung, die gültig aussieht.

Es gelten fünf Regeln: Innerhalb des Gültigkeitsbereichs ist der Wert lesbar; ein verschachtelter Gültigkeitsbereich darf ihn vorübergehend überschreiben; nach dessen Ende gilt wieder der äußere Wert; normale Kind-Tasks und Kind-Tasks einer Task-Gruppe erben den Kontext zum Zeitpunkt ihrer Erstellung; abgetrennte Tasks erben ihn standardmäßig nicht. Auch wenn ein Fehler geworfen wird, muss der äußere Zustand wiederhergestellt werden.

TaskLocal ist weder threadlokaler Speicher noch eine sichere Hülle für globale Variablen. Weitergegeben wird der Task-Kontext, nicht der zuletzt auf einem bestimmten Thread geschriebene Wert.

Die Werte sollten außerdem möglichst klein und unveränderlich sein. Hier werden ausschließlich Strings weitergegeben, keine veränderlichen Dictionaries, Sitzungsobjekte oder Datenbankverbindungen. Ein Referenzobjekt in TaskLocal abzulegen, macht es nicht automatisch nebenläufigkeitssicher.

Ein eigenständig ausführbares Testpaket erstellen

Die folgenden Befehle werden in einem gewöhnlichen Arbeitsverzeichnis auf dem Cloud-Mac ausgeführt. Zuerst wird die Toolchain-Version erfasst, damit spätere Fehlerberichte zwischen Code- und Compileränderungen unterscheiden können.

mkdir TraceScopeLab
cd TraceScopeLab
swift --version
swift package init --type library --name TraceScopeLab
mkdir -p Tests/TraceScopeLabTests

Im Testverzeichnis wird die Datei ContextTests.swift mit dem folgenden vollständigen Code angelegt. Die standardmäßig erzeugte Testdatei kann bestehen bleiben; beide Dateien sind voneinander unabhängig. Das Beispiel verwendet direkt das mit der Toolchain gelieferte Testframework und benötigt keine zusätzlichen Abhängigkeiten.

import XCTest

private enum TraceContext {
    @TaskLocal static var requestID: String?
}

private enum ProbeError: Error {
    case expected
}

final class ContextTests: XCTestCase {
    func testNestedScopeRestoresAfterThrow() {
        XCTAssertNil(TraceContext.requestID)

        TraceContext.$requestID.withValue("outer") {
            XCTAssertEqual(TraceContext.requestID, "outer")

            XCTAssertThrowsError(
                try TraceContext.$requestID.withValue("inner") {
                    XCTAssertEqual(TraceContext.requestID, "inner")
                    throw ProbeError.expected
                }
            )

            XCTAssertEqual(TraceContext.requestID, "outer")
        }

        XCTAssertNil(TraceContext.requestID)
    }

    func testTaskBoundaries() async {
        let observed = await TraceContext.$requestID
            .withValue("request-a") {
                let child = Task {
                    TraceContext.requestID
                }
                let detached = Task.detached {
                    TraceContext.requestID
                }

                let inherited = await child.value
                let absent = await detached.value

                let groupValue = await withTaskGroup(
                    of: String?.self,
                    returning: String?.self
                ) { group in
                    group.addTask {
                        TraceContext.requestID
                    }

                    var result: String?
                    for await value in group {
                        result = value
                    }
                    return result
                }

                return (inherited, absent, groupValue)
            }

        XCTAssertEqual(observed.0, "request-a")
        XCTAssertNil(observed.1)
        XCTAssertEqual(observed.2, "request-a")
        XCTAssertNil(TraceContext.requestID)
    }
}

Die Ergebnisse werden hier zuerst mit await abgerufen und anschließend an die Assertions übergeben. So landet kein asynchroner Lesezugriff in einem synchronen Assertion-Ausdruck. Auf den Abschluss aller erzeugten Tasks wird gewartet, damit nach Testende keine Arbeit im Hintergrund weiterläuft.

Den Kontext für abgetrennte Tasks ausdrücklich festlegen

Task {} erbt tasklokale Werte, ist aber ein unstrukturierter Task. Seine Lebensdauer ist nicht automatisch an den Gültigkeitsbereich gebunden, in dem er erstellt wurde. Kontextvererbung und automatisches Warten auf den Abschluss sind zwei verschiedene Dinge.

Ein zuvor erzeugter Task kann die ursprüngliche Kennung daher auch dann noch mitführen, wenn der übergeordnete Gültigkeitsbereich bereits verlassen wurde. Das ist kein Fehler bei der Wiederherstellung. Für Arbeiten innerhalb einer Anfrage sollte bevorzugt strukturierte Nebenläufigkeit verwendet werden. Ist ein unstrukturierter Task tatsächlich nötig, muss sein Handle aufbewahrt werden, um ausdrücklich auf ihn zu warten oder ihn abzubrechen.

Bei unabhängiger Hintergrundarbeit ist es oft klarer, standardmäßig keinen Anfragekontext mitzuführen. Wird dennoch eine Zuordnung für das Tracing benötigt, werden zunächst nur die zur Weitergabe freigegebenen Felder erfasst und anschließend innerhalb des abgetrennten Tasks gebunden:

let capturedID = TraceContext.requestID
let worker = Task.detached {
    TraceContext.$requestID.withValue(capturedID) {
        TraceContext.requestID
    }
}
let observedID = await worker.value

Dieser Code gehört in eine asynchrone Testmethode der zuvor angelegten Testdatei. Der Wert wird auf der erzeugenden Seite erfasst und im neuen Task gebunden. Der alte Kontext darf nicht erst innerhalb des abgetrennten Tasks gelesen werden. Unabhängige Arbeiten wie zeitgesteuerte Bereinigungen sollten dagegen bei nil bleiben, damit nicht ein ganzer Block von Logeinträgen fälschlich einer einzelnen Benutzeranfrage zugeordnet wird.

Die Isolation bei verschränkter Ausführung prüfen

Ein erfolgreicher Test mit nur einer Anfrage reicht nicht aus. Dafür wird eine weitere asynchrone Testmethode ergänzt, die gleichzeitig zwei Tasks erzeugt und darin jeweils request-a beziehungsweise request-b bindet. Beide rufen innerhalb ihrer Bindung await Task.yield() auf und geben nach der Fortsetzung die aktuelle Kennung zurück. Abschließend wird jedes Ergebnis einzeln per Assertion geprüft.

Die beiden Tasks dürfen nicht in einer fest vorgegebenen Reihenfolge fertig werden müssen. Task.yield() signalisiert lediglich die Bereitschaft, die Ausführung abzugeben; eine bestimmte Verschränkung garantiert es nicht. Das Prüfkriterium lautet deshalb: „Unabhängig davon, welcher Task zuerst fertig wird, sind beide Werte korrekt“ – nicht: „Der erste Logeintrag muss von A stammen“.

Nach dem Ende beider asynchronen Gültigkeitsbereiche wird außerdem geprüft, dass der Standardwert weiterhin nil ist. Die Ergebnisse sollten weder in einem gemeinsam genutzten Array gesammelt noch über einen globalen Zähler in eine bestimmte Reihenfolge gezwungen werden. Sonst kann der Test selbst eine Race Condition einführen.

Fehler so dokumentieren, dass sich ihre Ursache eingrenzen lässt

Zunächst werden die Tests gezielt ausgeführt, anschließend zur Gegenprüfung mit aktivierter paralleler Ausführung. Der zweite Lauf ersetzt keine ausdrücklichen Assertions zur Isolation, kann aber unerwünschte Wechselwirkungen durch gemeinsam genutzten Zustand zwischen Tests aufdecken.

swift test --filter ContextTests
swift test --parallel --filter ContextTests

Liest ein normaler Kind-Task nil, ist zuerst zu prüfen, ob er tatsächlich innerhalb von withValue erzeugt wurde. Liest ein abgetrennter Task eine Kennung, sollte nach einer ausdrücklichen Bindung oder einer zusätzlichen Wrapper-Schicht gesucht werden. Wird nach dem Verlassen eines verschachtelten Bereichs der äußere Wert nicht wiederhergestellt, muss sichergestellt werden, dass tatsächlich TaskLocal gelesen wird und nicht eine gleichnamige globale Eigenschaft.

Für den Fehlerbericht genügen Toolchain-Version, Testfallname, erwarteter Wert und tatsächlicher Wert. Echte Anfragekennungen aus Produktionslogs müssen nicht in die Test-Fixtures übernommen werden.

Zum Schluss gilt: Tracing und Autorisierung müssen getrennt bleiben. Kontext kann helfen, die Schritte eines Vorgangs zusammenzuführen, beweist aber nicht, dass der Aufrufer zu dessen Ausführung berechtigt ist. Kleine Werte sollten entlang klar definierter Task-Grenzen weitergegeben werden; Berechtigungen müssen als explizite Eingaben geprüft werden. Nur so bleiben die Schlussfolgerungen aus parallelen Tests langfristig verlässlich.

Häufig gestellte Fragen

Übernimmt Task.detached die TaskLocal-Werte?

Nein. Lesen Sie den benötigten Wert im erzeugenden Task und richten Sie im abgetrennten Task mit withValue ausdrücklich einen neuen Bereich ein.

Reicht TaskLocal als Grundlage für eine Berechtigungsprüfung?

Nein. TaskLocal eignet sich für Trace-Kontext, nicht als Identitätsnachweis. Übergeben und prüfen Sie Berechtigungsdaten bei sensiblen Aktionen ausdrücklich.

Den nächsten Build auf einem exklusiven Cloud-Mac ausführen

MacFTP bietet exklusive Apple-Silicon-Physikserver mit nativem macOS. Wählen Sie eine Laufzeit von einem Tag, einer Woche, einem Monat oder einem Quartal. Prüfen Sie zuerst die Konfiguration und binden Sie anschließend Ihre vorhandene Toolchain an den Server an. Den aktuellen Verfügbarkeitsstatus sehen Sie in Echtzeit im Portal.

Jetzt Cloud-Mac mieten

Vom Lesen zurück zum Projekt

Erst Konfiguration prüfen, dann Bereitstellung planen

Vergleichen Sie Modelle und Laufzeiten oder lesen Sie weitere technische Beiträge im Blog.