Swift actors: How do they work, and what kinds of problems do they solve?
Discover page available: ConcurrencySince the very first version of Swift, we’ve been able to define our various types as either classes, structs, or enums. But now, with the launch of Swift 5.5 and its built-in concurrency system, a new type declaration keyword has been added to the mix — actor
.
So, in this article, let’s explore the concept of actors, and what kinds of problems that we could solve by defining custom actor types within our code bases.
Preventing data races
One of the core advantages of Swift’s new actor types is that they can help us prevent so-called “data races” — that is, memory corruption issues that can occur when two separate threads attempt to access or mutate the same data at the same time.
To illustrate, let’s take a look at the following UserStorage
type, which essentially provides a way for us to pass a dictionary of User
models around by reference, rather than by value:
class UserStorage {
private var users = [User.ID: User]()
func store(_ user: User) {
users[user.id] = user
}
func user(withID id: User.ID) -> User? {
users[id]
}
}
By itself, there’s really nothing wrong with the above implementation. However, if we were to use that UserStorage
class within a multi-threaded environment, then we could quickly run into various kinds of data races, since our implementation currently performs its internal mutations on whatever thread or DispatchQueue
that it was called on.
In other words, our UserStorage
class currently isn’t thread-safe.
One way to address that would be to manually dispatch all of our reads and writes on a specific DispatchQueue
, which would ensure that those operations always happen in serial order, regardless of which thread or queue that our UserStorage
methods will be used on:
class UserStorage {
private var users = [User.ID: User]()
private let queue = DispatchQueue(label: "UserStorage.sync")
func store(_ user: User) {
queue.sync {
self.users[user.id] = user
}
}
func user(withID id: User.ID) -> User? {
queue.sync {
self.users[id]
}
}
}
The above implementation works, and now successfully protects our code against data races, but it does have a quite significant flaw. Since we’re using the sync
API to dispatch our dictionary access code, our two methods will cause the current execution to be blocked until those dispatch calls have finished.
That can become problematic if we end up performing a lot of concurrent reads and writes, since each caller would be completely blocked until that particular UserStorage
call has finished, which could result in poor performance and excessive memory usage. This type of problem is often referred to as “data contention”.
One way to fix that problem would be to instead make our two UserStorage
methods fully asynchronous, which involves using the async
dispatch method (rather than sync
), and in the case of retrieving a user, we’d also have to use something like a closure to notify the caller when its requested model was loaded:
class UserStorage {
private var users = [User.ID: User]()
private let queue = DispatchQueue(label: "UserStorage.sync")
func store(_ user: User) {
queue.async {
self.users[user.id] = user
}
}
func loadUser(withID id: User.ID,
handler: @escaping (User?) -> Void) {
queue.async {
handler(self.users[id])
}
}
}
Again, the above certainly works, and has been one of the preferred ways to implement thread-safe data access code prior to Swift 5.5. However, while closures are a fantastic tool, having to wrap all of our User
handling code within a closure could definitely make that code more complex — especially since we had to make our loadUser
method’s closure argument escaping.
A case for an actor
This is exactly the type of situation in which Swift’s new actor types can be incredibly useful. Actors work much like classes (that is, they are passed by reference), with two key exceptions:
- An actor automatically serializes all access to its properties and methods, which ensures that only one caller can directly interact with the actor at any given time. That in turn gives us complete protection against data races, since all mutations will be performed serially, one after the other.
- Actors don’t support subclassing since, well, they’re not actually classes.
So, practically, all that we have to do to convert our UserStorage
class into an actor is to go back to its original implementation, and simply replace its use of the class
keyword with the new actor
keyword — like this:
actor UserStorage {
private var users = [User.ID: User]()
func store(_ user: User) {
users[user.id] = user
}
func user(withID id: User.ID) -> User? {
users[id]
}
}
With just that tiny change in place, our UserStorage
type is now completely thread-safe, without requiring us to implement any kind of custom dispatching logic. That’s because actors force other code to call their methods using the await
keyword, which lets the actor’s serialization mechanism suspend such a call in case the actor is currently busy processing another request.
For example, here we’re using our new UserStorage
actor as a caching mechanism within a UserLoader
— which requires us to use await
when both loading and storing User
values:
class UserLoader {
private let storage: UserStorage
private let urlSession: URLSession
private let decoder = JSONDecoder()
init(storage: UserStorage, urlSession: URLSession = .shared) {
self.storage = storage
self.urlSession = urlSession
}
func loadUser(withID id: User.ID) async throws -> User {
if let storedUser = await storage.user(withID: id) {
return storedUser
}
let url = URL.forLoadingUser(withID: id)
let (data, _) = try await urlSession.data(from: url)
let user = try decoder.decode(User.self, from: data)
await storage.store(user)
return user
}
}
Note how, apart from the fact that we have to use await
when interacting with them, actors can be used just like any other Swift type. They can be passed as arguments, stored using properties, and can also conform to protocols.
Race conditions are still possible
However, while our user loading and storage code is now guaranteed to be free from low-level data races, that doesn’t mean that it’s necessarily free from race conditions. While data races are essentially memory corruption issues, race conditions are logical issues that occur when multiple operations end up happening in an unpredictable order.
In fact, if we end up using our new UserLoader
to load the same user within multiple places at the same time, we’ll very likely run into a race condition, since several, duplicate network calls will end up being performed. That’s because our storedUser
value will only exist once that user has been completely loaded.
Initially, we might think that solving that issue would be as simple as making our UserLoader
an actor as well:
actor UserLoader {
...
}
But it turns out that doing so isn’t quite enough in this case, since our problem isn’t related to how our data is mutated, but rather in which order that our underlying operations are performed.
Because even though each actor does indeed serialize all calls to it, when an await
occurs within an actor, that execution is still suspended just like any other — meaning that the actor will be unblocked and ready to accept new requests from other code. While that’s, in general, a great thing — as it lets us write nonblocking code that’s efficiently executed — in this particular case, that behavior will make our UserLoader
keep performing duplicate network calls, just like when it was implemented as a class.
To fix that, we’re going to have to keep track of when the actor is currently performing a given network call. That can be done by encapsulating that code within asynchronous Task
instances, which we can then store references to within a dictionary — like this:
actor UserLoader {
private let storage: UserStorage
private let urlSession: URLSession
private let decoder = JSONDecoder()
private var activeTasks = [User.ID: Task<User, Error>]()
...
func loadUser(withID id: User.ID) async throws -> User {
if let existingTask = activeTasks[id] {
return try await existingTask.value
}
let task = Task<User, Error> {
if let storedUser = await storage.user(withID: id) {
activeTasks[id] = nil
return storedUser
}
let url = URL.forLoadingUser(withID: id)
do {
let (data, _) = try await urlSession.data(from: url)
let user = try decoder.decode(User.self, from: data)
await storage.store(user)
activeTasks[id] = nil
return user
} catch {
activeTasks[id] = nil
throw error
}
}
activeTasks[id] = task
return try await task.value
}
}
What’s interesting is that the above solution is, conceptually, almost identical to how we solved a similar race condition using Combine in “Using Combine’s share operator to avoid duplicate work”. It’s one of those domains where even though the APIs we call might vary, the techniques and underlying principles are still more or less universal.
With the above changes in place, our UserLoader
is now completely thread-safe, and can be called as many times as we’d like from as many threads or queues as we’d like. Our activeTasks
logic will ensure that tasks will be properly reused when possible, and our UserLoader
actor’s serialization mechanism will dispatch all mutations to that dictionary in a predictable, serial order.
Conclusion
So, in general, actors are a fantastic tool when we want to implement a type that supports concurrent access to its underlying state in a very safe way. However, it’s also important to remember that turning a type into an actor will require us to interact with it in an asynchronous manner, which usually does make such calls somewhat more complex (and slower) to perform — even with tools like async/await at our disposal.
While there’s certainly more to explore when it comes to Swift’s implementation of the actor pattern (it’s definitely a topic that we’ll revisit in the future), as well as other ways to make Swift code thread-safe, I hope that this article has given you a few ideas on how you could make use of actors within the code bases that you work on.
For more on Swift’s new concurrency system, check out this page, and if you have any questions, comments, or feedback, then feel free to reach out via email.
Thanks for reading!