Skip to main content

What is actor hopping and how can it cause problems?

About 4 minSwiftArticle(s)bloghackingwithswift.comcrashcourseswiftxcodeappstore

What is actor hopping and how can it cause problems? 관련

Swift Concurrency by Example

Back to Home

What is actor hopping and how can it cause problems? | Swift Concurrency by Example

What is actor hopping and how can it cause problems?

Updated for Xcode 15

When a thread pauses work on one actor to start work on another actor instead, we call it actor hopping, and it will happen any time one actor calls another.

Behind the scenes, Swift manages a group of threads called the cooperative thread pool, creating as many threads as there are CPU cores so that we can’t be hit by thread explosion. Actors guarantee that they can be running only one method at a time, but they don’t care which thread they are running on – they will automatically move between threads as needed in order to balance system resources.

Actor hopping with the cooperative pool is fast – it will happen automatically, and we don’t need to worry about it. However, the main thread is not part of the cooperative thread pool, which means actor code being run from the main actor will require a context switch, which will incur a performance penalty if done too frequently.

You can see the problem caused by frequent actor hopping in this toy example code:

actor NumberGenerator {
    var lastNumber = 1

    func getNext() -> Int {
        defer { lastNumber += 1 }
        return lastNumber
    }

    @MainActor func run() async {
        for _ in 1...100 {
            let nextNumber = await getNext()
            print("Loading \(nextNumber)")
        }
    }
}

let generator = NumberGenerator()
await generator.run()

Download this as an Xcode projectopen in new window

In that code, the run() method must take place on the main actor because it has the @MainActor attribute attached to it, however the getNext() method will run somewhere on the cooperative pool, meaning that Swift will need to perform frequent context switching from to and from the main actor inside the loop.

In practice, your code is more likely to look like this:

// An example piece of data we can show in our UI
struct User: Identifiable {
    let id: Int
}

// An actor that handles serial access to a database
actor Database {
    func loadUser(id: Int) -> User {
        // complex work to load a user from the database
        // happens here; we'll just send back an example
        User(id: id)
    }
}

// An observable object that handles updating our UI
@MainActor
class DataModel: ObservableObject {
    @Published var users = [User]()
    var database = Database()

    // Load all our users, updating the UI as each one
    // is successfully fetched
    func loadUsers() async {
        for i in 1...100 {
            let user = await database.loadUser(id: i)
            users.append(user)
        }
    }
}

// A SwiftUI view showing all the users in our data model
struct ContentView: View {
    @StateObject var model = DataModel()

    var body: some View {
        List(model.users) { user in
            Text("User \(user.id)")
        }
        .task {
            await model.loadUsers()
        }
    }
}

Download this as an Xcode projectopen in new window

When that runs, the loadUsers() method will run on the main actor, because the whole DataModel class must run there – it has been annotated with @MainActor to avoid publishing changes from a background thread. However, the database’s loadUser() method will run somewhere on the cooperative pool: it might run on thread 3 the first time it’s called, thread 5 the second time, thread 8 the third time, and so on; Swift will take care of that for us.

This means when our code runs it will repeatedly hop to and from the main actor, meaning there’s a significant performance cost introduced by all the context switching.

The solution here is to avoid all the switches by running operations in batches – hop to the cooperative thread pool once to perform all the actor work required to load many users, then process those batches on the main actor. The batch size could potentially load all users at once depending on your need, but even batch sizes of two would halve the context switches compared to individual fetches.

For example, we could rewrite our previous example like this:

struct User: Identifiable {
    let id: Int
}

actor Database {
    func loadUsers(ids: [Int]) -> [User] {
        // complex work to load users from the database
        // happens here; we'll just send back examples
        ids.map { User(id: $0) }
    }
}

@MainActor
class DataModel: ObservableObject {
    @Published var users = [User]()
    var database = Database()

    func loadUsers() async {
        let ids = Array(1...100)

        // Load all users in one hop
        let newUsers = await database.loadUsers(ids: ids)

        // Now back on the main actor, update the UI
        users.append(contentsOf: newUsers)
    }
}

struct ContentView: View {
    @StateObject var model = DataModel()

    var body: some View {
        List(model.users) { user in
            Text("User \(user.id)")
        }
        .task {
            await model.loadUsers()
        }
    }
}

Download this as an Xcode projectopen in new window

Notice how the SwiftUI view is identical – we’re just rearranging our internal data access to be more efficient.

Similar solutions…
What is an actor and why does Swift have them? | Swift Concurrency by Example

What is an actor and why does Swift have them?
How to create and use an actor in Swift | Swift Concurrency by Example

How to create and use an actor in Swift
How to make parts of an actor nonisolated | Swift Concurrency by Example

How to make parts of an actor nonisolated
Understanding how global actor inference works | Swift Concurrency by Example

Understanding how global actor inference works
Important: Do not use an actor for your SwiftUI data models | Swift Concurrency by Example

Important: Do not use an actor for your SwiftUI data models

이찬희 (MarkiiimarK)
Never Stop Learning.