Engineering-Leitfaden

Swift-Datenrennen in Cloud-Mac-CI mit Thread Sanitizer finden

Swift-Datenrennen in Cloud-Mac-CI mit Thread Sanitizer finden

Wenn dieselbe XCTest-Suite lokal wiederholt erfolgreich durchläuft, in einer parallelen Pipeline aber gelegentlich bei einem Wörterbuchzugriff, einem Array-Index oder einer Zustandsprüfung abstürzt, liegt das meist nicht an einer „instabilen Maschine“. Häufig greifen zwei Ausführungspfade gleichzeitig auf gemeinsam genutzten, veränderlichen Zustand zu. Datenrennen hängen von der konkreten Ausführungsreihenfolge ab, sodass ein einzelner Neustart die Spuren leicht verwischt. Effektiver ist ein separater Thread-Sanitizer-Testjob auf einem Cloud-Mac: Er erhöht die Wahrscheinlichkeit konkurrierender Zugriffe, bewahrt das Ergebnis-Bundle auf und ermöglicht es, vom ersten Konfliktpaar auf die Eigentümerschaft des Zustands zurückzuschließen.

Sanitizer-Job und reguläre Tests zunächst trennen

Thread Sanitizer protokolliert Speicherzugriffe und verfolgt die Beziehungen zwischen Threads. Dadurch steigen sowohl Laufzeit als auch Speicherbedarf. Aktivieren Sie ihn deshalb nicht pauschal für die gesamte Pipeline. Legen Sie zunächst ein Scheme oder einen Test Plan namens ConcurrencySanitizer an, der nur Tests mit potenziell threadübergreifenden Zustandsänderungen enthält, etwa für Caches, Download-Warteschlangen, Datenbank-Wrapper, Callback-Bridges und paralleles Parsing.

Es empfiehlt sich, die Ausführung in drei Stufen zu unterteilen:

Stufe Testumfang Ausführungszeitpunkt
Schnellprüfung Reine Funktionen und gewöhnliche Unit-Tests Bei jedem Commit
Konkurrenzprüfung Nebenläufigkeitstests mit hohem Risiko Bei jedem Merge Request
Erweiterte Prüfung Vollständige Unit- und Integrationstests Als separater periodischer Job

Das Scheme muss im Repository freigegeben sein, da die Kommandozeilenumgebung es andernfalls nicht findet. Vor dem Commit lässt sich der Name mit xcodebuild -list -workspace App.xcworkspace überprüfen. Für das Test-Target sollte außerdem die parallele Testausführung deaktiviert werden, um implizite Zufälligkeit zu vermeiden. Erzeugen Sie die Nebenläufigkeit anschließend gezielt innerhalb der Testfälle. So stammt die Belastung aus nachvollziehbarem Testcode und nicht aus einem unkontrollierbaren Test-Scheduler.

Einen reproduzierbaren Lauf über die Kommandozeile festlegen

Prüfen Sie zunächst auf dem Cloud-Mac, welche Simulatoren installiert sind, und ersetzen Sie das Gerät im Beispiel durch den tatsächlich verfügbaren Namen. Weisen Sie jedem Job ein eigenes DerivedData-Verzeichnis zu, damit nicht zwei Runner denselben Index und dieselben Build-Zwischenprodukte überschreiben.

set -euo pipefail

RUN_ID="${CI_RUN_ID:-local}"
RESULT_DIR="$PWD/Artifacts/tsan"
DERIVED_DATA="$PWD/.derived-data/tsan-$RUN_ID"

mkdir -p "$RESULT_DIR"

xcodebuild test \
  -workspace App.xcworkspace \
  -scheme ConcurrencySanitizer \
  -configuration Debug \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -derivedDataPath "$DERIVED_DATA" \
  -enableThreadSanitizer YES \
  -resultBundlePath "$RESULT_DIR/result.xcresult"

