Depuis la version 5.5 de Swift, Apple a introduit un nouveau type de données dit Actor. Le nouveau type permet d’assurer que l’état d’un objet de type référence ne peut pas être modifié que par un seul thread. Cela permet d’assurer l’atomicité des opérations et d’éviter les problèmes dit “Data Races” ou accès concurrentiel à une ressource partagée. Cette solution ne constitue pas une nouveauté car elles existent d’autres bien anciennes et connues comme les mutex, les sémaphores, les moniteurs etc.. (toutes ses mécanismes sont inclus par les API Lock et GCD au niveau de swift). La nouveauté , que Apple a crée un nouveau type qui abstrait derrière toutes les solutions qu’on a évoqué et qui assure une serialisation des actions dans un environnement multi-threading. Cette article constitue un peu un “état de l’art” des articles et des vidéos que j’ai lu et que j’ai regardé sur ce sujet et ne constitue pas une nouveauté en termes des connaissances.

Je commencerai tout d’abord par présenter les catégories des types de données en iOS. Puis Je présenterai avec des exemples réels des situations d’exclusion mutuelles concrètes et comment on peut les détecter avec Xcode. Ensuite , comment l’utilisation des actors peut résoudre les problèmes d’exclusion.

Référence vs copie(valeur)/ Heap vs Stack

Dans la majorité des langages de programmation, les types de données qu’on utilise se distingue par leur manière de stockage au niveau de la mémoire centrale. Cela influe ensuite sur comment on peut lire et surtout modifier nos données.

Swift distingue deux catégories de types de données selon ce critère. Ceux gérés par référence et d’autres par copie.

  • Les structures, les enum (tout type scalaire) sont en copie.
  • Les classes, les fonctions (y compris les closures) et maintenant les Actors sont en référence.

Au niveau mémoire, les types dits par copie ou valeur sont stockés au niveau de la pile( stack) du programme. Par contre, ceux par référence existent dans la mémoire dynamique dite heap.

La mémoire heap permet une allocation dynamique des ressources au moment de l’exécution (Runtime). Par contre la pile est une mémoire statique (allocation se fait au moment de la compilation) limitée en terme de capacités mémoire par rapport à la heap. Cependant cette mémoire est thread safe, vu qu’on peut pas avoir deux threads qui accèdent à la même stack. En autre, au niveau de la pile, il n’y pas d’objet partagé. Cela, élimine la possibilité d’avoir le problème d’exclusion mutuelle (Data Races). Par contre, au niveau de la heap vu que la mémoire peut être partagée entre plusieurs threads, le risque d’avoir deux threads qui accèdent au même objet est fort probable. Cela se manifeste généralement au niveau des tests réels sur les appareils mais il y a des outils qui nous permettent de détecter à travers Xcode les data races que je vais montrer ensuite 🙂

le multithreading Heap vs Stack

Détection des Data races:

Nous avons ce code qui permet d’incrémenter un compteur puis de créer de créer un taskGroup pour 10000 appels de la méthode Increment.

class Counter {
    private var accounter = 0
    func increment() async {
        accounter += 1
        print("\(accounter) - \(Thread.current)")
    }
}

class AccounterViewModel {
    let counter = Counter()
    func printMessage() async {
        _ = await withTaskGroup(of: Void.self, body: { group -> Void in
            for _ in 1...10000 {
                group.addTask{ [weak self] in
                    await self?.counter.increment()
                }
            }
        })
    }
}

struct ContentView: View {
    var viewModel = AccounterViewModel()
    var body: some View {
        Text("Hello, world!")
            .padding()
            .task {
                await viewModel.printMessage()
            }
        
    }
}

Si vous exécutez ce code, vous allez voir que tout passe correctement à premier vu au niveau du terminal. Vous aurez un affichage normale de 1 à 10000. En effet, pour détecter qu’il y a un problème, il faut activer la détection des data races au niveau de Xcode de cette manière:

  • On sélectionner le schema de la target -> on clique sur éditer le schema -> on choisit l’action run -> puis l’option diagnostics -> on coche l’option thread Sanitizer
Activation du Thread Sanitizer

Maintenant, si vous exécutez une autre fois, vous aurez une alerte et un blocage d’exécution au niveau de deux thread (par exemple dans mon cas T1 et T6) qui veulent incrémenter en même temps la variable partagé accounter.

Détection de la data race

Le compilateur est généreux :), il nous indique clairement la ligne qui a déclenché le problème

indication du problème par Xcode

