Is using `withoutActuallyEscaping` within the context of a `TaskGroup` valid?

A simple example would be this:

func test(
  operation: @Sendable () async -> Void
) async {
  await withoutActuallyEscaping(operation) { operation in
    await withTaskGroup(of: Void.self) { taskGroup in
      taskGroup.addTask {
        await operation()
      }
    }
  }
}

Would this be a valid use case for withoutActuallyEscaping? I don't see how the closure would escape the async context. A TaskGroup anyways always waits until all child tasks have finished. And if it is a valid use case, why does addTask take an @escaping closure in the first place?

I have seen this being asked here: withoutActuallyEscaping + async functions - #5 by swhitty. But there was no answer.

1 Like

IFAIK withoutActuallyEscaping is not async.

That means, no matter how you implement test, operation will guarantee to exceed the invocation period of withoutActuallyEscaping, which is an undefined behavior.

1 Like

@CrystDragon

I mean the code I posted, does actually compile. And if I do this:

await test {
  try? await Task.sleep(for: .seconds(1))
}

... it will actually wait for one second.

1 Like

You're right.

Now, I'm confused why & how withoutActuallyEscaping accepts async closures... There must be some magic I've not known about.

1 Like

Thinking about it kinda makes sense. Awaiting doesn’t return to the calling context, so the closure never outlives the call

2 Likes

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 awaiting 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 awaits 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.

3 Likes

As for why the TaskGroup interface marks the addTask closures as @escaping, that's a good question. Normally you would await all the tasks you add to the group in the async closure for the group itself. But there's no compiler enforced requirement to do so. In fact I wonder what happens if you don't. If you get to the end of withTaskGroup... without awaiting all added tasks, are the remaining ones cancelled and then awaited, or are they cancelled without waiting? Also, imagining the implementation of TaskGroup, the task closures are probably stored somewhere, and as soon as you store a closure it has to be @escaping. There's no way, for example, to mark even a locally stored closure as non-escaping.

Using withoutActuallyEscaping there probably falls under the "okay but unsafe, you better know what you're doing" category, i.e. you probably want to ensure you actually are awaiting every added task yourself and not trust the implementation to do it for you.

1 Like

@aetherealtech
First of all thanks for the explanation. This was quite thorough.

Second, the code I pasted in the original question does not await anything (no waitForAll, or next), however it still awaits all child tasks and does not cancel them. And this is also documented here (I think):
A group waits for all of its child tasks to complete or be canceled before it returns. After this function returns, the task group is always empty.

So I'd assume it is not even "unsafe but okay in some scenarios", but actually safe?

The theory of storing the closures and therefore the @escaping requirements makes sense. My theory was, that this was added so that taskGroup cannot escape into the addTask closure (since it is inout), but this was just a wild guess.

1 Like

The proposal that introduced TaskGroups says:

Tasks added to the group by group.addTask() will automatically be awaited on
when the scope exits. If the group exits by throwing, all added tasks will
be cancelled and their results discarded.

So it's true that the functions don't escape the scope of the task group. However, that still counts as escaping the scope of the call to addTask, so they have to be @escaping. It would be nice to recognize that uses of enclosing non-escaping values like Philipp's example are okay (which they are — this use of withoutActuallyEscaping is fine), but currently this would require a special case in the compiler. I would hope that the upcoming generalized support for ~Escaping types will eventually allow this to be expressed directly in the language.

12 Likes

@John_McCall Thank you for confirming this.

1 Like