Swift 6 et le vertige de la concurrence

Le compilateur m’a dit que mon code était faux. Il avait raison.

Les accès concurrents, je connaissais : tout langage avec de vrais threads finit par les enseigner. Le thread principal est sacré, toucher l’interface depuis un thread secondaire est interdit, et les conditions de concurrence sont ce genre de bug qu’on débusque à 23 h après deux heures à fixer du code en apparence irréprochable.

Je connaissais aussi async/await. La syntaxe est quasi identique en Swift, JavaScript, Python : écrire async, écrire await, la fonction se suspend et le moteur d’exécution passe à autre chose. J’ai lu la documentation sur la concurrence Swift en me disant : je maîtrise le modèle de threads, la syntaxe. Ça ira. On connaît la suite.

Ce que je n’avais pas anticipé, c’est que le compilateur allait l’imposer.


La différence que fait un compilateur

Avant la concurrence Swift, l’outil standard chez Apple, c’était GCD, Grand Central Dispatch : une API à base de files pour envoyer du travail vers la file principale ou des files d’arrière-plan. Avec GCD, la sécurité des threads est une convention. On dispatche sur la bonne file, on pose les bons verrous, et si on oublie, l’application plante à l’exécution, ou pire, produit des résultats faux en silence. Le compilateur ne sait pas, s’en moque : il compile ce qu’on lui donne.

Le modèle de concurrence de Swift repose sur la même réalité : plusieurs tâches qui tournent simultanément sur différents threads d’un pool coopératif, l’état mutable partagé comme source de tous les maux. Sauf que Swift 6 ajoute une chose que GCD n’a jamais eue : le compilateur vérifie les règles de manière statique, non pas comme avertissement, mais comme erreur de compilation.

Pour qui vient de JavaScript plutôt que d’Objective-C, le contraste est encore plus net : l’async/await de JS est mono-thread, les accès concurrents sur les objets ordinaires y sont structurellement impossibles.1 L’async/await de Swift a la même apparence en surface, mais s’exécute sur un pool de threads.

graph TD
    accTitle: JavaScript event loop vs Swift executor
    accDescr: JavaScript runs all code on a single event loop thread, making data races impossible. Swift dispatches tasks across a thread pool, where concurrent access to shared mutable state causes data races.
    subgraph JavaScript
        JET["Boucle d'événements<br/>(thread unique)"]
        JET -->|"await suspend,<br/>retour à la boucle"| JET
        JET --> JUI["Tout le code s'exécute ici<br/>Aucun accès concurrent possible"]
    end

    subgraph Swift
        ME["Exécuteur Swift<br/>(pool de threads)"]
        ME --> T1["Tâche 1<br/>Thread A"]
        ME --> T2["Tâche 2<br/>Thread B"]
        ME --> T3["Tâche 3<br/>Thread C"]
        T1 & T2 & T3 -->|"état mutable partagé ?"| RACE["Accès concurrent<br/>💥"]
    end
Cliquez sur Run en mode non protégé, plusieurs fois de suite. Le résultat diffère à chaque exécution, et il est toujours faux. Passez en mode protégé par acteur : le résultat est toujours correct. Mêmes opérations, mêmes threads, garanties différentes.

La première fois que j’ai activé la concurrence stricte de Swift 6 sur le projet, j’ai récolté une quarantaine d’erreurs, dans un code que je croyais correct.


Les trois catégories d’erreurs

Les erreurs se répartissaient en quelques familles.

Catégorie 1 : état de l’interface accédé depuis une tâche d’arrière-plan. Le scanner tourne sur un thread secondaire ; la barre de progression lit les propriétés du ViewModel sur le thread principal. Au début, j’avais écrit du code qui mettait directement à jour le ViewModel depuis la tâche du scanner, en modifiant des propriétés que SwiftUI lisait activement pour afficher la progression. En JavaScript, ça aurait fonctionné : un seul thread, pas de conflit. En Swift, sur un exécuteur multi-threads, c’est un accès concurrent.

Swift 6 en a fait une erreur de compilation : expression is not concurrency-safe because it accesses 'x' from outside the actor.

Catégorie 2 : types non Sendable franchissant une frontière d’acteur. Sendable est un protocole, que l’on peut se représenter comme une interface TypeScript signifiant « ce type peut être partagé entre threads en toute sécurité ». Le compilateur infère implicitement Sendable pour les structs dont toutes les propriétés stockées le sont ; les classes à état mutable ne le sont pas, sauf garantie manuelle.

