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 vous 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 bogue 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 : on écrit async, on écrit 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, je maîtrise 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

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 et 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 cœurs, l’état mutable partagé comme source de tous les maux. Mais Swift 6 ajoute une chose que GCD n’a jamais eue : le compilateur vérifie les règles de manière statique. Pas un avertissement. Une erreur de compilation.

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

graph TD
    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/>cœur CPU A"]
        ME --> T2["Tâche 2<br/>cœur CPU B"]
        ME --> T3["Tâche 3<br/>cœur CPU 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 rappelait directement le ViewModel depuis la tâche du scanner, en mutant des propriétés que SwiftUI lisait activement pour afficher la progression. En JavaScript, aucun problème : 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. Pensez à une interface TypeScript qui signifie « ce type peut être partagé entre threads en toute sécurité ». Les structs sont Sendable par défaut si toutes leurs 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 fermeture 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 à l’appelant dans l’ordre, 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 mute l’état du ViewModel doit être @MainActor. Le compilateur le vérifie de manière statique.

graph LR
    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 parce qu’elles appartiennent au type plutôt qu’à une instance (familier des classes JavaScript), nonisolated parce qu’elles ne portent aucune exigence d’acteur et le compilateur 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) throws -> 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 = (try? Self.readFileSize(at: path)) ?? 0
            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
    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["Re-rendu SwiftUI"]

Les fermetures 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. 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 un mutex intégré. Une seule tâche peut s’exécuter à l’intérieur d’un acteur à la fois. Si deux tâches tentent d’appeler des méthodes sur le même acteur simultanément, l’une attend. Pas de verrous. Pas 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. Toute application iOS ou macOS où le défilement saccade pendant une opération d’arrière-plan souffre exactement de ce bogue : 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 bogues é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. Certaines invisibles : comptages de scan légèrement faux, indicateurs de progression qui reculent. Le genre de bogues 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. Les quarante premières erreurs ressemblaient à un réquisitoire.

Elles ne l’étaient pas. C’était une liste de tâches. Chacune pointait vers un vrai problème. On corrige les problèmes, les erreurs disparaissent, le code est 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, il ne rate rien, et vos états d’âme lui sont 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 :