SE-0300: Continuations for interfacing async tasks with synchronous code

I think there was a question previously in the pitch thread that I don't remember being addressed, asking why UnsafeContinuation<T> and UnsafeThrowingContinuation<T> (same for the checked variants) are separate types instead of introducing a single UnsafeContinuation<Result, Failure: Error> type?

This separation of types and making the error untyped is inconsistent with Task.Handle<Success, Failure: Error> in the structured concurrency pitch, and the already present Result<Success, Failure: Error> type.

I also don't see this addressed in the proposal itself. Is there a specific reason for this?

2 Likes

To clarify: this proposal is entirely agnostic to queues and performs no queue hopping. Absent anything else like actors being involved, whatever queue calls the resume method will continue being the current queue when the continuation is resumed. In all the examples shown, that means the queue on which the callback was called. Queue hopping is only introduced by actors, executors and the like; these are proposals that build upon the previous async/await proposal.

So in your example, again absent any use of actors, the awaited call to request would resume on whatever queue response used to execute the callback. Similarly, any APIs that uses customizable queues (either via a stored property or via an argument with the callback) would continue to do so and the continuation would resume on the same queue the callback does.

I’ll suggest to the proposal authors they add a note clarifying this to the proposal.

Not quite. This proposal is aimed at allowing calling of completion handlers from async functions. It is not directly related to actors, other than that actors rely heavily on async functions and this proposal helps with writing more async functions.

As such, this discussion of queue hopping, while helping clarify some things, isn’t really pertinent to this review. I’d ask further discussion of queues and queue hopping be taken to a separate thread to keep the review focused on the specific proposal. I realize this takes some level of suspension of concerns, because awaited functions resuming on a different and often unclear queue to the one on which they were called is unsatisfactory – as is completion handlers doing so. But that is what the actors and related proposals are intended to address, not this one.

7 Likes

Unless I'm misunderstanding something, this seems like a really bad user experience and a generally dangerous programming model. The entire value of async is that it simplifies both the interface and mental model asynchronous programming by making async code look synchronous. If that doesn't include a guarantee of calling context from one line to the next, the entire exercise is suddenly much less valuable and more dangerous. I mean, isn't one of the first things people are going to do is apply the APIs of this proposal to something like URLSessions completion handler APIs? Won't they be shocked when using the resulting async function that from one line to the next they've suddenly switched to the random background queue that URLSession uses for completion handlers? How are we supposed to use these APIs safely?

3 Likes

Nesting the APIs within Result is preferable, because the type parameters won't need to be inferred, so the Failure can be Never.

There could be an async Result.init, so the returning: and throwing: argument labels would make less sense.

There can be fewer APIs (i.e. a single Unsafe*Continuation type, with a single resume method).

extension Result {

  public struct UnsafeContinuation {

    public func resume(_ result: Result)
  }

  public init(
    _ operation: (UnsafeContinuation) -> Void
  ) async
}

extension Result where Failure == Never {

  public func get() -> Success
}

I'm not sure if your question was intended for me, but the answer might be found in §Alternatives considered: "being able to avoid the cost of checking when interacting with performance-sensitive APIs is valuable" …

Isn’t this the reason these low level APIs use “unsafe” in the name?

No.

The operation must follow one of the following invariants:

  • Either the resume function must only be called exactly-once on each execution path the operation may take (including any error handling paths), or else
  • the resume function must be called exactly at the end of the operation function's execution.

Unsafe*Continuation is an unsafe interface, so it is undefined behavior if these invariants are not followed by the operation. This allows continuations to be a low-overhead way of interfacing with synchronous code. Wrappers can provide checking for these invariants, and the library will provide one such wrapper, discussed below.

