Guide d’ingénierie

Détecter les courses aux données Swift avec Thread Sanitizer

Détecter les courses aux données Swift avec Thread Sanitizer

Lorsqu’une même suite XCTest réussit systématiquement en local, mais échoue parfois dans un pipeline parallèle lors de l’écriture dans un dictionnaire, d’un accès par indice à un tableau ou d’une assertion sur l’état, le problème vient rarement d’une « machine instable ». Il s’agit généralement d’un état mutable partagé auquel deux chemins d’exécution accèdent simultanément. Une course aux données dépend de l’ordonnancement exact des opérations, si bien qu’une simple relance suffit souvent à faire disparaître les indices. Une approche plus efficace consiste à créer, sur un Mac cloud, une tâche Thread Sanitizer distincte qui amplifie les accès concurrents, conserve le bundle de résultats et permet de remonter de la première paire d’accès conflictuels jusqu’au propriétaire de l’état.

Séparer d’abord la tâche de détection des tests ordinaires

Thread Sanitizer enregistre les accès mémoire et suit les relations entre les threads. Son activation augmente donc à la fois la durée d’exécution et la consommation de mémoire. Il ne faut pas l’appliquer directement à l’ensemble du pipeline. Commencez par créer un Scheme ou un Test Plan ConcurrencySanitizer qui ne contient que les tests susceptibles de modifier un état depuis plusieurs threads, notamment les caches, les files de téléchargement, les encapsulations de bases de données, les passerelles de callbacks et l’analyse parallèle.

Il est recommandé de répartir l’exécution en trois niveaux :

Niveau Périmètre des tests Moment d’exécution
Vérification rapide Fonctions pures et tests unitaires ordinaires À chaque commit
Vérification des accès concurrents Tests de concurrence à haut risque À chaque demande de fusion
Vérification étendue Ensemble des tests unitaires et d’intégration Tâche périodique distincte

Le Scheme doit être partagé dans le dépôt, faute de quoi l’environnement en ligne de commande ne pourra pas le trouver. Avant le commit, vérifiez son nom avec xcodebuild -list -workspace App.xcworkspace. Il convient également de désactiver l’exécution parallèle des tests pour la cible afin d’éliminer l’aléa implicite qu’elle introduit, puis de provoquer explicitement la concurrence dans les cas de test. La pression provient ainsi d’un code de test lisible, et non d’un ordonnanceur de tests impossible à maîtriser.

Figer une exécution reproductible en ligne de commande

Commencez par vérifier le nom des simulateurs installés sur le Mac cloud, puis remplacez l’appareil de l’exemple par la valeur réellement disponible. Attribuez un répertoire DerivedData distinct à chaque tâche afin d’éviter que deux exécuteurs ne réécrivent le même index et les mêmes artefacts intermédiaires de compilation.

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"

N’activez pas Address Sanitizer en même temps. Le cumul des deux instrumentations augmente le coût en ressources et le bruit dans les journaux, tout en rendant l’origine de l’échec plus difficile à déterminer. La configuration Release inclut généralement des optimisations et une stratégie d’assertions différente. La première phase de diagnostic doit donc utiliser Debug. Une fois la correction confirmée, ajoutez si nécessaire des validations dans d’autres configurations.

Un résultat au vert signifie uniquement que cet ordonnancement n’a pas déclenché la course. Il ne prouve pas que l’état partagé est sûr.

Augmenter volontairement la probabilité d’entrelacement des tâches

Le test de charge le plus utile ne consiste pas à exécuter aveuglément toute l’application en boucle. Il doit plutôt cibler un objet partagé et lancer simultanément des lectures, des écritures, des annulations et des réinitialisations. Le test ci-dessous met plusieurs tâches en concurrence pour mettre à jour un compteur. Il permet de vérifier que toute la chaîne de détection fonctionne réellement.

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 n’est pas une correction : il transfère simplement la responsabilité aux développeurs. Si le code réel en dépend, chaque point d’utilisation doit être examiné. Pour augmenter le taux de détection, les tests à haut risque peuvent être répétés 20 à 50 fois, à condition de fixer une durée maximale globale pour le pipeline. Évitez les longues pauses aléatoires. De brefs appels à Task.yield() sont plus adaptés pour multiplier les entrelacements tout en gardant les tâches sous contrôle.

Corriger la propriété de l’état

Pour l’état métier, privilégiez un Actor qui soit l’unique responsable des écritures :