NB: L’activation de thread Sanitizer peut agir sur la performance de la machine. Il est recommandé de ne pas activer énormément et surtout pas sur la CI/CD. Pour plus d’informations sur ce sujet, vous pouvez consulter cet article.

Une solution avec GCD

Une première solution pour garantir l’exclusion mutuelle c’est d’utiliser le GCD. On peut utiliser un dispatch queue de type concurrent avec une barrier qui contrôle l’accès en écriture sur la variable accounter

 private var queue = DispatchQueue(label: "accounter", attributes: .concurrent)
    func increment() async {
        queue.sync(flags: .barrier) {
            accounter += 1
            print("\(accounter) - \(Thread.current)")
        }
    }

Utilisation des actors

Afin de résoudre ce problème, la solution est devenu très simple avec Apple, c’est juste de transformer notre class Counter vers un actor

actor Counter {
    private var accounter = 0
    func increment() {
        accounter += 1
        print("\(accounter) - \(Thread.current)")
    }
}

Un actor se comporte de la même manière qu’une classe (implémente un protocole, on peut l’étendre…) et il existe dans le heap vu qu’il est de type référence. Cependant on peut pas hériter d’un actor ce qui implique qu’on peut pas utiliser les annotations override, final, open et pas de convenience Initializer :).

RQ: La méthode increment ne contient pas async dans sa déclaration vu que toute fonction ou attribut existant dans un actor est par défaut async et isolé.

@MainActor

L’annotation @MainActor vient pour remplacer API GCD Dispatch.queue.async. Elle permet de s’assurer qu’un code qui agit sur la partie UI s’execute dans le main thread. J’ai modifié le code afin d’afficher les valeurs des compteurs de cette manière:

actor Counter {
    private var accounter = 0
    func increment() -> Int {
       accounter += 1
       return accounter
    }
}

class AccounterViewModel {
    let counter = Counter()
    func printMessage() async -> String {
        var finalString = ""
        _ = await withTaskGroup(of: Int.self, body: { group -> Void in
            for _ in 1...10000 {
                group.addTask{ [unowned self] in
                    let currentCounter = await self.counter.increment()
                    return currentCounter
                }
            }
            for await counterValue in group {
                finalString += "\(counterValue) \t"
            }
        })
        return finalString
    }
}

La méthode increment retourne un entier et chaque task crée va s’occuper de retourner un entier. Vous avez remarqué que le type de retour de chaque task du group est maintenant Int (withTaskGroup(of: Int.self). Ensuite, on parcourt notre groupe de valeur et on ajoute chaque valeur à la chaine finalString avec un petit espace :). Maintenant, on passera à la modification du contentView

struct ContentView: View {
    var viewModel = AccounterViewModel()
    @State var text: String?
    var body: some View {
        Text( text ?? "Hello, world!")
            .padding()
            .task {
                text = await viewModel.printMessage()
            }
    }
}

@State permet de modifier une variable de type valeur à l’intérieur d’une struct. Il ne faut pas oublier qu’au moment qu’on modifie un attribut dans une structure, on modifie toute la structure derrière (une nouvelle structure est crée derrière).

Vous remarquez aussi ce message de warning qui nous indique que le système craint que la modification de la valeur text peut ne pas se faire dans le main thread. Ce message ne peut être afficher qu’en activant un swift flags au niveau de build settings de cette manière:

  • -Xfrontend
  • -warn-concurrency
  • -Xfrontend
  • -enable-actor-data-race-checks

Activation Concurrency et data races warning

Une des solutions est soit c’est d’ajouter l’annotation @MainActor avant la structure ContentView

@MainActor
struct ContentView: View {.....}

ou de déclarer notre viewModel comme étant MainActor cela nous ramène à le rendre adapté au traitement asynchrone d’un actor. Une solution est de le mettre compatible avec le protocole ObservableObject et de l’annoter avec @StateObject lors de la déclaration.

Problème déclaration de viewModel comme @MainActor

@MainActor class AccounterViewModel: ObservableObject { ...}
struct ContentView: View {
@StateObject private var viewModel = AccounterViewModel()
....}

Références

https://www.hackingwithswift.com/books/ios-swiftui/why-state-only-works-with-structs

https://www.andyibanez.com/posts/structured-concurrency-with-group-tasks-in-swift/

https://www.swiftbysundell.com/articles/swift-actors/

https://www.guru99.com/stack-vs-heap.html

Leave A Comment

Solve : *
15 − 6 =