Fiche 07.08 — Concurrence avancée : actors, Sendable, Swift 6 et continuations
Objectif
Comprendre les data races et comment les éliminer avec les actor, garantir la sécurité du passage entre threads avec Sendable, lire les erreurs de Swift 6 strict concurrency, et brancher n'importe quel SDK UIKit (completion/delegate) sur async/await via les continuations et AsyncStream.
1. Le problème : la data race
Une data race, c'est quand deux tâches accèdent au même état mutable depuis deux threads différents, et qu'au moins l'une écrit. Résultat : valeur corrompue, crash aléatoire, bug qui ne se reproduit pas.
Rappel UIKit : avant, tu protégeais ça à la main avec une DispatchQueue série (queue.sync { ... }), un NSLock, ou os_unfair_lock. C'était à toi de ne jamais oublier un verrou. Swift moderne déplace cette garantie dans le compilateur via les actors.
2. L'actor : un état isolé, accédé via await
Un actor est une référence (comme une class) mais qui sérialise tous les accès à son état mutable. Un seul accès à la fois, garanti par le compilateur. Pour y toucher depuis l'extérieur, tu dois await (l'accès peut suspendre le temps que l'actor soit libre).
Cas réel : un cache thread-safe partagé par plusieurs écrans/tâches.
À l'intérieur de l'actor, l'accès à store est synchrone (self est déjà isolé). De l'extérieur : await cache.data(for: url).
Piège classique : à l'intérieur d'une méthode
asyncde l'actor, dès que tu fais unawait, l'actor peut servir un autre appel pendant la suspension. Ne suppose pas que ton état est inchangé après unawait(reentrancy). Revérifie tes invariants après chaque point de suspension.
3. @MainActor et nonisolated
@MainActor est un acteur global : tout ce qu'il annote tourne sur le main thread. C'est l'équivalent garanti par le compilateur de ton vieux DispatchQueue.main.async.
nonisolated retire l'isolation pour un membre qui n'a pas besoin du contexte de l'actor (souvent du calcul pur ou des constantes). Utile pour exposer du synchrone sans await.
Subtilité : une propriété
letd'un typeSendable(commeid) est lisible sansawait, même sansnonisolated— elle ne peut pas changer, donc aucune data race possible. Leawaitn'est obligatoire que pour l'état mutable de l'actor.
Rappel UIKit : tu écrivais DispatchQueue.main.async { self.label.text = ... } au retour d'un appel réseau. Avec @MainActor, l'isolation est statique : impossible d'oublier, le compilateur refuse de toucher l'UI hors du main.
4. Sendable : ce qui peut traverser une frontière de concurrence
Pour qu'une valeur passe d'une tâche/actor à une autre sans risque de data race, son type doit être Sendable. C'est un contrat vérifié par le compilateur.
Sont Sendable automatiquement :
- les value types (
struct,enum) dont tous les membres sontSendable(Int,String,Date, etc.) ; - les
actor(leur état est isolé par construction) ; - les
final classimmuables (toutes les propriétésletetSendable).
Une class mutable n'est pas Sendable : c'est une référence partagée modifiable. Préfère un struct (value type) dès que possible.
@Sendable closures
Une closure exécutée sur une autre tâche doit être @Sendable : elle ne peut capturer que des valeurs Sendable, et pas de référence mutable partagée.
@unchecked Sendable : avec prudence
Quand tu garantis toi-même la thread-safety (par exemple une classe protégée en interne par un lock), tu peux désactiver la vérification. C'est une promesse au compilateur : si tu te trompes, la data race revient sans filet.
N'utilise
@unchecked Sendableque si tu as une vraie raison (interop, lock maison, type C). Sinon c'est un trou de sécurité que le compilateur ne couvre plus. Préfère unactor.
5. Swift 6 : strict concurrency
Avant (Swift 5 mode), les problèmes de concurrence étaient au mieux des warnings, souvent rien. En Swift 6 language mode, les violations de Sendable et d'isolation deviennent des erreurs de compilation. Tu peux activer la transition progressivement avec le flag -strict-concurrency=complete en restant en Swift 5.
Les erreurs typiques au build, et comment les lire :
Capture of 'self' with non-sendable type 'MyClass' in a '@Sendable' closure
→ Tu passes un objet non-Sendable dans une Task / closure concurrente. Solution : rends-le Sendable, transforme-le en struct, ou isole-le dans un actor.
Main actor-isolated property 'name' can not be referenced from a non-isolated context
→ Tu touches une propriété @MainActor depuis un contexte qui n'est pas sur le main. Solution : marque la fonction appelante @MainActor, ou await MainActor.run { ... }.
Sending 'value' risks causing data races
→ Tu envoies une valeur dont l'usage pourrait continuer des deux côtés de la frontière. Solution : passe une copie value type, ou un type Sendable.
Réflexe : ne « fais pas taire » l'erreur avec @unchecked Sendable ou nonisolated(unsafe) sans comprendre. 90 % du temps, la bonne réponse est un value type ou un actor.
6. Bridger un SDK à completion → async : la continuation
La majorité des SDK (UIKit, frameworks Apple anciens, libs tierces) exposent encore des API à completion handler. Pour les consommer en async/await, tu les enveloppes une fois avec une continuation.
Règle absolue : tu dois appeler resume exactement une fois. Zéro fois → la tâche reste suspendue à vie (fuite). Deux fois → crash.
withCheckedThrowingContinuation: la closure peutthrow(resume avecthrowing:).withCheckedContinuation: version non-throwing.withChecked...ajoute une vérification runtime qui crashe avec un message clair si tu resume deux fois (ou jamais) — garde-le en dev. Les varianteswithUnsafe...n'ont pas ce filet : réserve-les à du code chaud déjà validé.
Rappel UIKit : c'est exactement ainsi qu'on modernise requestAuthorization(completion:), requestWhenInUseAuthorization suivi d'un delegate, loadItem(forTypeIdentifier:completionHandler:), etc. Le pattern est toujours le même : 1 callback → 1 resume.
7. Un flux d'événements → AsyncStream
Une continuation = une valeur. Pour un flux d'événements (un delegate qui appelle plusieurs fois), c'est AsyncStream. Cas réel : transformer CLLocationManagerDelegate en séquence asynchrone que tu consommes avec for await.
yield(_:)pousse un élément dans le flux.finish()termine le flux proprement.onTerminationest appelé quand le consommateur arrête (annulation, sortie dufor await) : c'est là que tu coupes le SDK sous-jacent.
Note Swift 6 : ce pattern compile tel quel en mode Swift 5 (le défaut, utilisé par la majorité des projets). En mode Swift 6 strict, le compilateur signale deux frontières sur ce code : la conformance à
CLLocationManagerDelegate(protocole d'un framework Apple non encore annotéSendable) et la capture deself(type non-Sendable) dans la closure@SendableonTermination. Il n'y a pas de correctif d'une ligne : selon le SDK tu isoles la classe (@MainActor) et marques le delegatenonisolated, ou tu enveloppes le manager dans un wrapper que tu garantis thread-safe. C'est exactement la friction « API d'un vieux SDK vs strict concurrency » décrite en section 5 — d'où l'intérêt d'isoler ces ponts dans un seul type bien testé.
Pont UIKit général : tout SDK delegate (Bluetooth
CBCentralManagerDelegate, scanner, capteur, upload avec progression…) se modernise enAsyncStream. Un appel unique avec completion → continuation. Plusieurs callbacks dans le temps →AsyncStream.
Points à connaître
- Reentrancy : après un
awaitdans une méthode d'actor, l'état a pu changer (l'actor a servi un autre appel). Revérifie tes invariants, ne caches pas une valeur lue avant leawait. resumeexactement une fois : la double-resume crashe, la zéro-resume fuit silencieusement. Avec un delegate qui peut appeler success puis failure, ajoute un gardeif let c = continuation { self.continuation = nil; c.resume(...) }.@unchecked Sendableetnonisolated(unsafe)désactivent la vérif : tu reprends la responsabilité de la thread-safety. À éviter sauf interop/lock maison.- Ne mets pas tout en
@MainActor: le réseau, le décodage JSON, le calcul lourd doivent tourner hors du main. Seul l'état d'UI doit y rester.
Exercice
Tu as cette API legacy d'un SDK :
Écris une fonction loadProfile(id:) async throws -> Profile qui l'enveloppe avec withCheckedThrowingContinuation, en appelant resume exactement une fois dans chaque branche. (10-20 min)
Question d'entretien
« C'est quoi un actor ? »
Un type référence dont le compilateur sérialise tous les accès à l'état mutable : un seul à la fois. Ça élimine les data races sans lock manuel. On y accède de l'extérieur via
await, car l'accès peut suspendre.
« C'est quoi Sendable ? »
Un protocole-marqueur qui certifie qu'une valeur peut traverser une frontière de concurrence (tâche, actor) sans data race. Les value types dont les membres sont
Sendablele sont automatiquement ; uneclassmutable ne l'est pas.
« Pourquoi une continuation ? »
Pour ponter une API à completion handler (typiquement un SDK UIKit) vers
async/await.withCheckedThrowingContinuationsuspend la tâche jusqu'à ce que le callback appelleresume— exactement une fois, sinon crash (double) ou fuite (jamais). Pour un flux multi-événements, on utiliseAsyncStreamà la place.
Résumé
- une data race = accès concurrent à un état mutable, au moins une écriture → corruption ;
- un
actorisole son état et sérialise les accès (awaitde l'extérieur), sans lock manuel ; @MainActorforce le main thread (UI),nonisolatedretire l'isolation pour du code sans état partagé ;Sendable= peut traverser une frontière de concurrence ; préfère les value types ;@unchecked Sendableseulement si tu garantis la sécurité toi-même ;- Swift 6 strict concurrency transforme ces violations en erreurs de build ; la bonne réponse est presque toujours value type ou actor ;
- continuation (
withCheckedThrowingContinuation) pour ponter un callback unique →async:resumeexactement une fois ; AsyncStreampour ponter un delegate multi-événements (ex :CLLocationManagerDelegate) →for await.