Aktivieren Sie Thread Sanitizer nicht gleichzeitig mit Address Sanitizer. Die Kombination beider Instrumentierungen erhöht den Ressourcenverbrauch und das Lograuschen und erschwert zudem die Zuordnung der Fehlerursache. Release-Konfigurationen enthalten üblicherweise Optimierungen und abweichende Assertion-Strategien. Verwenden Sie für die erste Analyse daher Debug und ergänzen Sie nach bestätigter Fehlerbehebung bei Bedarf weitere Konfigurationen.

Ein grünes Ergebnis bedeutet lediglich, dass die Konkurrenzsituation bei dieser Ausführungsreihenfolge nicht ausgelöst wurde. Es beweist nicht, dass der gemeinsam genutzte Zustand sicher ist.

Die Wahrscheinlichkeit von Task-Überlappungen gezielt erhöhen

Der wertvollste Stresstest führt nicht blind die gesamte App in einer Schleife aus. Stattdessen konzentriert er sich auf ein gemeinsam genutztes Objekt und führt Lese-, Schreib-, Abbruch- und Rücksetzvorgänge gleichzeitig aus. Im folgenden Test konkurrieren mehrere Tasks beim Aktualisieren eines Zählers. Damit lässt sich prüfen, ob die Erkennungskette tatsächlich funktioniert.

final class UnsafeCounter: @unchecked Sendable {
    private(set) var value = 0

    func increment() {
        value += 1
    }
}

func testConcurrentIncrement() async {
    let counter = UnsafeCounter()

    await withTaskGroup(of: Void.self) { group in
        for _ in 0..<100 {
            group.addTask {
                counter.increment()
            }
        }
    }

    XCTAssertEqual(counter.value, 100)
}

@unchecked Sendable ist keine Fehlerbehebung, sondern überträgt die Verantwortung lediglich an die Entwickler. Wenn produktiver Code darauf angewiesen ist, muss jede Verwendungsstelle überprüft werden. Um die Trefferquote zu erhöhen, können Tests mit hohem Risiko 20 bis 50 Mal wiederholt werden. Für die Pipeline sollte jedoch ein Gesamtzeitlimit gelten. Vermeiden Sie zufällige lange Wartezeiten. Kurze Aufrufe von Task.yield() eignen sich besser, um Überlappungen zu fördern und die Tasks zugleich kontrollierbar zu halten.

Eigentümerschaft des Zustands korrigieren

Bei fachlichem Zustand sollte vorzugsweise ein einzelner Actor der einzige Schreibzugriffspunkt sein:

actor Counter {
    private var value = 0

    func increment() {
        value += 1
    }

    func currentValue() -> Int {
        value
    }
}

Aufrufer müssen über await zugreifen, wodurch die Eigentumsgrenze Teil des Typsystems wird. Falls eine vorhandene Schnittstelle synchron bleiben muss, kann eine eindeutig festgelegte serielle Queue oder ein sehr eng begrenzter Lock verwendet werden. Es darf jedoch nicht ein Teil der Zugriffspfade gesperrt werden, während ein anderer Teil direkt liest. Ein Lock schützt eine Invariante, nicht nur eine einzelne Zuweisungszeile.

Den Bericht anhand des ersten Zugriffskonflikts lesen

Berichte von Thread Sanitizer sind häufig sehr lang. Ignorieren Sie zunächst die nachfolgenden Kaskadenfehler und betrachten Sie nur das erste Paar aus Read und Write oder aus zwei Write-Zugriffen. Notieren Sie jeweils Thread, Queue, Quellcodezeile und Erzeugungsort des Objekts. Beantworten Sie anschließend drei Fragen:

  1. Greifen beide Pfade auf dieselbe Instanz zu?
  2. Wer sollte Eigentümer dieses Zustands sein?
  3. Deckt die Synchronisationsgrenze den vollständigen Lese-Änderungs-Schreibvorgang ab?

Ein Ausdruck wie cache[key] = value sieht beispielsweise wie eine einzige Anweisung aus, kann intern aber eine Suche, eine Kapazitätserweiterung und einen Schreibvorgang umfassen. Separate Markierungen vor und nach dem Aufruf erzeugen keinen gegenseitigen Ausschluss. Ebenso müssen „zuerst prüfen, ob das Array nicht leer ist, dann das erste Element abrufen“ innerhalb derselben Isolationsdomäne stattfinden. Andernfalls kann ein anderer Task das Array zwischen Prüfung und Zugriff leeren.

