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.
Swiftactor 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.
Swiftclass 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.
Swiftactor 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.
Swiftfinal 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:
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.
Swiftactor 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.
Swiftfunc 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!