WWDC21 introduced some major updates to Swift Concurrency. Perhaps the most notable of these updates is the new async/await pattern, which allows developers to write asynchronous code in a way that feels synchronous. Rather than the closure completion callbacks of the past, developers can now write asynchronous code in a cleaner, more structured syntax. While async/await introduces a way to define concurrent tasks and provides data-race safety for functions and closures, Actors provide us the same sort of concurrency handling, but for types like classes that deal with shared mutable state.
Data races occur when two threads concurrently access the same data and one of them is a write.
For our example, we’re going to be working with a class, FootballPlayer. A FootballPlayer can pass, rush or receive for a number of yards. It can also print out its stats, by combining its total accumulated passing, receiving, and rushing attempts and yards.
The following code results in a data race, and gives us an unexpected value.
Here, 2 and 3 execute on a different thread from 1, so when the instance of FootballPlayer prints its stats, it shows 1 reception for -3 yards:
This of course differs from the expected 2 receptions for 11 yards, because of the data race.
Swift actors are reference types that protect mutable state. They provide synchronization for shared mutable state and isolate their own state from the rest of the program. State is held within a single concurrency domain, meaning only a single thread will access that data at a given time, even when many clients are concurrently making requests of the actor.
Actors provide the same race and memory safety properties as async/await, while providing the familiar abstraction and reuse features that other explicitly declared types in Swift enjoy.
Actors share many similar capabilities to other Swift types like classes, structs and enums. Actors can have initializers, methods, properties, can be extended, be generic, and be used with generics. Their distinguishing factors are the synchronization and data isolation which set them apart.
The Swift Compiler enables Actors protect their state from data races through a set of limitations on the way in which actors and their instance members can be used, collectively called actor isolation.
The primary mechanism for mutable state protection is by only allowing stored instance properties to be accessed directly on self. Take for example the following code snippet:
This code protects state mutation because value is only mutated within the definition of the actor. In cases like this, calls within the actor are synchronous, and the synchronous code always runs uninterrupted.
To reference an actor from outside its definition, you do so asynchronously, using the await keyword.
If the actor is busy, then your code will suspend so the CPU can do other useful work. When the actor becomes free again, it will resume execution. In this example, the increment function will run to completion without any other code acting on the actor. The actor’s internal synchronization ensures one counter’s call to the increment function runs to completion before the other one can start.
Actor Reentrancy prevents deadlocks and guarantees forward progress by guaranteeing that runtime can re-enter the execution of code at a suspension point and carry on work from there.
To design for proper reentrancy, you must:
Always perform mutation of the actor state in synchronous code
Expect that the actor state could change during suspension
Check assumptions after await
Refactoring our Example
Now that we understand some of the basics of using actors in Swift, we can go back and refactor our FootballPlayer example to prevent any data races.
The first step is to simply change the type of FootballPlayer from a class to an actor.
Now, when we interact with our FootballPlayer we need to do so asynchronously.
In step 1, rather than our standard DispatchQueue.main.async, we can use asyncDetached to intentionally kick our call to receive out to an alternate thread. Even so, because of the internal synchronization and state protection of the actor, we ensure all calls to receive run synchronously and to completion, so by the time we print the footballPlayer's stats in step 3, we see the expected:
A reference to an actor-isolated declaration from outside that actor is called a cross-actor reference. Cross-actor references must only use Sendable types. Sendable types are safe to use concurrently. Value types are Sendable because each copy is independent. Actor types are Sendable because they synchronize access to mutable state. Classes can be Sendable but only if they are immutable or otherwise internally synchronized.
By insisting all cross-actor references only use Sendable types, we can ensure that no references to shared mutable state flow into or out of the actor’s isolation domain.
The Main Actor
The Main Actor is a an actor that operates on the main thread.
Let’s say we wanted to update the UI with the FootballPlayer's stats, and we want to ensure the code is always run on the main thread.
In the past this would look something like the following code snippet.
With @MainActor we can more easily ensure functions are always executed on the main thread like so.
In addition, types can be placed on the main actor, implying all methods and properties of that type are @MainActor.
Updates to Swift Concurrency like async/await and the actors are immensely impactful to the way developers design, write, and maintain asynchronous code. These patterns offer developers more understandable, elegant, and maintainable solutions to very common and often notorious problems of the past, while dramatically cleaning up the syntax. Developer updates like these are often the unsung heroes of WWDC events, but their effects will undeniably be appreciated by iOS developers worldwide.
For more reading, check out the Apple’s actors proposal from the swift-evolution repo here.
Additionally, here are two other blogs I found helpful: