swiftasync-awaitswift5swift-concurrencymainactor

asyncDetached falling back into main thread after MainActor call


[Warning! In case anyone runs across this question: it was asked very early in the history of the introduction of async/await into Swift. The problem it poses was probably a bug, indeed; but that bug is gone. The behavior described in the question is no longer reproducible. I've added a note at the end of the question, saying what happens now.]


I'm trying out the new async/await stuff. My goal here is to run the test() method in the background, so I use Task.detached; but during test() I need to make a call on the main thread, so I'm using MainActor.

(I realize that this may look convoluted in isolation, but it's pared down from a much better real-world case.)

Okay, so test code looks like this (in a view controller):

override func viewDidLoad() {
    super.viewDidLoad()
    Task.detached(priority: .userInitiated) {
        await self.test()
    }
}
@MainActor func getBounds() async -> CGRect {
    let bounds = self.view.bounds
    return bounds
}
func test() async {
    print("test 1", Thread.isMainThread) // false
    let bounds = await self.getBounds()
    print("test 2", Thread.isMainThread) // true
}

The first print says I'm not on the main thread. That's what I expect.

But the second print says I am on the main thread. That isn't what I expect.

It feels as if I've mysteriously fallen back into the main thread just because I called a MainActor function. I thought I would be waiting for the main thread and then resuming in the background thread I was already on.

Is this a bug, or are my expectations mistaken? If the latter, how do I step out to the main thread during await but then come back to the thread I was on? I thought this was exactly what async/await would make easy...?

(I can "solve" the problem, in a way, by calling Task.detached again after the call to getBounds; but at that point my code looks so much like nested GCD that I have to wonder why I'm using async/await at all.)

Maybe I'm being premature but I went ahead and filed this as a bug: https://bugs.swift.org/browse/SR-14756.


More notes:

I can solve the problem by replacing

    let bounds = await self.getBounds()

with

    async let bounds = self.getBounds()
    let thebounds = await bounds

But that seems unnecessarily elaborate, and doesn't convince me that the original phenomenon is not a bug.


I can also solve the problem by using actors, and this is starting to look like the best approach. But again, that doesn't persuade me that the phenomenon I'm noting here is not a bug.


I'm more and more convinced that this is a bug. I just encountered (and reported) the following:

override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view.
    async {
        print("howdy")
        await doSomeNetworking()
    }
}
func doSomeNetworking() async {
    print(Thread.isMainThread)
}

This prints howdy and then the second print prints true. But if we comment out the first print, the remaining (second) print prints false!

How can merely adding or removing a print statement change what thread we're on? Surely that's not intended.


Historical conclusion: In Swift 6, where Swift Concurrency and async/await have been fully rationalized, the problem no longer arises. func test() is @MainActor by default, because UIViewController is @MainActor, and all lines of it execute on the main actor. If func test() is declared nonisolated, then all lines of it execute off the main actor.


Solution

  • The following formulation works, and solves the entire problem very elegantly, though I'm a little reluctant to post it because I don't really understand how it works:

    override func viewDidLoad() {
        super.viewDidLoad()
        Task {
            await self.test2()
        }
    }
    nonisolated func test2() async {
        print("test 1", Thread.isMainThread) // false
        let bounds = await self.view.bounds // access on main thread!
        print("test 2", bounds, Thread.isMainThread) // false
    }
    

    I've tested the await self.view.bounds call up the wazoo, and both the view access and the bounds access are on the main thread. The nonisolated designation here is essential to ensuring this. The need for this and the concomitant need for await are very surprising to me, but it all seems to have to do with the nature of actors and the fact that a UIViewController is a MainActor.