@MainActor behavior for async functions

For our tool we need to use the attribute @_unsafeInheritExecutor to keep running code on whatever executor the caller is running on. I understand that underscore attributes should be avoided, but this behavior is important for our use-case and we can't force users to explicitly provide an executor.

I was looking into how this attribute works and what it allows. My findings are surprising to me and I wasn't able to find documentation that would explain this behavior. It might be a bug, but maybe it's not so before reporting it I wanted to write it all down here to discuss.

I've created a new macOS "Command Line Tool" project in Xcode and then tried all the code snippets below from the main.swift file.

Let's start with a couple of sanity checks. The main.swift file contains no imports and no other code but the following snippet.

MainActor.assertIsolated("main.swift") // ✅

await Task {
    MainActor.assertIsolated("Task") // ✅
}.value

@MainActor
func shouldRunOnMainActor() async {
    MainActor.assertIsolated("async func") // ✅
}

await shouldRunOnMainActor()

That works as expected, no surprise there. Putting breakpoints on each line containing assertIsolated shows us that we're indeed on main thread. With that in mind, let's try using the @_unsafeInheritExecutor. As with the previous snippet, this is the whole main.swift file.

@MainActor
func shouldRunOnMainActor() async {
    await shouldInheritExecutor()
}

@_unsafeInheritExecutor
func shouldInheritExecutor() async {
    MainActor.assertIsolated("shouldInheritExecutor") // ✅
}

await shouldRunOnMainActor()

Again it behaves as expected, the MainActor's executor is inherited and the code inside shouldInheritExecutor is running on the main thread. When put breakpoints inside both shouldRunOnMainActor and shouldInheritExecutor, we'll see we're still on the main thread as expected.

Now comes the surprise (or maybe a bug?).

@MainActor
func shouldRunOnMainActor() async {
    await shouldInheritExecutor()
}

@_unsafeInheritExecutor
func shouldInheritExecutor() async {
    MainActor.assertIsolated("shouldInheritExecutor") // ❌
}

await Task {
    await shouldRunOnMainActor()
}.value

This code crashes on the assertIsolated call. When I put breakpoints inside the Task's block, shouldRunOnMainActor, or shouldInheritExecutor it's never on the main thread, always on Thread 2 Queue : com.apple.root.default-qos.cooperative (concurrent).

As I mentioned, I was trying to find an answer for this, as I couldn't understand why a function with the @MainActor attribute would not run on main thread, but haven't found an answer. However, I knew that withCheckedContinuation uses the @_unsafeInheritExecutor attribute and if this was a bug, I wouldn't be the first person to run into it.

At first I thought it was the @inlineable attribute that my shouldInheritExecutor didn't have. That wasn't it, so I thought maybe it's some special handling for builtin Swift functions. I copied the whole function withCheckedContinuation from Swift sources and that worked as expected, stopping on a breakpoint inside withCheckedContinuation I could see it's running on main.

After scratching my head some more and trying to add a generic parameter to the shouldInheritExecutor function and also a block parameter, I tried adding the function: String = #function that's in withCheckedContinuation's signature. Right away it started working as expected.

@MainActor
func shouldRunOnMainActor() async {
    await shouldInheritExecutor()
}

@_unsafeInheritExecutor
func shouldInheritExecutor(function: String = #function) async {
    MainActor.assertIsolated("shouldInheritExecutor") // ✅
}

await Task {
    await shouldRunOnMainActor()
}.value

That lead me to try something else. I removed the parameter and instead added assertIsolated call right before I call shouldInheritExecutor.

@MainActor
func shouldRunOnMainActor() async {
    MainActor.assertIsolated("shouldRunOnMainActor") // ✅
    await shouldInheritExecutor()
}

@_unsafeInheritExecutor
func shouldInheritExecutor() async {
    MainActor.assertIsolated("shouldInheritExecutor") // ✅
}

await Task {
    await shouldRunOnMainActor()
}.value

So it seems as long as there's a synchronous operation, Swift will switch to main thread. If we put a breakpoint inside the Task's block, that's still running on the background thread. But inside shouldRunOnMainActor it's the main thread again.

When I started writing all of this, I assumed that this is expected and the function: String = #function was added to withCheckedContinuation to make sure it all works. But just before I started writing this paragraph, I went to check the withUnsafeContinuation and it doesn't have a default parameter.

So I tried it and it has the same issue as my shouldInheritExecutor where it doesn't inherit MainActor's inheritor in this scenario.

@MainActor
func shouldRunOnMainActor() async {
    await withUnsafeContinuation { c in
        MainActor.assertIsolated("withUnsafeContinuation") // ❌
        c.resume()
    }
}

await Task {
    await shouldRunOnMainActor()
}.value

After all this I'm thinking it's probably a bug and not the intended behavior. However, I'm still confused why shouldRunOnMainActor isn't running on main thread until there's a synchronous operation? Is that an optimization to reduce the number of executor switching?

3 Likes

If you ignore the existence of @ _unsafeInheritExecutor, not hopping executors until the first synchronous operation sounds like a pretty reasonable optimization that'll eliminate a bunch of unnecessary hops.

@ _unsafeInheritExecutor and everything relying on it is sort of inherently broken. #isolation is a fixed version of it that doesn't have all the weird problems, so the good news is the problem goes away as soon as you can drop support for Swift 5.10.

1 Like

Yeah, I definitely agree with that. I didn't expect to discover this bug though, since both withCheckedContinuation and withUnsafeContinuation use the attribute and adding/removing a synchronous operation before calling them changes the withUnsafeContinuation semantics.

We'd drop support for Swift 5.10 like a hot potato, but first we need the next release :sweat_smile:

1 Like