From what I understand (and to paraphrase what @Ben_Cohen said, continuation APIs here are more of a bread-and-butter language utility. Something as primitive as withoutActuallyEscaping(). Meanwhile, building on top of these primitives, the actor concurrency model is brought to live with e.g. the semantics around execution contexts you are calling for here.

It is like the way to define a synchronous function. It doesn't require execution context intrinsically. But it is flexible enough for you to enforce queue checks/switching in your method implementation, or require a callback queue as a function parameter.

As a concrete example, the equivalent of Kotlin Coroutines similarly does not require manual resumptions to be actively aware of these matters. You can call it anywhere, and the runtime internals use captured context to determine both the necessity of a dispatch, and the target dispatcher for the resumption.

So:

I mean, isn't one of the first things people are going to do is apply the APIs of this proposal to something like URLSession s completion handler APIs? Won't they be shocked when using the resulting async function that from one line to the next they've suddenly switched to the random background queue that URLSession uses for completion handlers? How are we supposed to use these APIs safely?

This should not happen. This is because the execution context of the suspending caller is fixed, and can so be captured for later use by the implementation details of the continuation to do a context switching when necessary.

I'm sorry, but that's contradicted by the paragraph I was replying to:

Absent anything else like actors being involved, whatever queue calls the resume method will continue being the current queue when the continuation is resumed.

That seems to say execution stays on whatever queue calls resume, so the user will see their code before and after the await call potentially executed on different queues. Like I said, I'm pretty sure this would be shocking to most users.

1 Like

I'm sorry, but that's contradicted by the paragraph I was replying to:

Absent anything else like actors being involved, whatever queue calls the resume method will continue being the current queue when the continuation is resumed.

That seems to say execution stays on whatever queue calls resume, so the user will see their code before and after the await call potentially executed on different queues. Like I said, I'm pretty sure this would be shocking to most users.

He did also say:

But that is what the actors and related proposals are intended to address, not this one.

Your concern is indeed legitimate in the vacuum of this proposal, which builds on a Swift that has no concurrency model in the language (yet).

But as far as I can understand, a deep integration approach is chosen for Swift to bake the actor concurrency model into the language, with no design intention to allow alternative runtime (unlike Kotlin).

So what "resuming a continuation" entails in practice will be and only be defined by the actor concurrency model, to be proposed separately. This is why, I assume, Ben was asking to suspend the concerns in this area.

Edit: After all, without a language definition of async context and synchronization boundary, what checking/switching semantics can we meaningfully define for this API? It cannot be defined around dispatch queues, for example, since this is a concept from libdispatch, external to Swift the language. (... unless the grand vision is changed to incorporate libdispatch into the language, which seems... very unlikely).

Perhaps proposing this after the actor concurrency model is in place would have rid of the confusion. But this is where we are at. :sweat_smile:

Right, but what does any that have to do with my point? This proposal presents API specifically intended to bridge the gap between functions using completion handlers and async contexts. It appears to enable use cases where the typical guarantees made to users of Swift's async syntax no longer hold. That is, users can use the API of the proposal to build async APIs for which the normal expectation of async APIs, that there is a single execution context, no longer hold. That this won't be a problem when using actors isn't relevant here, since there's no requirement for actor involvement here.

So what am I missing? Would calling async APIs created with the proposed APIs from within an actor guarantee the execution context doesn't stay on the dispatched queue? Is the assumption, then, that such APIs will only ever be called in such contexts? Nothing I've seen so far suggest that to be the case.

1 Like

The way I see it, either your function may access actor-isolated state between the suspension point which uses withUnsafeContinuation and the next one, or it will not do so.
In the first case the runtime will ensure the function is using the correct executor, potentially inlining the executor change into the withUnsafeContinuation callback.
In the latter case the runtime assumes you either don't care about which executor the function is running on, or that you want the executor the caller of the callback used, and therefore won't change the executor.

I'm not even talking about accessing state, just a three line example. Or is the assumption here that every interaction with async APIs will be associated with some actor context, like the global UI actor? That there is no way to have a standalone instance of async usage? What about script, or simple main.swift usage? Is there some implicit environment I'm missing?

1 Like

I'm +1 on this proposal, this is clearly important functionality, and I appreciate that the API works with both error handling and Result types, that is a nice touch. Some more detailed thoughts:


This point in the alternates considered section is a bit concerning/confusing to me:

  • Although the consequences of misusing CheckedContinuation are not as severe as UnsafeContinuation , it still only does a best effort at checking for some common misuse patterns, and it does not render the consequences of continuation misuse entirely moot: dropping a continuation without resuming it will still leak the un-resumed task, and attempting to resume a continuation multiple times will still cause the information passed through the continuation to be lost. It is still a serious programming error if a with*Continuation operation misuses the continuation; CheckedContinuation only helps make the error more apparent.

Why does this leak a task? Is this a logic error like a leak or is this a memory safety violation? If this is a memory safety problem, then this should probably also be an unsafe API, even if it is slightly safer. If it is memory safe but "just" a logic error, then I don't think the rationale above is super strong. We have lots of logic errors that happen from API misuses, and we don't dance around that. For example, "Sequence" in a generic context is an unchecked single iteration sort of beast and is often misused, we don't encode fear into the name.


I really wish we could simplify this a bit, by merging UnsafeContinuation and CheckedContinuation into a single safe XYZContinuation type. Per Konrad's nice explanation above, this would introduce some overhead, but:

  1. These types are only used when interoperating with legacy completion handler APIs that should eventually be replaced with more SwiftConcurrency-native centric APIs over time.

  2. It might be possible to reduce the API surface area by factoring this in other ways.

Just to brainstorm other factorings, instead of duplicating the struct types, would it be possible to have these two functions (plus the corresponding throwing versions):

func withContinuation<T>(_ operation: (XYZContinuation<T>) -> ()) async -> T {}
func withContinuation<T>(unsafe: Bool, _ operation: (XYZContinuation<T>) -> ()) async -> T {}

Notably the first operation would always be "checked", the later would be optionally safe:

  // proposed
  withCheckedContinuation { continuation in
     ..
  }
  withUnsafeContinuation { continuation in
     ..
  }

  // Alternate:
  withContinuation { continuation in
     ..
  }
  withContinuation(unsafe: true) { continuation in
     ..
  }

This uses one struct type for the checked and unsafe cases (the unsafe one would just use a null ARC pointer so no significant overhead would be present). The makes the types and operations "safe by default" and has less API surface area. Again, you'd still need the "throwing" versions of them.


The rationale in the alternates considered section about wanting to avoid the Continuation name for the type makes a certain sense to me (I also can't wait for move-only types and deinit on structs!):

  • Naming a type Continuation now might take the "good" name away if, after we have move-only types at some point in the future, we want to introduce a continuation type that statically enforces the exactly-once property.

Would it be possible/reasonable to do something like:

public struct XYZContinuation ...
public typealias Continuation = XYZContinuation

and then in a future release you can roll out NonCopyableContinuation and change the typealias possible with a new Swift version. This could work if the patterns people use in practice don't involve copies of the continuation in the common case.


I just throw these things out as ideas -- I don't know how practical they are, but it would be good to consider them. Overall, very nice and understandable proposal, thank you!

-Chris

5 Likes

This is best illustrated by an example, consider the following snippet:

await hello() // I'm in *some Task*, I keep it around while I wait

func hello() async { await with*Continuation { c in /* don't do anything */ } 

results in the task awaiting on hello() to "hang forever". So it is not that the withContinuation leaks anything directly, but rather that since the waiter never gets resumed, it remains retained (and waiting (!)) forever.

This is a serious issue (equally bad as deadlocking I guess), however it is not really a memory safety issue per se.


Yeah though that is a narrow view on this API. It is not only for objective-C APIs that will be replaced with async APIs, but also for interacting with "some C API that I don't own that works via callbacks, and it will never change".

Many examples of such APIs show up in the server ecosystem, and include database drivers or other low-level APIs which we don't control or even kernel APIs, where we might want to express our shims over them as async functions. It would be very annoying if for very low level libraries such as these, we were limited from using async functions because of the overheads caused by the checked API. Note that even with the checked: false shape you propose it would cause additional ARC traffic as it has to power the deinit warning being triggered.

I did not dive deep into this specific topic, however e.g. NIO's wrappers around epoll and kqueue would be nice to express with async functions rather than a callback how it does today (func whenReady(strategy: SelectorStrategy, _ body: (SelectorEvent<R>) throws -> Void) throws -> Void), but it's certainly another example of an low-level API that would not want to sacrifice any performance on for a integration with swift's async system.

Though epoll wrappers I guess would end up as an async functions that blocks anyway, since hitting epoll_wait so the specific example might not be the best... but you get the idea -- in those very low level APIs, where we might want to use an async function, and definitely do not want any additional overhead, I don't see a different way other than offering two types.

2 Likes

You’re still describing outcomes here, not benefits. Why is it better to use Result to cut back the API surface from 2 methods to one? Why is using Result with Never better than a throwing and non-throwing variant?

For me personally this would satisfy the principle of least surprise. Result is pretty much a standard right now when dealing with callback-based asynchronous code. It's much easier to just pass a Result value as is from a callback to resume instead of destructuring it manually into success and failure cases.

Similarly, Combine doesn't have separate Publisher and ThrowingPublisher protocols. I would expect that integration of async functions with Combine's publishers makes a lot of sense, having separate throwing and non-throwing continuation types makes it inconsistent.

2 Likes

Result is standard for callbacks because there is no mechanism for them to support throwing instead. So instead there need to be different design patterns to take its place. There is no ThrowingPublisher for similar reasons, not stylistic ones. But async/await provides this mechanism.

A Result-based approach was considered back when Swift’s error handling was originally designed. I’d very much discourage people pushing Result as an alternative to throwing with this proposal if this boils down to “if I designed Swift from scratch, I’d use Result instead of Swift’s error handling mechanism everywhere”. Such preferences won’t be considered to “fit well with the feel and direction of Swift” by the core team.

Nothing here is ruling out stylistic preferences for returning Result from async functions in cases where it suits better, just as you can from synchronous functions today. But arguments that this proposal shouldn’t take advantage of async functions being able to throw because Result is fundamentally a better approach than throwing probably won’t get very far.

4 Likes

I understand that it's not directly related to this exact proposals, but this is also inconsistent with Task.Handle<Success, Failure: Error> in the structured concurrency pitch. Would it make sense for that to be updated to Task.Handle<T> and Task.ThrowingHandle<T> instead?

I was originally (before this proposal review thread was created) evaluating continuations as a part of the structured concurrency pitch, and the inconsistency there between two separate continuation types and only one task handle type was glaring.

What's the reasoning then for inclusion of getResult() in addition to simple get() in task handles for structured concurrency?

extension Task {
  struct Handle<Success, Failure: Error>: Equatable, Hashable {
    /// Retrieve the result produced the task, if is the normal return value, or
    /// throws the error that completed the task with a thrown error.
    func get() async throws -> Success
    
    /// Retrieve the result produced by the task as a `Result` instance.
    func getResult() async -> Result<Success, Failure>
  }
}

I understand that the answer could be that "continuations and structured concurrency are separate proposals now and should be discussed separately". But I only want to understand where exactly the use of Result “fits well with the feel and direction of Swift” and where it doesn't. What's the distinction here between continuations and task handles that justifies the inclusion of getResult() and making the error type explicit in the latter, but not in the former?

By having fewer API variants, I find them easier to learn and use. I was also trying to limit top-level Unsafe* and withUnsafe* APIs, for better auto-completion of the existing pointer APIs.

I now understand your concern, but that wasn't my intention. My earlier example uses try result.get() immediately.


Could this be improved by adding a static suspend method to each continuation type?

try await UnsafeThrowingContinuation.suspend { continuation in
  /* ... */
  continuation.resume(returning: value)
}
5 Likes

I'm not too happy about "await with" either :-)

How about "await continuation"? Or "await continuation block"?

You could even rename the closure argument "continuationHandle", or even "promise" as this has the same semantics as the closure argument to Combine.Future()...

try await unsafeContinuation { promise in
  /* ... */
  promise(.success(value))
}

??