WWDC Notes: Protect mutable state with Swift actors
Published on: June 8, 2021Data races make concurrency hard. They occur when two threads access the same data and at least one of them is a write. It’s trivial to write a data race, but it’s really hard to debug.
Data races aren’t always clear, aren’t always reproducible, and might not always manifest in the same way.
Shared mutable state is needed for a data race to occur. Value types don’t suffer from data races due to the way they work; they’re copied.
When you pass an array around, copies are created. This is due to array’s value semantics.
Even an object that’s a value type can be captured in a racy way. When you mutate a value type in a concurrent code block, Swift will show a compiler error since you’re about to write a data race.
Shared mutable state requires synchronization. Existing methods are:
- Atomics
- Locks
- Serial dispatch queues
All three require the developer to carefully use these tools.
Actors
Actors are introduced to eliminate this problem. Actors isolate their state from the rest of the program. This means that all access to state has. To go through the actor. An actor ensures mutually-exclusive access to state.
What’s nice is that you cannot forget to synchronize an actor.
Actors can do similar things to structs, enums, and classes. They are reference types and their unique characteristic is in how they synchronize and isolate data.
actor Counter {
var value = 0
func increment() -> Int {
value = value + 1
return value
}
}
In this code, the actor will ensure that value
is never read / mutated concurrently.
let counter = Counter()
asyncDetached {
print(await counter.increment())
}
asyncDetached {
print(await counter.increment())
}
This code does not cause a data race even though we have no idea how/when these detached tasks run. They might run after each other or at the same time. The actor will ensure we don’t have a data race on value
.
Interacting with actors is done asynchronously. The actor might have your calling code wait for a while to free its resources and avoid data races.
Extensions on an actor can access an actor’s state because it’s considered internal to the actor. You can access an actor’s state synchronously from within the actor. In other words, within an actor, your code is uninterrupted and you don’t need to worry about suspensions.
Actor reentrancy
There is an important exception to this though. When a function on an actor runs it’s not interrupted, unless you have an wait
within the function. If you await
in an actor function, the function is suspended.
Consider this code:
actor ImageDownloader {
private var cache: [URL: Image] = [:]
func image(from url: URL) async throws -> Image? {
if let cached = cache[url] {
return cached
}
let image = try await download(from: url)
cache[url] = image
return image
}
}
If you call this code, no data races for cache
can occur. But if you call this method twice for the same url, here’s what happens:
- CALL1 sees that no image is cached for
url
- CALL1 starts download,
- CALL1 suspends
- CALL2 sees that no image is cached for
url
- CALL2 starts download,
- CALL2 suspends
- CALL1’s call to download completes and CALL1 resumes
- CALL1 caches image
- CALL2’s call to download completes and CALL2 resumes
- CALL2 overrides image with new version of image for same URL
While an actor’s function is suspended, the underlying data can be changed by another call to that function.
One workaround here would be to not overwrite the image when it’s re-downloaded:
cache[url] = cache[url, default: image]
Video mentions code associated with the video for a better solution; not found.
Ideally, all mutations in an actor are synchronous. You should expect that state is changed after you’ve used an await in your actor code. Any assumptions should be checked after an await
.
Actor isolation
Protocol conformances must respect actor isolation.
For example
extension SomeActor: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(someProperty)
}
}
This will throw a compiler error. hash(into:)
is an instance method on SomeActor
which means that it must be called with async
since the actor might run the function call at a later time if the actor is already busy.
However, Hashable
requires hash(into:)
to be synchronous. This is a problem because for an actor to guarantee isolation, we can’t call it synchronously.
To work around this, we can use nonisolated
:
extension SomeActor: Hashable {
nonisolated func hash(into hasher: inout Hasher) {
hasher.combine(someProperty)
}
}
This will make it so that the function is not isolated anymore. As long as someProperty
is immutable, this will work. We know that someProperty
can’t be mutated so reading it without isolation shouldn’t be a problem. After all, a data race is only a data race if one of the accesses mutates state.
If someProperty
is mutable though, we’d still get a compiler error because we don’t know who else might access someProperty
(and potentially mutate it).
Closures can be isolated to the actor.
extension SomeActor {
func doSomething() {
self.someList.map { item in
return self.infoFor(item)
}
}
}
infoFor
is defined on SomeActor
but we don’t need to await self.infoFor
because we know this closure never leaves the scope of the actor. Map’s closure isn’t escaping so we’re always actor isolated.
extension SomeActor {
func runLater() {
asyncDetached {
await doSomething()
}
}
}
In this example, the closure isn’t run immediately within the scope of the actor since it’s detached. This means we must await doSomething()
since we’re no longer actor isolated.
If an actor owns a reference type that might be exposed to the outside of an actor, we have a potential for a data race. This isn’t a problem for value types. However, it is a problem for classes since the actor will hold a reference to an instance of a reference type. If this references is (safely) passed outside of the scope of the actor, this non-actor scope might cause a data race on that reference type instance.
Objects that can be safely shared concurrently are called Sendable
types.
If you copy a something from one place to the other and each can modify it without issue, that object is sendable.
- Value types are sendable because they’re copied
- Actors are sendable because they isolate state
- Immutable classes can be sendable. These are classes with only
let
properties - Internally synchronized classes can be sendable
- Functions aren’t always sendable. They are if they are
@Sendable
Sendable describes a common but not universal property of type.
Swift will enforce Sendable at compile time so you’ll know when you’re at risk of leaking non-Sendable information.
Sendable
is a protocol that you can have classes conform to. Swift will automatically check correctness for you from that point. Something is Sendable
if all of its properties are Sendable
, similar to Decodable
’s synthesized properties.
Sendable objects with generics must have the generic be Sendanle
too. You can add conditional conformance to objects based on generics being Sendable
for example.
Making objects Sendable
if you need them in a concurrent situation is a great way to have Swift help you out when you might have a data race.
@Sendable
function types conform to Sendable
Sendable
closures cannot have mutable captures, must capture Sendable
types only, and can’t be both sync and actor isolated.
asyncDetached
takes a sendable closure.
var c = Counter()
asyncDetached {
c.increment()
}
This doesn’t work because c
is not Sendable
Main actor
The main thread in an app is where all kinds of UI operations are performed. Whenever you interact with the UI you’re on the main thread.
Slow tasks should be performed away from the main thread so it doesn’t freeze.
Main thread is good for quick operations.
DispatchQueue.main.async
is used to dispatch onto the main queue. This is very similar to running on an actor. If you’re on the main thread you can access it safely. Just like you can safely access an actor’s state from within the actor. If you want to run something on the actor from the outside you do so async with await
. This is similar to running code on the main queue async with DispatchQueue.main.async
.
The Main Actor in Swift is an actor that represents the main thread.
Main actors always use the main dispatch queue as their underlying queue. It’s interchangeable with DispatchQueue.main
from the pov of the runtime.
If you mark something with @MainActor
, it always runs on the main actor:
@MainActor func doSomething() {}
This code must be called async from outside of the main actor:
await doSomething()
Whole types cal be placed on the main actor with @MainActor
:
@MainActor class SomeClass
Opt out of running on the main actor in such a class with nonisolated
.