Aujourd’hui, nous allons explorer les Swift Actors. Pour commencer, comme d’habitude, je vais vous expliquer en détail comment les choses fonctionnent sous le capot. En effet, Swift 5.5 a introduit les Actors dans le cadre de son modèle de concurrence, principalement afin de prévenir les courses de données en garantissant un accès sécurisé aux états mutables partagés. C’est pourquoi, dans cet article, vous allez découvrir en profondeur non seulement les Actors eux mêmes, mais aussi les GlobalActors, le MainActor, ainsi que tout leur écosystème. Enfin, nous examinerons de plus comment le compilateur Swift, LLVM et le modèle de concurrence de Swift les gèrent en interne.
Avant même de commencer à écrire cet article, de nombreuses questions me trottaient dans la tête, et elles ont vraiment éveillé ma curiosité :
- Que sont les Actors et comment fonctionnent-ils en interne?
- Comment les Actors garantissent-ils l’isolation et empêchent-ils les courses de données ?
- Quel est le rôle des GlobalActors et du MainActor?
- Comment le compilateur Swift gère-t-il la concurrence basée sur les Actors?
- Quelle sont les bonnes pratiques, les pièges à éviter?
1. Que sont les Actors et comment fonctionnent-ils en interne?
Tout d’abord, un Actor en Swift est un type de référence, similaire à une classe, qui garantit un accès thread-safe à son état mutable en sérialisant les tâches via un exécuteur interne (queue / une file d’attente). Ainsi, il empêche les races conditions de concurrence en permettant à une seule tâche de modifier son état à la fois. En revanche, contrairement aux locks (verrous), les Actors utilisent la concurrence structurée, ce qui implique l’utilisation d’un await
pour un accès sécurisé. Par ailleurs, lorsqu’une fonction asynchrone est suspendue, l’Actor devient réentrant, permettant ainsi à d’autres tâches de s’exécuter pendant ce temps. Enfin, en interne, le runtime de Swift planifie les tâches de manière efficace, non seulement en garantissant une concurrence sûre, mais aussi une optimisation sans synchronisation manuelle.
1.1 Syntaxe et Bases des Actors
actor BankManager {
var balance: Double = 0.0
func deposit(amount: Double) {
balance += amount
}
func getBalance() -> Double {
return balance
}
}
Propriétés clés des Actors
✔️ Isolation: Une seule tâche peut accéder à l’état mutable à la fois.
✔️ Concurrency-safe: Empêche les courses de données (data race) sans nécessiter de verrous (locks).
✔️ Reference Type: Comme une classe, mais il impose l’isolation.
1.2 Comment appeler les méthodes d’un Actor
Comme les Actors sont isolés, l’appel de leurs méthodes depuis l’extérieur nécessite l’utilisation de await pour une exécution asynchrone :
let manager = BankManager()
Task {
await manager.deposit(amount: 100.0)
print(await manager.getBalance()) // 100.0
}
💡 À retenir:
- L’appel direct des méthodes d’un Actor nécessite await, car elles s’exécutent de manière asynchrone.
- Les propriétés Immutable peuvent être accédées de manière synchrone.:
actor Counter {
let name: String = "MyCounter"
}
let counter = Counter()
print(counter.name) // ✅ Allowed, because `name` is immutable.
2. Comment les Actors garantissent-ils l’isolation et empêchent les courses de données (Data Race) ?
Tout d’abord, les Actors garantissent l’isolation et empêchent les courses de données en imposant un accès exclusif à leur état mutable. Concrètement, en interne, chaque Actor dispose d’un exécuteur dédié (queue) qui sérialise les tâches, permettant à une seule opération de modifier ses propriétés à la fois. Cependant, l’accès direct à l’état d’un Actor depuis l’extérieur est interdit, ce qui oblige à utiliser await
pour une interaction contrôlée. Ainsi, cela empêche plusieurs threads de lire et d’écrire simultanément, éliminant de ce fait les conditions de concurrence. Par ailleurs, la réentrance des Actors permet à d’autres tâches de s’exécuter pendant qu’un Actor est en attente d’une opération await
, assurant non seulement une efficacité, mais aussi une sécurité préservée.
2.1 Code non sécurisé:
class UnsafeCounter {
var value = 0
}
let counter = UnsafeCounter()
DispatchQueue.concurrentPerform(iterations: 10) {
counter.value += 1 // 🚨 Data race
}
💥 Le code ci-dessus est non sécurisé car plusieurs threads peuvent accéder à valeur
simultanément.
2.2 Comment les Actors garantissent la sécurité:
actor SafeCounter {
var value = 0
func increment() {
value += 1
}
}
let counter = SafeCounter()
Task {
await counter.increment() // ✅ Safe
}
Pourquoi est-ce sécurisé ?
Parce que Swift garantit que une seule tâche à la fois peut accéder à valeur
.
3. Quel est le rôle des GlobalActors et du MainActor ?
En Swift, GlobalActor
et @MainActor
sont utilisés pour gérer la concurrence en garantissant que des morceaux de code spécifiques s’exécutent sur un Actor désigné, aidant ainsi à prévenir les courses de données.
3.1 Que sont les GlobalActor ?
En substance, un GlobalActor
est un type spécial d’Actor qui synchronise systématiquement l’accès à travers plusieurs instances. Plus précisément, il permet de définir un Actor singleton qui fournit un contexte d’exécution unifié pour des fonctions, propriétés ou types spécifiques. En pratique, vous pouvez l’utiliser pour regrouper du code lié qui nécessite impérativement de s’exécuter sur le même Actor, notamment pour éviter les conflits de concurrence ou centraliser des ressources partagées.
@globalActor
struct MyGlobalActor {
static let shared = MyActor()
}
actor MyActor {
func doWork() {
print("Executing on MyActor")
}
}
@MyGlobalActor
func someFunction() {
print("This function runs on MyGlobalActor")
}
Commençons par MyGlobalActor
: il s’agit d’un GlobalActor personnalisé. En effet, toute fonction, propriété ou type annoté avec @MyGlobalActor
s’exécutera automatiquement dans le contexte de MyActor
. Cependant, il existe une limitation inhérente à sa conception (sans pour autant être un défaut) : un GlobalActor doit impérativement avoir une seule et unique instance partagée shared
. Plus précisément, vous ne pouvez pas déclarer plusieurs instances shared
au sein d’un @globalActor
. Par conséquent, le compilateur rejettera ce code, car il ne saura pas quel Actor prioriser dans ce cas.
3.2 Qu’est-ce qu’un MainActor ?
En premier lieu, le @MainActor
est un global actor intégré qui garantit que le code annoté s’exécute exclusivement sur le thread principal. Cette particularité le rend indispensable pour mettre à jour l’interface utilisateur, que ce soit dans des applications SwiftUI ou UIKit. Concrètement, vous pouvez l’utiliser dans deux cas principaux : soit pour gérer une logique spécifique liée à la vue, soit pour vous assurer qu’un code critique (comme des opérations de rendu) s’exécute de manière synchrone sur le thread principal.
@MainActor
class ViewModel {
var text: String = "Loading..."
func updateText() {
text = "Updated!"
}
}
Équivalent à :
class ViewModel {
@MainActor var text: String = "Loading..."
@MainActor func updateText() {
text = "Updated!"
}
}
Différences clés entre GlobalActor
et @MainActor
Fonctionnalité | GlobalActor | @MainActor |
---|---|---|
Objectif | Définit un contexte d’exécution global personnalisé | Garantit l’exécution sur le thread principal |
Personnalisation | Oui, vous pouvez créer plusieurs GlobalActors | Non, il est prédéfini |
Cas d’utilisation | Appliquer un ordre d’exécution pour les ressources partagées. | Mises à jour de l’interface utilisateur et opérations réservées au thread principal |
4. Comment le compilateur Swift gère-t-il la concurrence basée sur les Actors?
4.1 Le rôle de swiftc
(compilateur Swift) dans l’isolation des Actors
Le compilateur Swift (swiftc
) applique la concurrence basée sur les Actors en garantissant que tous les accès à l’état isolé d’un Actor sont correctement synchronisés. Pour ce faire, il s’appuie sur les règles d’isolation des Actors, un mécanisme qui empêche les courses de données tout en imposant une concurrence structurée. En pratique, lorsque vous marquez un type avec actor
, le compilateur restreint automatiquement l’accès direct à son état mutable depuis l’extérieur. Par ailleurs, il détecte et traite les fonctions nonisolated
, leur offrant ainsi la possibilité de s’exécuter hors du contexte d’exécution de l’Actor, si nécessaire. Enfin, grâce à une analyse statique rigoureuse, swiftc
assure qu’un seul contexte d’exécution modifie l’état de l’Actor à la fois, ce qui rend la concurrence à la fois plus sûre et plus prévisible.
Lorsque vous écrivez :
actor MyActor {
var count = 0
func increment() {
count += 1
}
}
Le compilateur Swift swiftc le transforme en quelque chose comme ceci :
class MyActor : public swift::Actor {
private:
int count;
public:
void increment() {
// Implicitly async-safe
count++;
}
}
Voici ce qui se passe étape par étape :
- Analyse d’isolation des Actors : Le compilateur s’assure que tous les accès aux propriétés mutables d’un Actor passent par des barrières asynchrones..
- Vérification de Sendable: Le compilateur impose que les états des Actors respectent le protocole Sendable pour un accès sécurisé entre threads.
- Modèle de passage de messages: En arrière-plan, les Actors utilisent un modèle de file d’exécution (run queue) similaire à une boucle d’événements (event loop).
4.2 Modèle d’exécution des Actors dans LLVM
Le modèle des Actors Swift repose sur un système d’exécution hautement optimisé, intégré à LLVM, pour gérer la concurrence avec une efficacité maximale. Contrairement aux mécanismes traditionnels de verrouillage/déverrouillage, ils adoptent plutôt un modèle de passage de messages similaire à Erlang ou aux canaux en Go qui assure qu’une seule tâche s’exécute à la fois au sein d’un Actor. Concrètement, chaque Actor dispose d’une file de tâches (Job Queue), organisée en FIFO (First-In, First-Out), garantissant un traitement séquentiel et ordonné. C’est précisément cette architecture qui élimine les risques de courses de données.
Lorsqu’une fonction asynchrone dans un Actor appelle await
, Swift active alors des continuations des blocs logiques s’appuyant sur les coroutines LLVM pour suspendre la tâche sans bloquer le thread. Une fois l’opération attendue terminée, la tâche reprend exactement où elle s’était interrompue. C’est dans ce mécanisme que réside la puissance et l’efficacité des Actors Swift : ils combinent suspension non-bloquante et reprise contextuelle, offrant ainsi une concurrence à la fois sûre et performante.
- Files de tâches (Job Queues) : Une file FIFO qui exécute les tâches une par une.
- Continuations : Swift utilise des coroutines basées sur LLVM (async/await) pour suspendre et reprendre les tâches.
- Verrous d’Actors ? Non ! Contrairement aux verrous traditionnels, les Actors Swift utilisent le passage de messages.
5. Quelle sont les bonnes pratiques, les pièges à éviter?
Si les Actors Swift contribuent efficacement à la sécurité en matière de concurrence, ils ne constituent pas pour autant une solution universelle. En effet, leur utilisation systématique peut entraîner des surcharges inutiles, notamment dans des scénarios à faible contention ou pour des opérations purement synchrones. C’est pourquoi comprendre quand les employer par exemple pour isoler un état mutable complexe et comment ils impactent les performances comme le coût de la sérialisation des tâches s’avère crucial pour écrire du code à la fois sûr et performant.
5.1 Quand utiliser les Actors ?
En résumé, les Actors s’avèrent particulièrement utiles lorsque plusieurs composants de votre application doivent accéder et modifier de manière sécurisée les mêmes données. En effet, comme mentionné précédemment, ils éliminent les conditions de concurrence en garantissant systématiquement qu’une seule tâche modifie les données à un instant donné. Ils sont particulièrement adaptés pour gérer des états mutables partagés (ex : configurations dynamiques), optimiser les caches, ou encore remplacer des mécanismes de verrouillage complexes (comme les NSLock
ou DispatchSemaphore
). Grâce à cette approche, vous simplifiez non seulement l’architecture concurrente, mais vous réduisez aussi les risques d’erreurs subtiles liées aux accès parallèles non contrôlés.
Scénario | Pourquoi ? |
---|---|
Protéger l’état mutable partagé | Empêcher les conditions de course |
Gérer les données en concurrence caches | Garantir la cohérence et éviter la corruption |
Éviter les verrous DispatchQueue | Plus sûr et plus simple que la synchronisation manuelle |
5.2 Quand les Actors sont-ils inutiles ?
Bien que les Actors garantissent la sécurité concurrentielle, ils introduisent néanmoins une surcharge, ce qui les rend contre-productifs dans des contextes où les performances sont critiques ou lorsque les données sont déjà immuables. Dans ces cas précis, privilégier une structure struct
qui peut remplir le même rôle sans exiger de synchronisation.
Scénario | Meilleure alternative |
---|---|
Opérations critiques pour la performance | Accès direct (sans actor) |
Données immuables | Utiliser une structure (struct) |
Pas besoin de coordination globale | Utiliser des variables locales |
5.3 Considérations de performance
Swift’s Actors fonctionnent un model de par passage de messages, ce qui signifie que chaque tâche doit attendre son tour dans une file d’attente. Toutefois, les appels fréquents entre Actors ralentissent les performances. Dans ce cas, une meilleure approche consiste à stocker les données fréquemment accédées à l’intérieur de l’Actor, ce qui réduit la communication inutile.
5.3.1 Mauvaise approche (Appels entre Actors inefficaces)
actor InvoiceGenerator {
func generateInvoice(for userId: String) async -> Invoice {
// ❌ Cross-actor call 1
let userDetails = await fetchUserDetails(userId: userId)
// ❌ Cross-actor call 2
let purchaseHistory = await fetchPurchaseHistory(userId: userId)
return Invoice(user: userDetails, purchases: purchaseHistory)
}
}
func processInvoices(userIds: [String]) async {
let generator = InvoiceGenerator()
for userId in userIds {
// ❌ Multiple calls to actor
let invoice = await generator.generateInvoice(for: userId)
print("Generated invoice for \(invoice.user.name)")
}
}
5.3.2 Meilleure approche (Regroupement des requêtes à l’intérieur de l’Actor)
actor InvoiceGenerator {
func generateInvoices(for userIds: [String]) async -> [Invoice] {
// ✅ Batch fetch
let userDetails = await getMultipleUserDetails(userIds: userIds)
// ✅ Batch fetch
let purchasesList = await getMultiplePurchases(userIds: userIds)
return userIds.compactMap { id in
guard let user = userDetails[id],
let purchases = purchasesList[id]
else { return nil }
return Invoice(user: user, purchases: purchases)
}
}
}
func processInvoices(userIds: [String]) async {
let generator = InvoiceGenerator()
// ✅ One call instead of multiple
let invoices = await generator.generateInvoices(for: userIds)
for invoice in invoices {
print("Generated invoice for \(invoice.user.name)")
}
}
Pourquoi est-ce mieux ?
- Un seul appel entre Actors est effectué, réduisant la surcharge.
- Les profils mis en cache sont accédés localement, évitant un travail inutile.
- Les profils manquants sont récupérés en un seul lot, améliorant les performances.
Conclusion
En résumé, les Actors empêchent efficacement les conditions de concurrence, mais leur utilisation doit rester judicieuse pour ne pas compromettre les performances. Pour cela, il est crucial de conserver les données fréquemment sollicitées au sein de l’Actor lui-même, tout en minimisant les interactions entre différents Actors. En parallèle, privilégiez les GlobalActors lorsque certaines tâches nécessitent systématiquement le même contexte d’exécution. En suivant ces principes, vous pourrez développer un code concurrent à la fois sûr et optimisé en Swift.
Cas d’utilisation | Acteur recommandé |
---|---|
Mises à jour de l’interface utilisateur dans SwiftUI | @MainActor |
Traitement en arrière-plan | actor Régulier |
Logging, état global | @GlobalActor Personnalisé |
Sources
Pour approfondir votre compréhension des Actors et de la concurrence en Swift, vous pouvez explorer les ressources suivantes :
- La documentation officielle de Swift sur la concurrence fournit un aperçu complet des Actors, async/await et de la concurrence structurée.
- La proposition Swift Evolution pour les Actors (SE-0306) explique la conception et les détails d’implémentation du modèle d’Actors de Swift.
- Apprenez-en plus sur les mécanismes sous-jacents dans la documentation LLVM Coroutines, qui alimente l’async/await et la suspension des tâches en Swift.
- Pour une explication visuelle et détaillée, regardez la vidéo WWDC 2021 sur la concurrence en Swift, où les ingénieurs d’Apple expliquent comment les Actors et async/await fonctionnent ensemble.