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,