Swift 5.5 Concurrency and Threads

Swift Swift Concurrency

Say we want to perform some expensive background work after a SwiftUI view appears. A naive approach could look something like this:

struct ContentView: View {
    
    var body: some View {
        Text("Hello!").onAppear {
            Task.detached {
                // run some expensive work
            }
        }
    }
}

First of all, a detached task itself may not be the best choice. Detached tasks are… well, detached. In this case, our expensive work won’t be canceled automatically when the view goes away. More importantly, depending on how we structure the code inside the task, we may end up running parts of the expensive work on the main thread.

Lets see what happens (or at least what can happen) if we call an async main actor method inside a detached task, something like this:

struct ContentView: View {
    
    var body: some View {
        Text("Hello!").onAppear {
            Task.detached {
                print("before preparing: \(Thread.isMainThread)")
                // prepare the expensive work
                await doSomethingElse()
                print("after preparing: \(Thread.isMainThread)")
                // run the expensive work
            }
        }
    }
    
    @MainActor
    func doSomethingElse() async {
    }
}

Swift concurrency model does not make any guarantee that a task will continue on the same thread after calling an awaitable function. Sure, the detached task is initially detached from the main actor, but there is no guarantee it will stay on a background thread. In this particular case, the detached task continued running on the main thread in my test, after calling the main actor method doSomethingElse:

before preparing: false
after preparing: true

This means that the expensive task ended up blocking the UI, which is something we were trying to avoid. What can we do to fix it? Probably the best approach is to refactor the expensive work into an actor:

actor ExpensiveWorker {
    
    static let shared = ExpensiveWorker()
    
    private init() {}
    
    func prepare() {
        print("inside the actor prepare: \(Thread.isMainThread)")
    }
    
    func run() {
        print("inside the actor run: \(Thread.isMainThread)")
    }
}

We can now rewrite the task to use the ExpensiveWorker methods instead:

var body: some View {
    Text("Hello!").onAppear {
        Task.detached {
            print("before preparing: \(Thread.isMainThread)")
            await ExpensiveWorker.shared.prepare()
            await doSomethingElse()
            print("after preparing: \(Thread.isMainThread)")
            await ExpensiveWorker.shared.run()
        }
    }
}

The print output is now

before preparing: false
inside the actor prepare: false
after preparing: true
inside the actor run: false

Great, no more main thread blocking! Even better, we don’t have to use a detached task any more. We can instead place the code inside the task modifier’s closure:

var body: some View {
    Text("Hello!").task {
        print("before preparing: \(Thread.isMainThread)")
        await ExpensiveWorker.shared.prepare()
        await doSomethingElse()
        print("after preparing: \(Thread.isMainThread)")
        await ExpensiveWorker.shared.run()
    }
}

The output is now

before preparing: true
inside the actor prepare: false
after preparing: true
inside the actor run: false

Contrary to the previously used detached task, the above task starts on the main thread as it inherits the current actor context. But prepare and run are correctly moved to a background thread as they are isolated by the ExpensiveWorker actor. And because the task is not detached any more, it will be automatically canceled if not completed before the view is gone.

You can find out more about actors and how they protect mutable state in last year’s WWDC session. For more technical details refer to the Swift evolution actors proposal. One last important thing to note here is that actors currently use a cooperative thread pool (as explained here). As a result, actors are not currently suitable for very long blocking tasks. In some cases, Task.yield() may be a solution. If that’s not possible, a serial DispatchQueue may be a good alternative. In that case, continuations can be used to convert completion handlers into async functions. Note the word “currently”. This may change after the custom executors proposal is implemented. Custom executors will allow backing actors with something else other than the cooperative thread pool. For example, we will be able to define actors whose code is executed on a specific dispatch queue or even a specific thread. This will solve the aforementioned problem with long blocking tasks.