Actors

Actors are one of the new features of Concurrency in Swift, and their main purpose is to solve one of the most common problems when dealing with concurrent operations: mutating shared state.

When multiple threads access and mutate the same resource, there is no guarantee as to which thread will access the resource at a given time. If two different threads attempt to modify the same resource simultaneously, the app will crash. This problem is known as data races, and thanks to actors, we can forget about it.

Actors are a reference type, similar to a class. When accessing or modifying a resource in an actor, it is guaranteed to be executed in a serial way, preventing multiple threads from modifying the same resource simultaneously.

Syntax

We use the actor keyword just like any other class or struct type. Inside the actor, access to properties are synchronous. This is handled by the runtime to ensure that properties are accessed and modified in a serial way, preventing data races.

Swift
actor SomeActor {
    var count = 0
    
    func increaseCount() {
        count += 1
    }
    
    func start() {
        increaseCount()
    }
}

However, if the actor properties are accessed from outside the actor, then we need to add the await keyword.

Swift
class TestActorClass {
    let model: SomeActor
    
    init(model: SomeActor) {
        self.model = model
    }
    
    func test() async {
        print(await model.count)
        await model.increaseCount()
    }
}

Sometimes, inside an actor, there may be methods or properties that do not access or mutate any shared state. To use these properties outside the actor’s scope without using the await keyword, we can add nonisolated to such properties and methods.

Swift
actor SomeActor {
    var count = 0
    nonisolated let title = "Some title"
    
    func increaseCount() {
        count += 1
    }
    
    func start() {
        increaseCount()
    }
    
    nonisolated func someOtherMethod() {
        
    }
}

class TestActorClass {
    let model: SomeActor
    
    init(model: SomeActor) {
        self.model = model
    }
    
    func test() async {
        print(await model.count)
        await model.increaseCount()
        
        //nonisolated
        print(model.title)
        model.someOtherMethod()
    }
}

Data races

We are going to explore actors using the example from the previous post about Task Group, identifying and addressing data races.

In our previous scenario, we used a Task Group to download a list of files and then updated the UI with text from those files. Initially, we didn’t encounter any issues because the method modifying the list of files was executed on the main actor. However, if we introduce a count property into the model, and this property is modified by each task, then we introduce a data race. The tasks run in parallel and may attempt to modify the same property simultaneously.

Swift
final class ContentModel: ObservableObject {

    let numberOfFiles = 4
    @Published var files: [File] = []
    var count = 0
    
    @MainActor
    func update(file: File) {
        files.append(file)
    }
    
    func downloadFiles() async throws {
        try await withThrowingTaskGroup(of: File.self, returning: Void.self) { group in

            for index in 1..<numberOfFiles + 1 {
                group.addTask { [unowned self] in
                    self.count += 1
                    return try await self.download(index: index)
                }
            }

            for try await file in group {
                await update(file: file)
            }
        }
    }
}

If we execute the previous code with Thread Sanitizer enabled, we will observe warnings in Xcode regarding the data race:

data races warning

These kinds of problems can eventually lead to crashes in production builds. So let’s better take care of the problem using actors.

Solving the data race using an actor

We are going to convert our model into an actor. We replace the class keyword with the actor keyword. This ensures the integrity of the count property when it is accessed and modified from different threads. Then, we move the modification of the count property into a separate method. We can call the increaseCount method from within our actor in a synchronous way. However, if we call increaseCount from outside the actor’s scope, we need to add the await keyword because this code will be executed on the actor’s executor. Accessing the method from a Task Group is considered to be outside the actor’s scope, so we also need to add the await keyword.

Swift
actor ContentModel: ObservableObject {

    let numberOfFiles = 4
    @Published @MainActor var files: [File] = []
    var count = 0
    
    @MainActor
    func update(file: File) {
        files.append(file)
    }
    
    func increaseCount() {
        self.count += 1
    }
    
    func downloadFiles() async throws {
        try await withThrowingTaskGroup(of: File.self, returning: Void.self) { group in

            for index in 1..<numberOfFiles + 1 {
                group.addTask { [unowned self] in
                    await increaseCount()
                    return try await self.download(index: index)
                }
            }

            for try await file in group {
                await update(file: file)
            }
        }
    }
}

If we run the previous code we can see all warnings are gone away.

Global Actors

Sometimes, it can be useful to have a unique instance of an actor that can be accessed from anywhere in the code, similar to the MainActor. The MainActor is an actor that executes code on the main thread and performs UI updates. To achieve similar behavior with our own actors, it’s quite simple. We add @globalActor at the beginning of the actor definition, and we include a shared instance property.

Swift
@globalActor actor ContentModel: ObservableObject {
    static let shared = ContentModel()

    let numberOfFiles = 4
    @Published @MainActor var files: [File] = []
    var count = 0
    
    @MainActor
    func update(file: File) {
        files.append(file)
    }
    
    func increaseCount() {
        self.count += 1
    }
    
    func downloadFiles() async throws {
        try await withThrowingTaskGroup(of: File.self, returning: Void.self) { group in

            for index in 1..<numberOfFiles + 1 {
                group.addTask { [unowned self] in
                    await increaseCount()
                    return try await self.download(index: index)
                }
            }

            for try await file in group {
                await update(file: file)
            }
        }
    }
}

After making this change, we can access the actor using the shared instance, and we can ensure that a particular method or property is executed on the same executor as the actor by adding @ActorName before a method or property declaration. This ensures that the code is executed on the same executor as the actor’s executor.

Swift
func test() async -> Int {
    await ContentModel.shared.count
}

@ContentModel
func test2() async {
    
}

class TestActor {
    @ContentModel var title: String = "Some title"
}

Conclusion

In this post, we explored one of the most common problems, mutating shared state from concurrent threads, and how we can use actors to finally overcome this issue. See you in my next article!