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.
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.
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.
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.
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.
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.
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.).
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.
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
}
}
}
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.