La concurrence Swift 6.2 en pratique : par défaut sur MainActor, s'en échapper à dessein
Swift 6.0 a fait des accès concurrents aux données une erreur de compilation et, pendant un an, l’a fait payer à tout le monde. Le vérificateur de concurrence stricte a transformé du code d’interface tout à fait ordinaire en un mur de violations Sendable et d’erreurs « main actor-isolated property can not be referenced from a nonisolated context ». Le diagnostic était juste (ce code pouvait réellement entrer en concurrence), mais le volume enterrait le signal, et beaucoup d’équipes sont soit restées en mode Swift 5, soit ont parsemé leur code de @MainActor jusqu’à ce que les erreurs se taisent.
Swift 6.2 change la valeur par défaut plutôt que les règles. La garantie de sûreté face aux accès concurrents reste la même ; ce qui change, c’est le point de départ du compilateur. Adoptez les nouvelles valeurs par défaut et l’essentiel du mur disparaît, car le compilateur présume désormais ce qui était déjà vrai dans votre application : la majeure partie de votre code s’exécute sur le main actor, et vous le quittez à dessein, à des endroits nommés. C’est le modèle que je livre dans toutes les applications de la famille 9411. Voici comment il fonctionne, et les six erreurs qui mordent encore une fois que vous l’activez.
En bref
- SE-0466 vous permet d’isoler par défaut tout un module sur
@MainActor. DéfinissezSWIFT_DEFAULT_ACTOR_ISOLATION = MainActoret cessez d’annoter chaque vue, chaque modèle et chaque view-model2. - SE-0461 fait que les fonctions
asyncnonisolated s’exécutent sur l’actor de l’appelant par défaut (nonisolated(nonsending)), de sorte qu’appeler du code async ne force plus un saut d’actor ni les erreursSendableà la traversée de la frontière qui l’accompagnaient3. @concurrentest la porte de sortie. Marquez une fonction@concurrentpour pousser un travail lourd en CPU (décodage, traitement d’images) vers un thread d’arrière-plan, délibérément et visiblement4.- Xcode 26 active les deux valeurs par défaut pour les nouveaux projets. L’interrupteur global est
SWIFT_APPROACHABLE_CONCURRENCY = YES5. - Le modèle inverse la charge de la preuve : au lieu de prouver que chaque ligne peut s’exécuter hors du main actor en toute sûreté, vous gardez tout sur le main actor et prouvez seulement les rares endroits que vous quittez.
- Six erreurs concrètes survivent au changement. Toutes les six ont un correctif d’une seule ligne, une fois le motif identifié.
L’inversion, et pourquoi elle compte
Ancien modèle (Swift 6.0) : le code est nonisolated jusqu’à ce que vous l’isoliez. Chaque type qui touchait à l’état de l’interface avait besoin de @MainActor, chaque appel async traversant une frontière d’isolation exigeait une conformité Sendable, et le compilateur signalait chaque manque. Pour une application dont 95 pour cent du code s’exécute déjà sur le thread principal, vous passiez votre temps à annoter ces 95 pour cent pour décrire un fait qui n’a jamais fait de doute.
Nouveau modèle (Swift 6.2) : le code est sur le main actor jusqu’à ce que vous le quittiez. SE-0466 vous permet de déclarer l’isolation sur le main actor comme valeur par défaut du module, de sorte qu’une vue, son modèle et ses fonctions auxiliaires sont tous @MainActor sans une seule annotation2. SE-0461 supprime ensuite la seconde taxe : une fonction async nonisolated s’exécute désormais sur l’actor qui l’a appelée au lieu de sauter vers l’exécuteur global, de sorte que l’attendre ne vous traîne pas à travers une frontière d’isolation et n’exige pas Sendable sur tout ce qui se trouve dans la portée3.
Le modèle mental est celui qui correspond au comportement réel des applications d’interface. Le thread principal par défaut n’est pas un compromis ; c’est la vérité d’une application dont l’état, ce sont ses vues. La concurrence devient l’exception à laquelle vous recourez, nommée et contenue, plutôt que la condition ambiante contre laquelle vous vous défendez à chaque ligne. Le travail du compilateur passe de « prouvez que ceci peut s’exécuter en concurrence en toute sûreté » à « vous avez dit que ceci s’exécute en concurrence, alors prouvez que c’est sûr », et la seconde question est posée à bien moins d’endroits.
Activer le tout
Deux réglages de build, tous deux dans les valeurs par défaut d’Xcode 26 pour les nouveaux projets, et tous deux à définir explicitement sur un projet existant5 :
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
SWIFT_APPROACHABLE_CONCURRENCY = YES
Le premier, c’est SE-0466 : le module s’isole par défaut sur le main actor. Le second est l’interrupteur global qui active l’ensemble de fonctionnalités de l’approachable concurrency, y compris le comportement « l’appelant exécute » de SE-0461. Dans un package Swift, vous définissez les mêmes valeurs par défaut via swiftSettings avec les flags upcoming-feature correspondants, plutôt que par le réglage de build d’Xcode6.
Activez les deux sur un projet existant et le nombre d’erreurs chute fortement, car l’essentiel de ce que le vérificateur signalait était du code sur le thread principal qu’il ne pouvait pas, jusque-là, présumer comme tel. Ce qui reste est une courte liste de véritables cas-frontières. Ils méritent qu’on les connaisse par leur nom, car chacun est un endroit où votre code quitte réellement le main actor, et le correctif consiste à le dire avec précision.
Les six erreurs qui survivent au changement
Voici les erreurs de concurrence stricte qui apparaissent encore sous SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, tirées de la migration des applications de la famille 941 vers ce modèle1. Chacune est une véritable frontière, non un faux positif, et c’est pourquoi le correctif est une annotation précise plutôt qu’une suppression.
1. Fonctions pures sur des types Sendable. Une méthode pure sur une énumération Sendable (un constructeur d’URL, un formateur) hérite de l’isolation sur le main actor sous la valeur par défaut du module, puis lève une erreur lorsqu’elle est appelée depuis un contexte nonisolated : « Call to main actor-isolated instance method in a synchronous nonisolated context. » La méthode ne touche à aucun état, donc l’isoler sur le main actor est erroné. Marquez-la nonisolated7 :
nonisolated func searchURL(for query: String) -> URL? { ... }
2. Statiques singleton dans les paramètres par défaut. static let shared = Foo() est isolé sur le main actor sous la valeur par défaut, mais les valeurs des paramètres par défaut sont évaluées dans le contexte de l’appelant, qui est souvent nonisolated : « Main actor-isolated static property ‘shared’ can not be referenced from a nonisolated context. » Rendez le statique nonisolated. Si le type est Sendable (ou @unchecked Sendable parce que vous protégez son état vous-même), vous n’avez besoin d’aucun qualificatif unsafe :
final class KeychainProxySecretStore: @unchecked Sendable {
nonisolated static let shared = KeychainProxySecretStore()
}
3. Constantes primitives immuables. Le même problème de paramètre par défaut frappe une constante toute simple : un static let defaultInterval référencé depuis un argument par défaut nonisolated. Le correctif est identique et la constante est trivialement sûre à partager :
nonisolated static let defaultInterval: TimeInterval = 15 * 60
4. Un corps de Task lisant un self capturé. Une closure externe capture [weak self] ; à l’intérieur, un Task { @MainActor in self?.foo() } lit cet optionnel capturé : « Reference to captured var ‘self’ in concurrently-executing code. » La Task lit une liaison var depuis la portée englobante de manière concurrente, et c’est là l’accès concurrent. Recapturez self à la frontière de la Task pour que celle-ci possède une liaison immuable :
NotificationCenter.default.addObserver(...) { [weak self] _ in
Task { @MainActor [weak self] in
self?.value = next
}
}
5. Callbacks KVO lisant un état isolé sur le main actor. Un callback webView.observe(\.canGoBack) { wv, _ in ... } est @Sendable et donc nonisolated, mais WKWebView.canGoBack est isolé sur le main actor : « Main actor-isolated property ‘canGoBack’ can not be referenced from a Sendable closure. » Le KVO délivre de manière synchrone sur le thread qui a muté la valeur, et l’état de navigation de WKWebView ne mute que sur le thread principal, donc la lecture est saine. Affirmez-le avec MainActor.assumeIsolated, ce qui supprime entièrement le saut de Task et reste synchrone8 :
let pushNav: @Sendable (WKWebView?) -> Void = { [weak self] webView in
MainActor.assumeIsolated {
guard let self else { return }
// safe to read webView?.canGoBack synchronously
}
}
assumeIsolated est une promesse faite au compilateur, non une question. Ne l’utilisez que là où l’invariant d’exécution tient véritablement (un callback documenté comme s’exécutant sur le thread principal), car une promesse erronée est un crash, pas un avertissement.
6. Un travail lourd qui ne devrait pas être sur le main actor. Celle-ci, le vérificateur ne la signalera pas, et c’est la plus importante à attraper vous-même. Sous une valeur par défaut sur le main actor, une méthode synchrone gourmande en CPU (un JSON décodant une charge utile volumineuse, le redimensionnement d’une image) s’exécute sur le main actor et fait sauter votre interface. La valeur par défaut vous garde sur le thread principal ; @concurrent est la façon de le quitter à dessein4 :
@concurrent
func decodeLargePayload(_ data: Data) async throws -> Report {
try JSONDecoder().decode(Report.self, from: data)
}
@concurrent décharge la fonction vers l’exécuteur global et est mutuellement exclusif avec @MainActor, un global actor personnalisé et nonisolated(nonsending), et ce par conception : une fonction soit s’exécute là où se trouve son appelant, soit s’en éloigne délibérément, jamais de manière ambiguë4. Toute la discipline qu’exige le nouveau modèle vit entièrement dans ce motif. Restez sur le main actor pour tout ce qui touche à l’interface, et ne recourez à @concurrent que pour le travail qui a mesurablement besoin d’un thread d’arrière-plan.
Quand les valeurs par défaut ne vous conviennent pas
Le main actor par défaut convient aux applications : le code SwiftUI et UIKit s’exécute massivement sur le thread principal, et la valeur par défaut épouse la réalité. Il convient moins bien dans deux cas, et prétendre le contraire vous fait perdre votre temps.
- Une cible de bibliothèque ou de framework sans interface. Une couche réseau, un parseur ou un moteur de données n’a aucune raison de s’isoler par défaut sur le main actor, et le faire impose
@concurrentounonisolatedsur presque tout. LaissezSWIFT_DEFAULT_ACTOR_ISOLATIONnon défini pour ces cibles et isolez délibérément, à l’ancienne, dans l’autre sens. - Un système concurrent riche en actors. Si votre conception exécute véritablement beaucoup de choses en parallèle (un pipeline réel, pas une application avec quelques tâches d’arrière-plan), la valeur par défaut sur le main actor vous combat. Vous voulez des actors explicites et du code nonisolated, et la valeur par défaut de SE-0466 est le mauvais point de départ.
Pour une application, en revanche, le choix est facile : activez les deux réglages, laissez le nombre d’erreurs s’effondrer, et traitez la poignée qui subsiste comme une carte indiquant exactement où votre code quitte le thread principal. Cette carte vaut la peine d’être possédée. L’ancien modèle vous donnait mille avertissements et aucune carte ; le nouveau vous donne six frontières honnêtes et une valeur par défaut qui correspond enfin à la façon dont l’application s’exécute.
Un dernier mot sur le bruit face au signal. SourceKit affichera des erreurs d’index inter-fichiers (« Cannot find type X in scope », « No such module ») dans l’éditeur pendant qu’Xcode reconstruit son index, surtout juste après avoir régénéré un projet. Ce sont des artefacts d’index, pas des erreurs de concurrence. Si xcodebuild rapporte BUILD SUCCEEDED, le modèle de concurrence est satisfait et l’éditeur ne fait que rattraper son retard1. Courir après les fantômes d’index est le moyen le plus rapide de gâcher un après-midi sur une migration qui marchait déjà.
-
Code de production de l’auteur dans les applications iOS de la famille 941 (Ki, Return, Get Bananas), toutes livrées avec
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActoretSWIFT_APPROACHABLE_CONCURRENCY = YES. Les six motifs d’erreur et leurs correctifs ci-dessous constituaient l’ensemble complet du nettoyage de concurrence stricte pour Ki 1.0.0. ↩↩↩ -
Swift Evolution, SE-0466 : Control default actor isolation inference. Permet à un module de s’isoler par défaut sur
@MainActor, de sorte que les cibles d’interface et d’application s’exécutent sur le main actor à moins que le code ne s’en exclue via@concurrentou un actor explicite. ↩↩ -
Swift Evolution, SE-0461 : Run nonisolated async functions on the caller’s actor by default. Une fonction
asyncnonisolated adopte par défautnonisolated(nonsending), s’exécutant sur l’actor de l’appelant au lieu de sauter vers l’exécuteur global, ce qui supprime les exigencesSendablede traversée de frontière qui accompagnaient le saut. ↩↩ -
Swift Evolution, SE-0461 introduit l’attribut
@concurrentpour faire qu’une fonction s’exécute sur l’exécuteur global (un thread d’arrière-plan).@concurrentetnonisolated(nonsending)sont les deux modes d’isolation opposés d’une fonction async nonisolated : une fonction soit s’exécute là où se trouve son appelant, soit s’en éloigne délibérément.@concurrentne peut pas être combiné avec@MainActorni avec un global actor personnalisé. ↩↩↩ -
SWIFT_APPROACHABLE_CONCURRENCYest le réglage de build Xcode global qui active les fonctionnalités à venir de l’approachable concurrency (y compris le comportement de SE-0461), etSWIFT_DEFAULT_ACTOR_ISOLATIONsélectionne l’isolation par défaut du module. Les nouveaux projets Xcode 26 activent les deux avec la valeur par défaut sur le main actor. Documenté dans Donny Wals, « Exploring concurrency changes in Swift 6.2 », et Paul Hudson, « What’s new in Swift 6.2 », tous deux recoupés avec les propositions sous-jacentes SE-0461 et SE-0466. ↩↩ -
Swift, Swift Concurrency Migration Guide, « Enabling Complete Concurrency Checking » et configuration du mode de langage. Dans un package Swift, l’isolation par défaut et les fonctionnalités d’approachable concurrency se définissent via les flags upcoming-feature de
swiftSettingsplutôt que par le réglage de build d’Xcode. ↩ -
Swift, Migration Guide : global actor isolation and
nonisolated. Les méthodes d’un type@MainActorhéritent de l’isolation sur le main actor ;nonisolateden exclut une méthode, ce qui est correct pour les fonctions pures qui ne touchent à aucun état isolé. ↩ -
Apple Developer,
MainActor.assumeIsolated(_:). Affirme que l’exécution courante est déjà sur le main actor et exécute la closure de manière synchrone, sans saut d’actor. L’assertion provoque un trap à l’exécution si l’invariant ne tient pas, donc elle n’est valide que là où l’appelant est garanti d’être sur le thread principal. ↩