Bewahren Sie die .xcresult-Datei auf, statt lediglich die letzten Zeilen der Terminalausgabe zu erfassen. Zunächst kann eine strukturierte Zusammenfassung exportiert werden:

xcrun xcresulttool get test-results summary \
  --path Artifacts/tsan/result.xcresult \
  --format json > Artifacts/tsan/summary.json

Die Verfügbarkeit des Befehls hängt von der lokal installierten Xcode-Version ab. Das Skript sollte daher zuerst mit xcrun xcresulttool help prüfen, ob der Unterbefehl vorhanden ist. Archivieren Sie das Ergebnis-Bundle zusammen mit dem Quellcode-Commit, dem Scheme-Namen und der Simulator-Runtime-Version. Andernfalls lässt sich die konkrete Situation später nur schwer rekonstruieren.

Die Verifikation als zuverlässiges Quality Gate etablieren

Führen Sie nach der Korrektur zuerst den ursprünglichen Stresstest aus und stellen Sie sicher, dass der Sanitizer kein Datenrennen mehr meldet. Starten Sie anschließend die reguläre Suite ohne Instrumentierung, damit durch die geänderte Isolation verursachte Deadlocks, Reihenfolgeänderungen oder Timeouts erkannt werden. Bei der Code-Review ist außerdem Folgendes zu prüfen:

  • Wurde unbegründetes @unchecked Sendable entfernt?
  • Besitzen veränderliche Collections nur einen Schreibzugriffspunkt?
  • Kann eine Continuation beim Umwandeln eines Callbacks in async mehrfach fortgesetzt werden?
  • Umgeht Task.detached einen vorhandenen Actor?
  • Teilen sich Test-Doubles oder globale Singletons Zustand zwischen Testfällen?
  • Laden fehlgeschlagene Jobs die vollständige .xcresult-Datei hoch?

Thread Sanitizer erkennt Datenrennen, die zur Laufzeit tatsächlich auftreten. Die strikte Nebenläufigkeitsprüfung von Swift erzwingt dagegen Isolationsregeln bereits beim Kompilieren. Keine der beiden Methoden ersetzt die andere. Wenn Compilerwarnungen, Nebenläufigkeits-Stresstests und Sanitizer-Ergebnisse in getrennten Phasen verarbeitet werden, lassen sich Fehlerursachen klarer zuordnen. Das eigentliche Ziel besteht nicht darin, den Bericht verschwinden zu lassen, sondern jedem veränderlichen Zustand einen eindeutigen, nachvollziehbaren und überprüfbaren Eigentümer zuzuweisen.

Häufig gestellte Fragen

Soll Thread Sanitizer bei jedem Commit laufen?

Für jeden Commit genügt meist eine kleine, risikobasierte Testsuite. Die vollständige Suite sollte separat laufen, da instrumentierte Tests deutlich mehr Zeit und Speicher benötigen.

Beweist ein erfolgreicher Lauf die Freiheit von Datenrennen?

Nein. Er beweist nur, dass im beobachteten Lauf kein kollidierender Zugriff erkannt wurde. Wiederholungen und gezielte Parallelität erhöhen die Trefferwahrscheinlichkeit.

Wann ist ein Swift Actor besser als ein Lock?

Ein Actor eignet sich für zusammenhängenden veränderlichen Fachzustand. Ein Lock ist eher für kleine, klar begrenzte synchrone Abschnitte mit dokumentierter Aufrufdisziplin geeignet.

Dedizierter physischer Knoten

Cloud-Mac-Workflows mit OpsVM bereitstellen

Wählen Sie aus drei Apple-Silicon-Konfigurationen und sechs verfügbaren Knoten. Maßgeblich ist der in der Konsole in Echtzeit angezeigte Status.

Konfiguration auswählen und bestellen