J’avais une classe que je passais dans une closure de TaskGroup. TaskGroup est le mécanisme Swift pour exécuter plusieurs tâches en parallèle et collecter leurs résultats, comparable à Promise.all mais avec un cadre structuré : les résultats remontent au fur et à mesure que chaque tâche se termine (pas dans l’ordre de soumission, contrairement à Promise.all), et le groupe ne peut pas survivre à la portée qui l’encadre. La classe avait des propriétés mutables : le compilateur a refusé : type is not Sendable.

Catégorie 3 : violations d’isolation @MainActor. @MainActor est un acteur spécial intégré qui représente le thread principal de l’interface : un singleton, toujours le même thread, celui sur lequel SwiftUI effectue son rendu. Marquer un type ou une fonction @MainActor signifie qu’il ne peut s’exécuter que là. Les vues SwiftUI sont @MainActor, mes ViewModels étaient @MainActor, tout code qui modifie l’état du ViewModel doit l’être : le compilateur le vérifie de manière statique.

graph LR
    accTitle: MainActor isolation for UI state
    accDescr: A background task cannot directly mutate the ViewModel (compile error). It must go through MainActor.run to reach the @MainActor ViewModel, which then triggers SwiftUI re-renders via @Observable tracking.
    BG["Tâche d'arrière-plan<br/>(thread quelconque)"]
    MA["@MainActor<br/>(thread principal uniquement)"]
    VM["ScanViewModel<br/>@MainActor @Observable"]
    UI["Vue SwiftUI<br/>@MainActor"]

    BG -->|"mutation directe ❌<br/>erreur de compilation"| VM
    BG -->|"await MainActor.run ✅"| MA
    MA --> VM
    VM -->|"suivi @Observable"| UI

Le motif qui résout les trois cas

La correction est simple : séparer le calcul de la mutation d’état.

Le calcul pur n’a pas besoin d’isolation par acteur. Lire la taille d’un fichier, hacher des octets, construire une structure de données à partir de mémoire brute : rien de tout cela ne touche à l’état partagé. Ces opérations peuvent tourner n’importe où, sur n’importe quel thread, sans synchronisation. On les marque nonisolated static : static, car elles appartiennent au type plutôt qu’à une instance (familier des classes JavaScript) ; nonisolated, car elles ne portent aucune exigence d’acteur, si bien que le moteur d’exécution peut les placer librement sur n’importe quel thread.

La mutation d’état, c’est autre chose. Tout ce qui touche le ViewModel, tout ce que SwiftUI lit, doit se faire sur @MainActor. On collecte les résultats du travail d’arrière-plan et on les ramène en un seul saut.

// Extract I/O work into a nonisolated static helper
private nonisolated static func readFileSize(at path: String) -> Int64 {
    // pure computation, no shared state
    var stat = stat()
    guard lstat(path, &stat) == 0 else { return 0 }
    return Int64(stat.st_size)
}

// In an actor method, call it from TaskGroup
await withTaskGroup(of: (String, Int64).self) { group in
    for path in paths {
        group.addTask {
            let size = Self.readFileSize(at: path)
            return (path, size)
        }
    }
    for await (path, size) in group {
        await MainActor.run { self.updateSize(path: path, size: size) }
    }
}

Les données circulent dans une seule direction :

graph LR
    accTitle: TaskGroup data flow pattern
    accDescr: Input paths feed a TaskGroup that spawns parallel workers using nonisolated static helpers. Workers perform pure I/O without actor context. Results are collected with for-await and forwarded to the ViewModel via MainActor.run, triggering a SwiftUI re-render.
    PATHS["Entrée : paths[]"] --> TASK["TaskGroup<br/>addTask par chemin"]
    TASK --> WORKERS["N workers parallèles<br/>helpers nonisolated static"]
    WORKERS -->|"E/S pures<br/>pas de contexte d'acteur"| RESULTS["Résultats<br/>tuples (String, Int64)"]
    RESULTS -->|"for await"| COLLECT["Collecte sur l'acteur"]
    COLLECT -->|"MainActor.run"| STATE["Mise à jour du ViewModel<br/>@MainActor"]
    STATE --> SWIFTUI["Réévaluation SwiftUI"]

Les closures du TaskGroup peuvent appeler Self.readFileSize parce que la méthode est nonisolated static. Aucun contexte d’acteur requis.


