Understanding how withoutActuallyEscaping
works with an async
closure requires precise understanding of why closures ever need to be marked as @escaping
, and how async
call stacks actually work.
In this code:
@MainActor
func doTheThing() {
var aLocalValue = 0
DispatchQueue.main.async { // This closure is @escaping
aLocalValue = 5
}
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { // Also @escaping
print("The value is: \(aLocalValue)") // This print 5. But how!?
}
print("The value is: \(aLocalValue)") // May print 0 or 5
}
We have a local variable, of a value type (Int
) to boot, but not only does it survive past the scope it was declared in, we're able to somehow write back to it after the scope is finished. If we were just capturing an immutable copy, that would be easy: copy the current value into the structure for the closure. But that doesn't allow closures to write back to the value in a way that other escaping closures who also captured it (and not explicitly, by value) can "see".
The only way for this to be possible is that the local value, which is normally just on the stack, has to be moved onto the heap. And since multiple closures can capture it, it has to be put in a reference counted box, so that the last capturing closure can destroy it once it gets discarded. So behind the scenes the compiler does this:
final class Box<T> {
var value: T
init(value: T) { self.value = value }
}
@MainActor
func doTheThing() {
let _aLocalValue = Box(value: 0)
DispatchQueue.global().async { [_aLocalValue] in
_aLocalValue.value = 5
}
DispatchQueue.global().asyncAfter(deadline: .now() + 5) { [_aLocalValue] in
print("The value is: \(_aLocalValue.value)") // Now it makes sense, right?
}
print("The value is: \(_aLocalValue.value)")
}
If we didn't mark closures that escape to the compiler, it would have to defensively box everything mutably captured by closures, even if they are run immediately and discarded (i.e. the closure in an array.map { ... }
).
If you think about it, this same problem exists with async code:
@MainActor
func doTheThing() async {
var localValue = 0
try? await Task.sleep(nanoseconds: 5_000_000)
localValue = 5 // It might not look like it, but that stack frame is long gone.
}
All those local variables have to be moved out of the stack into some sort of coroutine frame. I don't know exactly how Swift implements this. Some languages store coroutine frames on the heap (effectively doing the same thing as above, although reference counting isn't needed because there's only one owner). It's also possible to create a dedicated stack that isn't owned by any single OS thread.
Calling an async
closure immediately (which requires await
ing it) that captures local state has to solve the problem of making that local state survive past the original stack frame. But that problem already has to be solved by async code in general (the local variables have to survive past await
s even when no closures are involved).
If an async
closure escapes, that means it survives not only past the original stack frame but also past the async call stack. Whatever mechanism Swift uses to keep local state around during the async call is no longer good enough. The state has to survive even longer now. That might involve double duty: first placing the local state into a coroutine frame (however that is done), and then moving captured state out to a reference counted box, adding another layer of indirection.
If you pass an async
closure to a parameter marked @escaping
but it never actually escapes, the compiler still has to add this extra indirection/reference counting even though it's not necessary. Simply moving the state into the coroutine frame would have been fine. That is why it is still meaningful to distinguish async
closures that escape and ones that don't.