actor Counter {
    private var value = 0

    func increment() {
        value += 1
    }

    func currentValue() -> Int {
        value
    }
}

Les appelants doivent passer par await, ce qui inscrit la frontière de propriété dans le système de types. Si une interface existante doit impérativement rester synchrone, utilisez une file série clairement définie ou un verrou de portée très limitée. Il ne faut toutefois pas verrouiller certains chemins tout en autorisant des lectures directes sur d’autres. Un verrou protège un invariant, pas seulement une ligne d’affectation.

Lire le rapport à partir de la première paire d’accès conflictuels

Les rapports de Thread Sanitizer sont souvent très longs. Ignorez d’abord les erreurs en cascade qui suivent et concentrez-vous sur la première paire Read et Write, ou sur les deux premiers accès Write. Pour chacun, relevez le thread, la file, la ligne de code source et l’emplacement de création de l’objet, puis répondez à trois questions :

  1. Les deux chemins accèdent-ils à la même instance ?
  2. Qui est censé posséder cet état ?
  3. La frontière de synchronisation couvre-t-elle toute l’opération de lecture-modification-écriture ?

Par exemple, cache[key] = value semble ne représenter qu’une ligne, mais peut inclure en interne une recherche, une extension de capacité et une écriture. Ajouter séparément des marqueurs avant et après l’appel ne crée aucune exclusion mutuelle. De même, « vérifier d’abord que le tableau n’est pas vide, puis récupérer son premier élément » doit se dérouler dans le même domaine d’isolation. Sinon, une autre tâche peut encore vider le tableau entre la vérification et la lecture.

Conservez le fichier .xcresult au lieu de capturer uniquement les dernières lignes du terminal. Vous pouvez commencer par exporter un résumé structuré :

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

La disponibilité de cette commande varie selon la version locale de Xcode. Le script doit donc commencer par exécuter xcrun xcresulttool help afin de vérifier le sous-commande. Archivez ensemble le bundle de résultats, l’identifiant du commit source, le nom du Scheme et la version du runtime du simulateur. Sans ces éléments, il sera difficile de reconstituer les conditions de l’échec.

Transformer la validation de la correction en contrôle fiable

Après la correction, relancez d’abord le test de charge initial et vérifiez que le Sanitizer ne signale plus aucune course. Exécutez ensuite la suite ordinaire sans instrumentation afin de détecter les interblocages, changements d’ordre ou dépassements de délai introduits par la nouvelle isolation. La revue de code doit également vérifier les points suivants :

  • Les utilisations injustifiées de @unchecked Sendable ont-elles été supprimées ?
  • Les collections mutables possèdent-elles un seul point d’entrée pour les écritures ?
  • La conversion d’un callback en async risque-t-elle de reprendre plusieurs fois la continuation ?
  • Task.detached contourne-t-il un Actor existant ?
  • Les doublures de test et les singletons globaux partagent-ils un état entre plusieurs cas de test ?
  • Les tâches en échec téléversent-elles le fichier .xcresult complet ?

Thread Sanitizer détecte les courses qui se produisent réellement à l’exécution, tandis que la vérification stricte de la concurrence de Swift impose les règles d’isolation à la compilation. Ces deux mécanismes ne se remplacent pas. En séparant les avertissements du compilateur, les tests de charge concurrents et les résultats du Sanitizer en plusieurs étapes, la cause des échecs devient plus claire. L’objectif final n’est pas simplement de faire disparaître le rapport, mais de donner à chaque état mutable un propriétaire unique, explicable et vérifiable.

Questions fréquentes

Faut-il lancer Thread Sanitizer à chaque commit ?

Mieux vaut exécuter à chaque commit un groupe réduit de tests sensibles à la concurrence, puis réserver la suite complète à une tâche distincte plus longue.

Un test réussi garantit-il l’absence de course aux données ?

Non. L’outil ne voit que les accès réellement exécutés pendant ce passage. Les répétitions et une orchestration concurrente volontaire améliorent la couverture.

Quand préférer un Actor Swift à un verrou ?

Un Actor convient à un état métier mutable partagé. Un verrou reste pertinent pour une section synchrone très courte dont les règles d’accès sont explicites et mesurées.

Nœud physique dédié

Déployer un workflow Mac dans le cloud avec OpsVM

Choisissez parmi trois configurations Apple Silicon et six nœuds disponibles à la vente. La disponibilité réelle est confirmée en temps réel par la console.

Choisir une configuration et commander