Super Simple Example of a Swift Actor

Besides classes, structs and enum Swift has actors, now. Let's create a super simple example of what an actor can do.

Let's build something super simple

class Counter { var value = 0 func increment() { value += 1 } func decrement() { value -= 1 } }

Let's build a view model that uses a counter:

final class Model: ObservableObject { @Published var counterValue: String? private var counter: Counter init() { counter = Counter() update() } func increment(times: Int) { for _ in 1...times { counter.increment() } update() } func decrement(times: Int) { for _ in 1...times { counter.decrement() } update() } private func update() { assert(Thread.isMainThread) counterValue = counter.value.description } }

and a view that uses the view model:

struct ContentView: View { @StateObject var model = Model() var body: some View { VStack { if let value = model.counterValue { Text(value) } Button("do something...") { let start = Date() model.increment(times: 1_000_000) model.increment(times: 1_000_005) model.decrement(times: 2_000_000) let elapsed = Date().timeIntervalSince(start) print(String(format: "%.05f", elapsed)) } } .padding() } }

Press the button and the desired output 5 will appear. The time spend in the buttons closure will be printed to the console.

Now break it

Let's break it by offloading the millions of incrementens and decrements to another Thread. Change the view model like this:

func increment(times: Int) { DispatchQueue(label: .init()).async { for _ in 1...times { self.counter.increment() } DispatchQueue.main.async { self.update() } } }

or

func decrement(times: Int) { Thread { for _ in 1...times { self.counter.decrement() } DispatchQueue.main.async { self.update() } }.start() }

If you run this now, the time spend in the "do something..." button closure is close to 0, but the UI no longer shows 5. There's your obvious data-race.

Fix it

Let's go into the Counter and introduce a queue that will perform all mutations to the value property:

class Counter { var value = 0 private var queue = DispatchQueue(label: "Counter") func increment() { queue.sync { value += 1 } } func decrement() { queue.sync { value -= 1 } } }

The closure returns immediately and after some time (you really have to give it some time) we're getting the desired 5 in the UI again. There's a high chance that there are different values shown before all the operations are performed. Press again and it will settle with 10. All good, again.

Fix it with an actor

Now let's use an actor. Go back to the original Counter but make it an actor.

actor Counter { var value = 0 func increment() { value += 1 } func decrement() { value -= 1 } }

The cool thing is that you can have the compiler guide you from now on. To improve even further on that mark the counterValue property in the view model as @MainActor.

@MainActor @Published var counterValue: String?

Now build and lets go through the errors.

The first error message is:

Actor-isolated instance method 'increment()' can not be referenced from a non-isolated context

so let's fix that by awaiting the call to counter.incerement() from an async context. Change increment(times:) to this:

func increment(times: Int) { Task { for _ in 1...times { await counter.increment() } update() // not done, yet } }

Note that you can drop self from self.counter.increment(), again. Do the same for decrement(times:) and you'll get to the next error:

Property 'counterValue' isolated to global actor 'MainActor' can not be mutated from this context

Xcode even gives us a code action, now. Press fix for 'Add '@MainActor' to make instance method 'update()' part of global actor 'MainActor'. Once update() is isolated on the MainActor you need to await all calls to it. The reason is that Swift might need to perform a context switch because your code might not be running on the MainActor at the time you call update().

Since init() is a normal Swift function and not an async function we need to put the call to update() in a Task block:

init() { counter = Counter() Task { await update() } }

If I understand everything right an async function has it's own execution stack which can be executed and suspended from an operating system thread. So the last error message:

Actor-isolated property 'value' can not be referenced from the main actor

tells us that the code:

@MainActor private func update() { assert(Thread.isMainThread) counterValue = counter.value.description }

has an async execution context (the MainActor) and wants to read a value from another Actor (our Counter Actor). So for this to work we need to send the Counter Actor the message that we want to read its value property. This needs to be awaited. So fix the code like this:

@MainActor private func update() async { assert(Thread.isMainThread) counterValue = await counter.value.description }

Here's the updated view model:

final class Model: ObservableObject { @MainActor @Published var counterValue: String? private var counter: Counter init() { counter = Counter() Task { await update() } } func increment(times: Int) { Task { for _ in 1...times { await counter.increment() } await update() } } func decrement(times: Int) { Task { for _ in 1...times { await counter.decrement() } await update() } } @MainActor private func update() async { assert(Thread.isMainThread) counterValue = await counter.value.description } }

The @MainActor annotation of the @Published property counterValue helps with error messages. Get in the habit and think: "I will use this property in the view so it needs to be modified on the main actor."