Clarification needed on UnsafeContinuation documentation

The documentation for withUnsafeContinuation and withCheckedContinuation says (emphasis added):

Suspends the current task, then calls the given closure with [an unsafe|a checked] continuation for the current task.

To me, this says unambiguously that the suspension happens before the continuation closure is called. But I don't think those are the actual semantics.

SE-0300 says:

withUnsafe*Continuation will run its operation argument immediately in the task's current context, passing in a continuation value that can be used to resume the task.

To me, this says that the task is not suspended before the continuation closure is called. I believe these are the actual semantics of the implementation. Correct?

Would you agree that the documentation is wrong?

5 Likes

They're both right. The task stops executing any async code at all before the continuation is formed, and any state will be moved off of the callstack into the task object at that point. The closure is then immediately executed in the same execution context (in other words, the current thread) with the closure as a parameter. Once the closure returns, control goes back to the executor.

3 Likes

I think you could argue that the task is still running while the closure is executing. I know that's detectable in at least one way, which is that task-local values are still set; you can't change them without running async code, which you can't do within the closure, but you can still read them. I'm blanking on whether there are other ways this is semantically detectable.

4 Likes

I find the meaning of the documentation hard to understand.

Consider the following code (which I'm using in unit tests to synchronize progress of two tasks). Depending on the interpretation of the documentation there is a race condition in self.completion = $0.

actor MeetingPoint {
    var completion: CheckedContinuation<Void, Never>? = nil

    func join() async {
        if let completion = completion {
            self.completion = nil
            completion.resume()
        } else {
            await withCheckedContinuation {
                self.completion = $0
            }
        }
    }
}

When two tasks call join at the same time, one will succeed and enter the actor. This task progresses to await withCheckedContinuation. If the task suspends at this point, before calling the closure, the second task may enter the actor and find the condition in the wrong state (completion == nil). That's not the intended behavior.

If, on the other hand, the task does not suspend before the closure is executed, the behavior is as intended and there's no race condition.

The documentation makes me think the code is wrong, and checking needs to take place within the closure.

1 Like

To further illustrate what I mean, this is the code I'm actually using, because the documentation makes me afraid the code will suspend at the wrong point:

func join() async {
    return await withCheckedContinuation { newCompletion in
        if let oldCompletion = completion {
            self.completion = nil
            oldCompletion.resume()
            newCompletion.resume()
        } else {
            self.completion = newCompletion
        }
    }
}
1 Like

The closure is executed synchronously, without allowing any interleaving on the actor; your first code is correct.

This scheduling behavior is actually a special power of the with*Continuation functions ever since SE-0338. We intend to generalize that so that other functions can opt in to that behavior, but we haven't done so yet.

5 Likes

I think this is where my misunderstanding comes from. My mental model is that the task continues running, executing the continuation closure, and that the task suspends when the continuation closure returns. (This begs the question what happens when the continuation closure resumes the continuation synchronously, which would kind of resume the task before it suspended, so maybe this mental model isn't ideal.)

This interpretation better fits my mental model. Another way to detect this seems to be withUnsafeCurrentTask:

func doSomething() async {
  withUnsafeCurrentTask { unsafeTask in
    print("Task before continuation: \(unsafeTask!.hashValue)")
  }
  let _: Void = await withCheckedContinuation { continuation in
    withUnsafeCurrentTask { unsafeTask in
      print("Task inside continuation closure: \(unsafeTask!.hashValue)")
    }
    continuation.resume()
  }
}

This will print the same hash value for both unsafe tasks. (hashValue isn't ideal to verify these are identical, but close enough; another way could be to use withUnsafeCurrentTask to cancel the current task before creating the continuation, then check Task.isCancelled inside the continuation closure.)

2 Likes

Interesting, thanks for this information. I didn't consider how SE-0338 would change things, but knowing that with…Continuation needs special handling to preserve its semantics makes things clearer.

Let me expand on your post to verify my understanding:

Among other things, SE-0338 prescribes:

non-actor-isolated async functions never formally run on any actor's executor

I.e. if an actor calls a non-actor-isolated async func, the runtime must switch executors immediately. The executor hop may (not must) suspend the current task, e.g. if the target executor is busy.

There is a special exception for await with*Continuation (implemented via @_unsafeInheritExecutor, I think) that opts out of the new SE-0338 semantics and continues to execute these functions on the calling executor.

Correct?

2 Likes

That’s correct, yes.

The semantic rule has always been that the task is not resumed until both resume is called and the closure has returned.

If resume is called synchronously, the task is not suspended at all.

3 Likes

Thanks John!