Introduction : la concurrence en Swift, toute une histoire
Si vous développez pour les plateformes Apple, vous le savez probablement déjà : la concurrence en Swift a toujours été un sujet… disons, épineux. Depuis l'arrivée d'async/await dans Swift 5.5, le langage a fait un bond considérable en matière de programmation concurrente. Mais soyons honnêtes — les versions Swift 6.0 et 6.1 ont aussi apporté leur lot de frustrations. Des annotations @Sendable un peu partout, des erreurs de compilation obscures liées à l'isolation des acteurs, et une courbe d'apprentissage qui décourageait même les développeurs chevronnés.
Swift 6.2 change la donne.
Apple appelle ça l'Approachable Concurrency — la « concurrence accessible ». L'idée de base est simple mais ambitieuse : rendre la programmation concurrente progressive. Vous commencez par écrire du code séquentiel classique, et vous n'introduisez la concurrence que lorsque vous en avez vraiment besoin. Le compilateur continue de vous protéger, mais il arrête de vous harceler avec des avertissements à chaque ligne.
Allez, on plonge dans le vif du sujet. Dans cet article, on va explorer en profondeur toutes les nouveautés de la concurrence dans Swift 6.2 : l'isolation par défaut au MainActor, nonisolated(nonsending), l'attribut @concurrent, et les cinq propositions d'évolution qui forment le socle de cette transformation. Avec des exemples de code concrets et des conseils de migration, vous aurez tout ce qu'il faut pour moderniser vos projets.
Le problème : pourquoi la concurrence Swift était-elle si pénible ?
Avant de plonger dans les nouveautés, prenons un moment pour comprendre pourquoi Swift 6.0 et 6.1 ont rendu la vie si compliquée. Ce contexte est essentiel pour apprécier la portée des changements dans Swift 6.2.
L'explosion des annotations
Avec le mode strict de concurrence activé dans Swift 6.0, il fallait annoter explicitement quasiment tout. Chaque classe liée à l'interface utilisateur nécessitait un @MainActor. Chaque fermeture traversant une frontière de concurrence devait être @Sendable. Le résultat ? Un code noyé sous les annotations, franchement difficile à lire et à maintenir.
// Swift 6.0/6.1 — annotations omniprésentes
@MainActor
class ProfileViewModel: ObservableObject {
@Published var userName: String = ""
@Published var isLoading: Bool = false
func loadProfile() async {
isLoading = true
let profile = await fetchProfile()
userName = profile.name
isLoading = false
}
nonisolated func fetchProfile() async -> Profile {
// Cette fonction s'exécute sur le pool de threads global
// Comportement implicite et parfois surprenant
let data = try? await URLSession.shared.data(from: profileURL)
// ...
return Profile(name: "Jean")
}
}
L'incohérence du comportement nonisolated
Le problème le plus déroutant (et croyez-moi, il a fait perdre des heures à beaucoup de monde) venait du comportement incohérent de nonisolated selon que la fonction était asynchrone ou non :
- Fonction nonisolated synchrone : s'exécutait sur l'acteur de l'appelant (comportement intuitif)
- Fonction nonisolated asynchrone : s'exécutait automatiquement sur le pool de threads global (comportement… surprenant)
Cette asymétrie était source de bugs subtils. Un développeur pouvait raisonnablement s'attendre à ce qu'une fonction nonisolated reste sur le thread de son appelant. Mais dès qu'il ajoutait async, le comportement changeait silencieusement. Des opérations qui semblaient s'exécuter sur le thread principal se retrouvaient soudainement en arrière-plan, provoquant des mises à jour d'interface hors du thread principal et des crashs difficiles à diagnostiquer.
Le fardeau de Sendable
Le protocole Sendable est fondamentalement une bonne idée : il garantit qu'un type peut être transféré en toute sécurité entre différents contextes de concurrence. Mais en pratique, le compilateur exigeait Sendable partout, même quand les données ne traversaient jamais réellement de frontière de concurrence. Résultat : des dizaines d'avertissements pour du code parfaitement sûr. Frustrant.
Approachable Concurrency : la philosophie derrière tout ça
L'Approachable Concurrency repose sur un principe clé : la divulgation progressive (progressive disclosure). Ce concept, bien connu en design d'interfaces, signifie que les fonctionnalités complexes ne sont présentées à l'utilisateur que lorsqu'il en a besoin. Appliqué à la concurrence Swift, ça donne :
- Par défaut, tout s'exécute sur le MainActor — vous écrivez du code séquentiel classique, sans vous soucier de la concurrence.
- Vous introduisez la concurrence uniquement quand c'est nécessaire — en utilisant
@concurrentpour déplacer explicitement du travail en arrière-plan. - Le compilateur vous guide — au lieu de vous assommer d'erreurs, il vous indique précisément où et comment gérer la concurrence.
Cette approche est particulièrement pertinente pour les applications iOS et macOS, où — soyons réalistes — 80 à 90 % du code est lié à l'interface utilisateur et doit de toute façon s'exécuter sur le thread principal. Pourquoi obliger les développeurs à annoter chaque classe avec @MainActor alors que c'est le comportement souhaité dans la grande majorité des cas ?
Isolation par défaut au MainActor (SE-0466)
La proposition SE-0466 est probablement le changement le plus impactant de Swift 6.2. Elle introduit un nouveau réglage du compilateur — defaultIsolation — qui permet de définir l'isolation par défaut de tout un module.
Comment l'activer
Dans un projet Xcode 26, ce réglage est activé par défaut pour les nouvelles cibles d'application. Pour les packages Swift, ajoutez le réglage dans votre fichier Package.swift :
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "MonApplication",
platforms: [.iOS(.v18), .macOS(.v15)],
targets: [
.target(
name: "MonApplication",
swiftSettings: [
.defaultIsolation(MainActor.self)
]
),
.testTarget(
name: "MonApplicationTests",
dependencies: ["MonApplication"],
swiftSettings: [
.defaultIsolation(MainActor.self)
]
)
]
)
Ce que ça change concrètement
Une fois activé, toutes les déclarations de votre module sont implicitement isolées au MainActor, sauf indication contraire. En clair :
// AVANT Swift 6.2 — annotation obligatoire
@MainActor
class ProfileViewModel: ObservableObject {
@Published var userName: String = ""
func updateUI() {
// Garanti sur le thread principal grâce à @MainActor
}
}
// APRÈS Swift 6.2 avec defaultIsolation(MainActor.self)
// Plus besoin de @MainActor : c'est le comportement par défaut !
class ProfileViewModel: ObservableObject {
@Published var userName: String = ""
func updateUI() {
// Automatiquement sur le thread principal
}
}
Ce changement élimine des dizaines, voire des centaines d'annotations @MainActor dans un projet typique. Le code devient plus lisible, plus propre, et l'intention est plus claire : vous ne marquez explicitement que les exceptions — le code qui doit s'exécuter ailleurs que sur le thread principal.
Les valeurs possibles
Le réglage defaultIsolation accepte deux valeurs :
.defaultIsolation(MainActor.self)— tout est isolé au MainActor par défaut.defaultIsolation(nil)— retour au comportement classique sans isolation par défaut (nonisolated)
Pour les bibliothèques et les packages destinés à être utilisés dans différents contextes, il est souvent préférable de ne pas activer l'isolation par défaut au MainActor. En revanche, pour les cibles d'application, c'est une évidence.
Impact sur les protocoles et l'héritage
L'isolation par défaut s'applique aussi aux conformités de protocoles et à l'héritage de classes. Si votre classe se conforme à un protocole défini dans un module sans isolation par défaut, le compilateur gère la transition intelligemment :
// Protocole défini dans un module externe (sans defaultIsolation)
protocol DataFetching {
func fetchData() async throws -> Data
}
// Dans votre module avec defaultIsolation(MainActor.self)
class NetworkManager: DataFetching {
// fetchData() est implicitement @MainActor
// Le compilateur peut inférer la conformité isolée
// grâce à SE-0470 (InferIsolatedConformances)
func fetchData() async throws -> Data {
let (data, _) = try await URLSession.shared.data(
from: URL(string: "https://api.example.com/data")!
)
return data
}
}
nonisolated(nonsending) : enfin un comportement cohérent (SE-0461)
La proposition SE-0461 corrige l'incohérence historique du comportement nonisolated qu'on a décrite plus haut. Le nouveau mot-clé nonisolated(nonsending) garantit que la fonction s'exécute toujours sur l'exécuteur de l'appelant, qu'elle soit synchrone ou asynchrone.
Comprendre le changement
// Comportement AVANT Swift 6.2
class DataService {
nonisolated func syncOperation() {
// S'exécute sur l'acteur de l'appelant ✓
}
nonisolated func asyncOperation() async {
// S'exécute sur le pool de threads global ✗ (surprise !)
}
}
// Comportement APRÈS Swift 6.2 avec NonisolatedNonsendingByDefault
class DataService {
nonisolated func syncOperation() {
// S'exécute sur l'acteur de l'appelant ✓
}
nonisolated func asyncOperation() async {
// S'exécute aussi sur l'acteur de l'appelant ✓ (cohérent !)
}
}
En activant le flag NonisolatedNonsendingByDefault, toute fonction marquée nonisolated (ou implicitement nonisolated) est traitée comme nonisolated(nonsending). Concrètement, elle hérite de l'exécuteur de son appelant au lieu de sauter automatiquement sur un thread en arrière-plan.
Pourquoi « nonsending » ?
Le terme « nonsending » fait référence au fait que la fonction ne nécessite pas que ses paramètres soient Sendable. Puisqu'elle s'exécute sur le même exécuteur que l'appelant, il n'y a pas de transfert entre domaines de concurrence — et donc pas besoin de la contrainte Sendable. C'est un avantage majeur en termes de simplicité.
Activer le comportement par défaut
Pour activer ce comportement dans votre package :
// swift-tools-version: 6.2
.target(
name: "MonModule",
swiftSettings: [
.enableUpcomingFeature("NonisolatedNonsendingByDefault")
]
)
Dans Xcode 26, ce réglage s'active via les paramètres de build sous Swift Compiler > Upcoming Features.
Utilisation explicite
Même sans le flag activé globalement, vous pouvez utiliser nonisolated(nonsending) de manière explicite sur des fonctions individuelles :
class ImageProcessor {
// Cette fonction reste sur l'exécuteur de l'appelant
nonisolated(nonsending) func processImage(_ image: UIImage) async -> UIImage {
// Le traitement se fait sur le même thread que l'appelant
// Pas de changement de contexte, pas de coût de Sendable
let filtered = await applyFilters(to: image)
return filtered
}
}
@concurrent : quand vous avez vraiment besoin d'un thread en arrière-plan (SE-0461)
OK, si nonisolated(nonsending) est le nouveau comportement par défaut, comment on fait lorsqu'on veut explicitement exécuter du code en arrière-plan ? C'est là qu'intervient @concurrent.
Le rôle de @concurrent
@concurrent marque une fonction pour qu'elle quitte l'acteur de l'appelant et s'exécute sur l'exécuteur global (le pool de threads coopératif). C'est l'exact opposé de nonisolated(nonsending). Quand vous marquez une fonction avec @concurrent, elle est automatiquement nonisolated — pas besoin de le spécifier en plus.
class DataManager {
// Cette fonction s'exécute en arrière-plan
@concurrent
func decodeJSON<T: Decodable>(_ data: Data) async throws -> T {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(T.self, from: data)
}
// Cette fonction reste sur l'acteur de l'appelant (MainActor par défaut)
func loadAndDisplay() async {
do {
let data = try await fetchData()
// Le décodage se fait en arrière-plan grâce à @concurrent
let users: [User] = try await decodeJSON(data)
// De retour sur le MainActor pour la mise à jour de l'UI
self.users = users
} catch {
self.errorMessage = error.localizedDescription
}
}
}
Quand utiliser @concurrent ?
Il est tentant de tout marquer @concurrent pour « optimiser les performances ». Résistez à cette tentation ! L'introduction de concurrence a un coût : changement de contexte entre threads, nécessité de rendre les données Sendable, et complexité accrue.
Utilisez @concurrent uniquement dans ces cas :
- Travail CPU intensif : décodage JSON volumineux, traitement d'images, calculs mathématiques complexes
- Opérations bloquantes : accès au système de fichiers, opérations de base de données synchrones
- Traitements parallèles : quand vous avez besoin de distribuer du travail sur plusieurs cœurs
En revanche, n'utilisez pas @concurrent pour :
- Les appels réseau :
URLSessiongère déjà l'asynchronie viaawait— aucun thread n'est bloqué - Les opérations légères : le surcoût du changement de contexte annulerait les bénéfices
- Le code UI : il doit rester sur le MainActor, point
@concurrent et Sendable
Un point crucial à garder en tête : lorsque vous marquez une fonction @concurrent, elle crée une nouvelle frontière d'isolation. Les paramètres passés doivent donc être Sendable, et les valeurs capturées aussi :
class ImageService {
@concurrent
func resize(_ image: UIImage, to size: CGSize) async -> UIImage {
// UIImage est Sendable, donc pas de problème
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: size))
}
}
// ⚠️ Ceci ne compilerait PAS si CustomData n'est pas Sendable
// @concurrent
// func process(_ data: CustomData) async -> Result { ... }
}
Les cinq propositions derrière l'Approachable Concurrency
L'Approachable Concurrency n'est pas un changement unique mais un ensemble coordonné de cinq propositions d'évolution du langage. Chacune s'attaque à un aspect spécifique de la complexité. Passons-les en revue.
1. DisableOutwardActorInference (SE-0401)
Cette proposition supprime l'inférence automatique de l'isolation d'acteur à partir des property wrappers. Avant Swift 6.2, un property wrapper comme @StateObject pouvait implicitement rendre toute la classe @MainActor — un comportement pour le moins surprenant :
// AVANT : @StateObject inférait @MainActor sur toute la classe
class MonViewModel: ObservableObject {
@Published var compteur = 0
// Toute la classe devient implicitement @MainActor
// à cause de @Published... comportement inattendu !
}
// APRÈS (SE-0401) : l'isolation doit être explicite
// Avec defaultIsolation, c'est automatiquement MainActor de toute façon
class MonViewModel: ObservableObject {
@Published var compteur = 0
// L'isolation vient de defaultIsolation, pas du property wrapper
}
2. GlobalActorIsolatedTypesUsability (SE-0434)
Cette proposition simplifie considérablement le travail avec les types isolés à un acteur global comme @MainActor. Parmi les améliorations :
- Accès plus facile aux propriétés
Sendabled'un type isolé à un acteur - Traitement automatique de certaines fermetures comme
@Sendable - Capture sûre de valeurs non-
Sendabledans des fermetures isolées
@MainActor
class ConfigManager {
let apiKey: String = "abc123" // Sendable et immuable
var cache: [String: Data] = [:] // Non-Sendable
}
// AVANT : accéder à apiKey depuis un contexte non-MainActor
// générait un avertissement, même si String est Sendable et let
// APRÈS (SE-0434) : accès autorisé sans avertissement
func readConfig(manager: ConfigManager) {
let key = manager.apiKey // ✓ OK : String est Sendable et immuable
}
3. InferIsolatedConformances (SE-0470)
SE-0470 permet aux conformités de protocoles d'être inférées comme isolées à un acteur. C'est particulièrement utile quand une classe @MainActor se conforme à un protocole :
protocol Refreshable {
func refresh() async
}
// Avec SE-0470, cette conformité est automatiquement
// inférée comme @MainActor
class FeedViewModel: Refreshable {
var items: [FeedItem] = []
func refresh() async {
// Cette méthode est @MainActor grâce à l'inférence
let newItems = try? await fetchFeedItems()
items = newItems ?? []
}
}
4. InferSendableFromCaptures (SE-0418)
Celle-ci va vous plaire. Cette proposition réduit considérablement le bruit lié à @Sendable. Le compilateur peut désormais inférer automatiquement qu'une fermeture est @Sendable en analysant ses captures :
func traiterDonnees(ids: [Int]) async {
// AVANT : il fallait marquer la fermeture @Sendable explicitement
// await withTaskGroup(of: Void.self) { group in
// for id in ids {
// group.addTask { @Sendable in
// await processItem(id)
// }
// }
// }
// APRÈS (SE-0418) : @Sendable inféré automatiquement
await withTaskGroup(of: Void.self) { group in
for id in ids {
group.addTask {
await processItem(id) // Int est Sendable → inférence OK
}
}
}
}
5. NonisolatedNonsendingByDefault (SE-0461)
On a déjà couvert cette proposition en détail. Pour résumer : les fonctions nonisolated async héritent maintenant de l'exécuteur de l'appelant au lieu de sauter sur le pool de threads global. C'est la pièce centrale du puzzle.
Guide de migration : passer à l'Approachable Concurrency
Migrer un projet existant vers les nouvelles fonctionnalités de concurrence de Swift 6.2 demande une approche méthodique. Pas de panique, voici un guide étape par étape.
Étape 1 : Mettre à jour la version des outils Swift
Assurez-vous que votre Package.swift utilise Swift 6.2 :
// swift-tools-version: 6.2
Étape 2 : Activer les fonctionnalités une par une
C'est la recommandation officielle d'Apple (et honnêtement, c'est un bon conseil) : n'activez pas tout d'un coup. Activez chaque fonctionnalité individuellement pour comprendre son impact sur votre code :
.target(
name: "MonApplication",
swiftSettings: [
// Commencez par celles-ci — faible impact
.enableUpcomingFeature("DisableOutwardActorInference"),
.enableUpcomingFeature("InferSendableFromCaptures"),
// Puis ajoutez celles-ci
.enableUpcomingFeature("GlobalActorIsolatedTypesUsability"),
.enableUpcomingFeature("InferIsolatedConformances"),
// En dernier — changement de comportement le plus significatif
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
// Optionnel : isolation MainActor par défaut
.defaultIsolation(MainActor.self)
]
)
Étape 3 : Identifier le code qui doit rester concurrent
Avant d'activer NonisolatedNonsendingByDefault, identifiez les fonctions qui s'exécutaient intentionnellement en arrière-plan. Avec le nouveau comportement par défaut, ces fonctions vont maintenant s'exécuter sur l'acteur de l'appelant. Si elles font du travail CPU intensif, ça pourrait bloquer le thread principal.
class AnalyticsService {
// AVANT : s'exécutait automatiquement en arrière-plan
nonisolated func processEvents(_ events: [AnalyticsEvent]) async {
// Traitement lourd...
}
// APRÈS : ajoutez @concurrent pour préserver le comportement
@concurrent
func processEvents(_ events: [AnalyticsEvent]) async {
// Continue de s'exécuter en arrière-plan
}
}
Étape 4 : Utiliser l'outil de migration Xcode
Xcode 26 intègre un outil de migration qui analyse votre code et propose automatiquement d'ajouter @concurrent aux fonctions qui en ont besoin. Utilisez-le comme point de départ, mais vérifiez chaque suggestion manuellement — l'outil n'est pas parfait.
Étape 5 : Nettoyer les annotations devenues inutiles
Avec defaultIsolation(MainActor.self) activé, vous pouvez enfin supprimer les annotations @MainActor redondantes :
// Supprimez les @MainActor explicites désormais redondants
// @MainActor ← plus nécessaire
class SettingsViewModel: ObservableObject {
@Published var theme: Theme = .system
@Published var notifications: Bool = true
func saveSettings() async {
// Déjà sur le MainActor grâce à defaultIsolation
let settings = Settings(theme: theme, notifications: notifications)
await persistSettings(settings)
}
}
Exemple complet : une application moderne avec Swift 6.2
Mettons tout ensemble avec un exemple concret. On va construire une galerie photo qui utilise pleinement les nouvelles fonctionnalités de concurrence de Swift 6.2.
// Package.swift
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "PhotoGallery",
platforms: [.iOS(.v18)],
targets: [
.target(
name: "PhotoGallery",
swiftSettings: [
.defaultIsolation(MainActor.self),
.enableUpcomingFeature("NonisolatedNonsendingByDefault")
]
)
]
)
// Models.swift
// Pas besoin de @MainActor — c'est le défaut !
struct Photo: Codable, Sendable, Identifiable {
let id: UUID
let title: String
let url: URL
let thumbnailUrl: URL
let width: Int
let height: Int
}
struct PhotoCollection: Codable, Sendable {
let photos: [Photo]
let totalCount: Int
let page: Int
}
// NetworkClient.swift
class NetworkClient {
private let session: URLSession
private let baseURL: URL
init(baseURL: URL, session: URLSession = .shared) {
self.baseURL = baseURL
self.session = session
}
// Pas besoin de @concurrent : URLSession.data est déjà async
// La fonction ne bloque aucun thread
func fetchData(from endpoint: String) async throws -> Data {
let url = baseURL.appendingPathComponent(endpoint)
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw NetworkError.invalidResponse
}
return data
}
// @concurrent : le décodage JSON peut être coûteux
// pour de grandes collections
@concurrent
func decode<T: Decodable>(_ data: Data) async throws -> T {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(T.self, from: data)
}
}
enum NetworkError: Error, Sendable {
case invalidResponse
case decodingFailed
}
// PhotoRepository.swift
class PhotoRepository {
private let client: NetworkClient
init(client: NetworkClient) {
self.client = client
}
func fetchPhotos(page: Int) async throws -> PhotoCollection {
let data = try await client.fetchData(from: "photos?page=\(page)")
return try await client.decode(data)
}
// @concurrent pour le traitement d'image en parallèle
@concurrent
func generateThumbnails(for photos: [Photo]) async -> [UUID: Data] {
var thumbnails: [UUID: Data] = [:]
await withTaskGroup(of: (UUID, Data?).self) { group in
for photo in photos {
group.addTask {
let data = try? await URLSession.shared.data(
from: photo.thumbnailUrl
).0
return (photo.id, data)
}
}
for await (id, data) in group {
if let data {
thumbnails[id] = data
}
}
}
return thumbnails
}
}
// GalleryViewModel.swift
import SwiftUI
// Pas de @MainActor nécessaire !
class GalleryViewModel: ObservableObject {
@Published var photos: [Photo] = []
@Published var isLoading = false
@Published var errorMessage: String?
@Published private(set) var currentPage = 1
@Published private(set) var hasMorePages = true
private let repository: PhotoRepository
init(repository: PhotoRepository) {
self.repository = repository
}
func loadPhotos() async {
guard !isLoading else { return }
isLoading = true
errorMessage = nil
do {
let collection = try await repository.fetchPhotos(page: currentPage)
photos.append(contentsOf: collection.photos)
hasMorePages = photos.count < collection.totalCount
currentPage += 1
} catch {
errorMessage = "Impossible de charger les photos : \(error.localizedDescription)"
}
isLoading = false
// Toutes les mises à jour de @Published sont sur le MainActor
// — pas de crash lié au thread principal !
}
func refresh() async {
photos = []
currentPage = 1
hasMorePages = true
await loadPhotos()
}
}
// GalleryView.swift
import SwiftUI
struct GalleryView: View {
@StateObject private var viewModel: GalleryViewModel
init(repository: PhotoRepository) {
_viewModel = StateObject(
wrappedValue: GalleryViewModel(repository: repository)
)
}
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(
columns: [
GridItem(.adaptive(minimum: 150))
],
spacing: 8
) {
ForEach(viewModel.photos) { photo in
PhotoCard(photo: photo)
.task {
if photo.id == viewModel.photos.last?.id,
viewModel.hasMorePages {
await viewModel.loadPhotos()
}
}
}
}
.padding()
}
.navigationTitle("Galerie")
.refreshable {
await viewModel.refresh()
}
.overlay {
if viewModel.isLoading && viewModel.photos.isEmpty {
ProgressView("Chargement...")
}
}
.alert(
"Erreur",
isPresented: .constant(viewModel.errorMessage != nil)
) {
Button("Réessayer") {
Task { await viewModel.loadPhotos() }
}
} message: {
Text(viewModel.errorMessage ?? "")
}
}
.task {
await viewModel.loadPhotos()
}
}
}
Pièges courants et bonnes pratiques
Adopter l'Approachable Concurrency de Swift 6.2 représente un vrai changement de paradigme. Voici les erreurs les plus fréquentes que j'ai pu observer (et comment les éviter).
Piège n°1 : Abuser de @concurrent
Quand on découvre @concurrent, la tentation est grande de tout marquer pour « aller plus vite ». En réalité, chaque utilisation de @concurrent crée une nouvelle frontière d'isolation, ce qui impose des contraintes Sendable et ajoute le surcoût d'un changement de contexte. Parfois, ça ralentit les choses au lieu de les accélérer.
// ❌ MAUVAIS : @concurrent inutile
class UserService {
@concurrent
func getCurrentUser() async -> User? {
// Un simple accès réseau via await
// URLSession gère déjà l'asynchronie !
return try? await fetchUser(id: currentUserId)
}
}
// ✓ BON : laisser la fonction hériter de l'acteur de l'appelant
class UserService {
func getCurrentUser() async -> User? {
return try? await fetchUser(id: currentUserId)
}
}
Piège n°2 : Oublier les implications pour les bibliothèques
Si vous développez une bibliothèque, activer defaultIsolation(MainActor.self) forcerait tous les consommateurs à travailler avec des types isolés au MainActor. C'est rarement ce que vous voulez. Réservez cette option aux cibles d'application.
Piège n°3 : Migrer tout d'un coup
Activer toutes les fonctionnalités simultanément peut provoquer des changements de comportement subtils. Une fonction qui s'exécutait en arrière-plan se retrouve soudainement sur le thread principal — et si elle fait du travail CPU intensif, votre UI va freezer.
// ⚠️ ATTENTION : cette fonction s'exécutait en arrière-plan
// avant le flag NonisolatedNonsendingByDefault
nonisolated func compressImage(_ image: UIImage) async -> Data? {
// Travail CPU intensif !
// Avec le nouveau comportement, cela bloquera le MainActor
return image.jpegData(compressionQuality: 0.3)
}
// ✓ SOLUTION : ajouter @concurrent
@concurrent
func compressImage(_ image: UIImage) async -> Data? {
return image.jpegData(compressionQuality: 0.3)
}
Piège n°4 : Confondre nonisolated et @concurrent
La différence fondamentale, en une phrase :
nonisolated(avecNonisolatedNonsendingByDefault) : « je n'ai pas d'isolation propre, je reste chez mon appelant »@concurrent: « je quitte activement l'acteur de mon appelant pour m'exécuter en arrière-plan »
Bonne pratique : structurer son code en couches
L'architecture idéale avec Swift 6.2 sépare clairement les responsabilités :
- Couche présentation (ViewModels, Views) — sur le MainActor par défaut, parfait avec
defaultIsolation - Couche domaine (cas d'utilisation, logique métier) — généralement aussi sur le MainActor, car les opérations async comme les appels réseau ne bloquent pas
- Couche traitement intensif (décodage lourd, traitement d'images, calculs) — marquée
@concurrentpour s'exécuter en arrière-plan
Comparaison avec d'autres langages
L'approche de Swift 6.2 est assez unique dans l'écosystème des langages modernes. Là où Rust impose un modèle de propriété strict dès le départ, et où Kotlin laisse une grande liberté avec les coroutines, Swift choisit un chemin intermédiaire :
- Kotlin Coroutines : flexibles mais sans vérification à la compilation des frontières de concurrence — les bugs de data race restent possibles
- Rust async : extrêmement sûr mais avec une courbe d'apprentissage abrupte (on ne va pas se mentir)
- Swift 6.2 : sécurité vérifiée à la compilation avec une entrée progressive — vous commencez simple et ajoutez de la complexité à la demande
L'Approachable Concurrency reconnaît une vérité pragmatique : la plupart du code d'application n'a pas besoin de concurrence. En rendant le thread principal le défaut, Swift aligne enfin le langage avec la réalité du développement d'apps.
Conclusion : un vrai tournant pour le développement Swift
Swift 6.2 et son Approachable Concurrency représentent un changement de philosophie majeur. Au lieu de forcer les développeurs à comprendre la concurrence pour écrire la moindre ligne de code, le langage propose désormais un chemin progressif : commencez par du code simple sur le thread principal, puis introduisez la concurrence de manière ciblée là où c'est nécessaire.
Les cinq propositions d'évolution — SE-0401, SE-0418, SE-0434, SE-0461, et SE-0470 — forment un ensemble cohérent qui élimine les principales sources de friction. L'isolation par défaut au MainActor via SE-0466 réduit drastiquement le nombre d'annotations. Et les nouveaux mots-clés nonisolated(nonsending) et @concurrent donnent un contrôle explicite et prévisible sur le comportement de vos fonctions.
Pour migrer vos projets existants, la clé est la progressivité : activez les fonctionnalités une par une, identifiez le code qui doit rester concurrent, et utilisez les outils de migration d'Xcode 26. Le résultat ? Un code plus propre, plus sûr, et — paradoxalement — plus facile à raisonner, même pour les scénarios de concurrence complexes.
La concurrence en Swift n'a jamais été aussi accessible. C'est le moment de mettre à jour vos projets.