[Pre-Pitch]: updating `with{Checked|Unsafe}Continuation` to support typed throws (and perhaps nonisolated(nonsending))

this earlier pitch proposes holistic typed throws adoption throughout the Concurrency module. specifically it suggests the following changes for the continuation API:

Continuations

Currently, there are two variants of each continuation creation method to
accommodate for the throwing and non-throwing variant. Since, the natural
spelling would be without the Throwing in the method name. We propose
to add the following two new methods:

public func withCheckedContinuation<T, Failure: Error>(
    function: String = #function,
    _ body: (CheckedContinuation<T, Failure>) -> Void
) async throws(Failure) -> T

public func withUnsafeContinuation<T, Failure: Error>(
  _ fn: (UnsafeContinuation<T, Failure>) -> Void
) async throws(Failure) -> T

this change has not yet been implemented, but it seems like it would be a nice improvement, and it may be reasonably straightforward to add (here is a draft attempting to do this). i assume such a change would require going through evolution as i think it requires introducing new API.

assuming this would be a reasonable change to make, one question i had is whether the new functions should also be nonisolated(nonsending). since they are documented to not suspend when called (and the existing implementations by default inherit isolation) it seems like it that would make sense.

curious to hear general thoughts, and i'd particularly be interested if you can think of any implementation challenges that might be faced – ABI concerns, overload ambiguity issues (i think i hit one of these already), etc.

if this seems like a desirable change and the implementation is as straightforward as i'm hoping, i can put together the doc for a formal proposal. it'd probably be nicer to update all the Concurrency API surface at once for this sort of thing, but so far that hasn't happened (and may be unrealistic) and IMO piecemeal adoption is better than nothing.

6 Likes

Typed throws adoption in the standard library was approved as part of the proposal accepting typed throws, at least where that adoption is essentially seamless for the end user.

If there are specific source compatibility or other idiosyncratic issues that arise which require their own design consideration, it would be reasonable to consider if that requires a separate proposal process. However, to my knowledge, adoption on these APIs is blocked on working out implementation details.

It being accepted that we intend to adopt these language features in the standard library, with implementation itself outside the review process, is there anything about these APIs to review? If not, then my understanding is that no further review is necessary.

2 Likes

Right, this doesn't need a proposal.

I'm actively working on these typed throws and nonisolated nonsending adoption throughout the Concurrency library.

Landing those changes is pretty difficult and sometimes even uncovers bugs that need addressing before we can make these changes. A big set of changes is pending here: [Concurrency] Prevent task re-enqueues in continuation APIs by ktoso · Pull Request #84944 · swiftlang/swift · GitHub and [Concurrency] Correct enqueue behavior in with... functions by adopting nonisolated(nonsending) by ktoso · Pull Request #80753 · swiftlang/swift · GitHub and I'm going to be revisiting those soon.

I also recently landed Concurrency: improve withTaskPriorityEscal... docs and adopt noniso(nonsend) by ktoso · Pull Request #86282 · swiftlang/swift · GitHub and more changes in the same style are incoming, including withTaskGroup etc (which did uncover some issues with nonisolated(nonsending) closures and isolation computation).

Keep in mind that ABI must be maintained when changing all those APIs, and some of the changes don't change mangling but DO change the ABI, so one has to be extremely careful making those changes.

I appreciate the help, but on these I'd actually suggest to leave it to myself as I'm going through these one by one recently and it's work I have scheduled in the coming weeks.

6 Likes

For what it's worth, the Pitch: Remove discardableResult from throwing task initializers - #25 by jamieQ pitch is part of this work as well, because we moved to adopt typed throws in there, which opened up this question which actually does need to go through evolution.

1 Like

sounds good to me. to confirm though – will you be adding typed throws support to the continuation functions? i skimmed the PRs you linked and they appeared focused on adopting nonisolated(nonsending) and did not yet change the throws signature AFAICT.

We should do this. :slight_smile:

As I've said before, it's my opinion that implementing typed throws in the stdlib should not require further Swift Evolution proposals unless it involves a breaking change.

4 Likes

Yes, we'll do typed throws and nonisolated nonsending in all these APIs. I'll do the change "together" so we have less overloads and compat shims laying around.

1 Like

Since we are already touching on the topic of execution semantics, here are a few examples to better visualize them.

  • Execution semantics comparison between Swift 5.5, Swift 5.7(SE-0338) and Swift 6.2(nonisolated (nonsending)). Initially, in Swift 5.5, execution semantics were sticky to the caller’s executor for nonisolated asynchronous tasks until the nonisolated code reached a real suspension point. This behavior was very similar to Task.immediate, where, upon resumption, the nonisolated code would then continue executing on the Global Concurrent Executor (GCE). Swift 5.7 changed these semantics so that nonisolated asynchronous tasks would always and immediately hop off to the GCE. Now, with the optional nonisolated(nonsending) feature in Swift 6.2, the caller’s executor is inherited for the duration of the call for nonisolated asynchronous tasks.

  • The problem with #isolation and function parameters. The isolated parameter on these functions forces the function to switch to the isolated parameter’s executor, only to immediately hop off it and switch to the GCE (when the passed-in function or closure is nonisolated and asynchronous). After all, since Swift 5.7, nonisolated asynchronous tasks always execute on the GCE regardless of the caller’s executor, which makes this easy to get wrong. SE-0461 succinctly explains why this can be confusing:

nonisolated is difficult to understand. There is a semantic difference between the isolation behavior of nonisolated synchronous and asynchronous functions; nonisolated synchronous functions always run on the caller's actor, while nonisolated async functions always switch off of the caller's actor. This means that sendable checking applies to arguments and results of nonisolated async functions, but not nonisolated synchronous functions.

  • Here is a concise example of how to resolve these issues with nonisolated(nonsending) and why the old semantics are undesirable.

@ktoso Feel free to correct me if I got something wrong.

These are welcome changes, thanks! Is there a central discussion/dashboard that mentions all the planned API changes? Might be good to make sure APIs aren't missed (like Clock.sleep, etc.).

2 Likes

Feel free to file issues if you’re concerned about some being missed. Otherwise basically checking anything that’s async or takes closures in the concurrency libs.

1 Like

async let was one that I stumbled across recently.

3 Likes

Hah, thanks, that'll be a bit more work than just library updating -- thank you for the reminder :slight_smile:

3 Likes

This one is a bit tricky since changing the requirement on the protocol is a source break. While most clocks throw a CancellationError there might be odd clock implementations out there that don't. The only way that I can see to stage this in would be like this:

protocol Clock {
  // Existing method
  func sleep(until deadline: Instant, tolerance: Instant.Duration?) async throws

  // New typed throws method
  func sleep(until deadline: Instant, tolerance: Instant.Duration?) async throws(CancellationError)
}

extension Clock {
  func sleep(until deadline: Instant, tolerance: Instant.Duration?) async throws {
    // Default for the existing method
    try await self.sleep(until: deadline, tolerance: tolerance)
  }

  func sleep(until deadline: Instant, tolerance: Instant.Duration?) async throws(CancellationError) {
    // Default for the new method that force casts
    do {
      try await self.sleep(until: deadline, tolerance: tolerance)
    } catch {
      throw error as! CancellationError
    }
  }
}
1 Like

While typed throws would be great here, nonisolated(nonsending) changes would still be a great improvement and required for "test" clocks to perform reliably without the injection of artificial suspension points.

6 Likes