Articles, podcasts and news about Swift development, by John Sundell.

Swift actors: How do they work, and what kinds of problems do they solve?

Published on 24 Nov 2021
Discover page available: Concurrency

Since 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:

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!