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?