@_unsafeInheritExecutor
is an interesting and really unsafe annotation so it should only be used when you know what you are doing. All methods that are annotated with this have been checked to not break any invariants.
To understand what it does, we first have to talk about a few Concurrency concepts. When are suspensions actually happening? There are two places right now where async
code can suspend
- When calling
with[Checked/Unsafe]Continuation
- When hopping to a different executor
The best way to see where this is actually happening is looking at the emitted silgen of your code. There are three important sil functions to look out for:
- get_async_continuation
- await_async_continuation
- hop_to_executor
The important bit is that only the second and third of those are actually suspending. The first one get_async_continuation
is merely creating a continuation that will be awaited via await_async_continuation
. This translated in code like this:
func foo() async {
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
// Nothing has suspended yet. We just created the continuation via get_async_continuation
// We can do some work here but nothing suspended yet
} // When reaching the end of the scope here we are going to actually suspend via await_async_continuation
}
The important bit now and this is where @_unsafeInheritExecutor
comes into play is how hop_to_executor
works or rather when it is used. Let's try to build our own withCustomCheckedContinuation
that mimics the API of withCheckedContinuation
:
func withCustomCheckedContinuation(_ body: () async -> Void) async {
await body()
}
Looking at the SILGen of this:
sil hidden [ossa] @$s6output29withCustomCheckedContinuationyyyyYaXEYaF : $@convention(thin) @async (@noescape @async @callee_guaranteed () -> ()) -> () {
bb0(%0 : $@noescape @async @callee_guaranteed () -> ()):
debug_value %0 : $@noescape @async @callee_guaranteed () -> (), let, name "body", argno 1, loc "/app/example.swift":8:38, scope 8 // id: %1
%2 = enum $Optional<Builtin.Executor>, #Optional.none!enumelt, loc "/app/example.swift":8:6, scope 8 // users: %5, %3
hop_to_executor %2 : $Optional<Builtin.Executor>, loc "/app/example.swift":8:6, scope 8 // id: %3
%4 = apply %0() : $@noescape @async @callee_guaranteed () -> (), loc "/app/example.swift":9:11, scope 9
hop_to_executor %2 : $Optional<Builtin.Executor>, loc "/app/example.swift":9:11, scope 9 // id: %5
%6 = tuple (), loc "/app/example.swift":10:1, scope 9 // user: %7
return %6 : $(), loc "/app/example.swift":10:1, scope 9 // id: %7
} // end sil function '$s6output29withCustomCheckedContinuationyyyyYaXEYaF'
You can see that there are two hop_to_executor
in this SIL. One right at the start and another right after calling the body
closure. In Swift Concurrency, the callee is responsible to hop to the correct executor and the caller of an async function has to hop back to its executor after the async function returned. This is to ensure that at any point in an async method we are on the correct executor.
Now let's apply @_unsafeInheritExecutor
to our withCustomCheckedContinuation
and look at the SIL
sil hidden [ossa] @$s6output29withCustomCheckedContinuationyyyyYaXEYaF : $@convention(thin) @async (@noescape @async @callee_guaranteed () -> ()) -> () {
bb0(%0 : $@noescape @async @callee_guaranteed () -> ()):
debug_value %0 : $@noescape @async @callee_guaranteed () -> (), let, name "body", argno 1, loc "/app/example.swift":9:38, scope 8 // id: %1
%2 = apply %0() : $@noescape @async @callee_guaranteed () -> (), loc "/app/example.swift":10:11, scope 9
%3 = tuple (), loc "/app/example.swift":11:1, scope 9 // user: %4
return %3 : $(), loc "/app/example.swift":11:1, scope 9 // id: %4
} // end sil function '$s6output29withCustomCheckedContinuationyyyyYaXEYaF'
As you can see there is no hop_to_executor here at all. Since the body
closure can do an arbitrary amount of hops we are on an arbitrary executor after calling it. This is insanely scary and the reason why this annotation should only be used with extreme care.
For withCheckedContinuation
however this exactly what we want. Since we must avoid any potential suspension points before creating the continuation otherwise invariants inside an actor are almost impossible to maintain. It is safe to use withCheckedContinuation
because the caller of withCheckedContinuation
is going to hop back to its executor and anything that is done in the actual implementation of withCheckedContinuation
is fully thread safe and agnostic to what executor it is running on.
I hope this helps to clarify this a bit. If you have more questions, let me know!