Les acteurs

Renala doit hacher les fichiers pour trouver les doublons. Six tâches tournent en parallèle, chacune lisant et hachant un fichier différent, et les six écrivent leurs résultats dans le même état partagé : sans protection, c’est l’accès concurrent de manuel scolaire.

La solution est un acteur : un type référence avec une sérialisation intégrée. Le moteur d’exécution maintient un exécuteur série pour chaque acteur : une seule tâche s’exécute à l’intérieur à la fois, et une seconde tâche qui tente d’entrer est suspendue (pas bloquée) jusqu’à ce que la première finisse ou atteigne un await.2 Plus besoin de verrous ni de gestion manuelle des threads : le compilateur impose l’isolation.

Le HashVerificationService de Renala est un acteur. Il gère le pool de six workers, accumule les résultats de hachage, et les expose au reste de l’application via des appels await : le code externe se suspend tant que l’acteur est occupé et reprend quand l’appel se termine.

Le compromis : chaque appel à un acteur depuis l’extérieur est asynchrone. On ne peut pas appeler actor.someMethod() sans await depuis un contexte de concurrence différent. Parfois agaçant ; jamais faux.


Le résultat concret

Toute cette machinerie sert un seul objectif : l’interface reste réactive pendant que le scanner tourne.

Écran de sélection des volumes de Renala montrant les volumes disponibles pendant que l'application est au repos, prête à scanner
La liste des volumes est toujours interactive. Pendant qu’un scan tourne sur des tâches d’arrière-plan, le thread principal reste libre pour les mises à jour de l’interface, les animations et les interactions utilisateur. Swift 6 rend cette correction obligatoire, pas optionnelle.

Un scan complet du disque touche 5,2 millions de fichiers, ce travail prend plus d’une minute : si une fraction de ce travail atterrit sur le thread principal, l’interface se fige ; la cause la plus fréquente de saccades dans les applications iOS ou macOS, c’est du travail bloquant sur le thread principal. Swift 6 en fait une erreur de compilation plutôt qu’un symptôme à l’exécution.


Ce que le compilateur a attrapé, et que les tests auraient raté

Les bugs étaient réels. Si j’avais livré le code en étouffant ces erreurs, il y aurait eu des conditions de concurrence. Certaines visibles (état d’interface corrompu, plantages), d’autres invisibles (comptages de scan légèrement faux, indicateurs de progression qui reculent) : le genre de bugs qu’on traque pendant des jours sans jamais les reproduire de manière fiable, parce que les conditions de concurrence sont non déterministes. Elles dépendent du thread qui s’exécute en premier, et ça change à chaque fois.

Swift 6 en a fait des erreurs à la compilation, avant tout test, avant qu’aucun utilisateur n’ait jamais exécuté le code. Le prix, c’est une courbe d’apprentissage : le vocabulaire du compilateur (Sendable, @MainActor, isolation par acteur) n’est pas mince, et les quarante premières erreurs ressemblaient à un réquisitoire de mon incompétence.

Elles ne l’étaient pas : c’était une liste de tâches, et chacune pointait vers un vrai problème. On corrige les problèmes, les erreurs disparaissent, et le code se trouve correct par construction. La dernière soumission sur l’App Store : zéro plantage lié à la concurrence.

J’ai cessé de résister au bout d’une semaine ; après quoi le compilateur est devenu le meilleur relecteur de code que j’aie jamais eu. Il ne se fatigue pas, ne rate rien, et vos états d’âme lui sont parfaitement indifférents.


Pour aller plus loin

La concurrence Swift a plus de profondeur qu’un seul article ne peut couvrir. Voici les ressources que j’ai trouvées les plus utiles :

Sessions WWDC (gratuites, officielles) :

Références écrites :

Footnotes

  1. SharedArrayBuffer et Atomics peuvent introduire des accès concurrents sur la mémoire partagée en JavaScript, mais ce sont des mécanismes opt-in rarement rencontrés dans le code applicatif courant.

  2. Les acteurs sont réentrants aux points de suspension. Si une méthode d’acteur atteint un await, l’acteur peut commencer à exécuter une autre tâche en file d’attente avant que la première ne reprenne. C’est voulu (SE-0306) et évite les deadlocks, mais cela signifie que l’état de l’acteur peut changer de part et d’autre d’un await. Pour le HashVerificationService de Renala, ce n’était pas un problème : les mutations d’état critiques sont synchrones.