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
ObjectifDéfinit un contexte d’exécution global personnaliséGarantit l’exécution sur le thread principal
PersonnalisationOui, vous pouvez créer plusieurs GlobalActorsNon, il est prédéfini
Cas d’utilisationAppliquer 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 :

  1. 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..
  2. Vérification de Sendable: Le compilateur impose que les états des Actors respectent le protocole Sendable pour un accès sécurisé entre threads.
  3. 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énarioPourquoi ?
Protéger l’état mutable partagéEmpêcher les conditions de course
Gérer les données en concurrence cachesGarantir la cohérence et éviter la corruption
Éviter les verrous DispatchQueuePlus sûr et plus simple que la synchronisation manuelle
✅  Quand il faut utiliser les Actors

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énarioMeilleure alternative
Opérations critiques pour la performanceAccès direct (sans actor)
Données immuablesUtiliser une structure (struct)
Pas besoin de coordination globaleUtiliser des variables locales
❌ Quand les acteurs ne sont-ils inutiles ?

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’utilisationActeur recommandé
Mises à jour de l’interface utilisateur dans SwiftUI@MainActor
Traitement en arrière-planactor 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 :

Categorized in:

Swift,