August 23, 2021
Swift Concurrency - Part 3 - Actors
Data Races
Data races are an unfortunate reality of building concurrent programs. They are easy to introduce and difficult to track down. Let's look at a conceptual data race in this code snippet.
class CowStore {
var herdName: String = "untitled herd"
}
...
var store = CowStore()
Task {
store.herdName = "legen-dairy"
print("task 1, herd name: \(store.heardName)")
}
Task {
store.heardName = "moo-riffic"
print("task 2, herd name: \(store.heardName)")
}
Here we are reading and writing to an shared instance of CowStore
from both concurrently executing tasks. One task may write and read before the other task can write and read or both tasks do so simultaneously. This can put our program in an undefined state since the result could be inconsistent across runs. Even worse, this could cause the app to crash!
Fortunately, Swift can now prevent some of these potential data races early with static checking. The previous code wouldn't even compile, we would be presented with this error.
🛑 Reference to captured var 'store' in concurrently-executing code
This static checking for potential data races is possible via the new @sendable
marker. The closure on this Task initializer is marked with @sendable
and requires all passed in data to conform to Sendable
. We'll get to Sendable
later.
Actors
Swift 5.5 brings along actors, a new entity that safely provides shared state access to concurrently running tasks. Actors isolate internal state and coordinate access through a lightweight serialization mechanism ensuring that shared mutable state is only available to one Task at a time.
Actor Isolation
Actors can be thought of as classes that protect access to their mutable state. Actors require that any modification to mutable state happen from inside the actor, that is to say only the actor instance can modify self
. This is referred to as being isolated, wheras anything from outside the actor is non-isolated. Let's see it in action.
actor CowStore {
var herdName: String = "untitled herd"
}
...
var store = CowStore()
store.herdName = "deja moo" // 🛑 Actor-isolated property 'herdName' can not be mutated from a non-isolated context
Here we've changed CowStore
from a class to an actor. When trying to set herdName
on our instance store
we're met with a new error. herdName
can only me mutated from an isolated context, let's add a function to CowStore
to do just that.
actor CowStore {
var herdName: String = "untitled herd"
func setHerdName(_ name: String) {
self.herdName = name
}
}
...
var store = CowStore()
store.setHerdName("deja moo") // 🛑 Expression is 'async' but is not marked with 'await'
Now that the herdName
is modified from inside the actor within setHerdName(_:)
we are one step closer to our desired intent but have a new compiler error. Calling functions and accessing mutable properties behave differently when in an isolated and non-isolated context. While working inside the actor, in an isolated context, we have synchonrous access to every property and function of the actor. While working outside the actor, in a non-isolated context, we have asynchronous access. This is because of the serialization mechanism of the actor; only one Task may access the isolated state a time and the actor may be busy. We use await
to suspend execution of our task until the actor is free.
Let's update our usage of setHerdName(_:)
with await
since we are calling into store
from a non-isolated context.
var store = CowStore()
await store.setHerdName("deja moo") // ✅
print(await store.herdName) // prints "deja moo"
There are two circumstances where you won't need to use await
on an actor from a non-isolated context. The first is when accessing immutable state, this is because it's impossible to have a race condition if the shared state can never be written to. The second is a function of an actor marked with nonisolated
. These functions don't run inside the actor, so they won't need to be called with await
. While non-isolated functions do not need to be called with await
any isolated state the function itself does access would need to be called with await
.
actor CowStore {
// immutable shared state - no need for `await` when accessing from outside
let animalType = Cow.self
var herdName: String = "untitled herd"
func setHerdName(_ name: String) {
self.herdName = name
}
}
extension CowStore {
// nonisolated function - no need for `await` when calling, unless the function is marked as `async`
nonisolated func whatDoesTheCowSay() {
// accessing isolated state here (ex. `herdName`) would require `await`
print("MOOOOO")
}
}
...
print(store.animalType) // prints "Cow"
store.whatDoesTheCowSay() // prints "MOOOOO"
Additional Actor Details
It's worth noting that with the actor access model being async
/await
we are implicitly saying that our code should run on a background thread. However, there is one exception to that rule, the MainActor
, which we’ll get to that later. Another thing to note is that the system decides which thread an actor is run on, though for most occasions this is just an implementation detail.
You can create multiple instances of an actor and each has its own lightweight serialization mechanism. Taking ownership of an actor, running your code, and releasing it for another task is very efficient. Even hopping from one actor, to another, and then to another is very efficient.
Actors work great as architectural components like logical controllers, managers, and data stores.
Sendable
As you can expect, an actor that is built to protect its mutable state would not fulfill its purpose if mutable reference types were able to be modified outside the confines of the actor. To prevent this "leaking" of shared mutable state there is a new protocol Sendable
. Sendable
types are those that can be safely used in concurrent code either because of copy-by-value semantics, immutability, or thread safe implementations. This includes all value types, actors, let
defined immutable classes, thread safe classes, and functions marked with @Sendable
, and more. This is even enforced by the compiler, preventing us from introducing race conditions in our code. Neat!
⚠️
Sendable
enforcement has not yet arrived - Xcode 13 Beta 4
The Main Actor
Earlier I mentioned an exception to the rule that actor operations are run on a background thread, that exception is the built-in implementation of the actor protocol, MainActor
. The MainActor
is special in that it represents the main thread. Any types, functions,… annotated with @MainActor
are guaranteed only to be run on the main thread, think DispatchQueue.main.async { … }
. As expected with an actor, calls to functions marked with @MainActor
from outside of the MainActor
are asynchronous and must be awaited. That is to say, code already running within the MainActor
can make synchronous calls within the MainActor
, only calls to the MainActor
from outside must be awaited.
@MainActor
class View: UIView {
...
func updateName(_ name: String) {
...
}
}
Task {
let name = await store.herdName
view.updateName(name) // 🛑 Expression is 'async' but is not marked with 'await'
await view.updateName(name) // ✅
}
I love when things like the MainActor come around, it's more than just a convenient way to ensure code is run on the main thread, it takes a mental burden off of the developer. For so long we've been using DispatchQueue.main.async { … }
to get back onto the main thread to call functions we knew had to be on the main thread. Annotating classes and functions with @MainActor
just once takes away the need to remember to only and always call it on the main thread.
❖ ❖ ❖