Pitch: `Continuation` — Safe and Performant Async Continuations

Note that you can move ownership of a noncopyable value into a closure using a capture list. The language still doesn't know when a closure will only run once, so it won't let you statically consume the value, but you can dynamically consume it using Optional.take. Something like this should be possible:

let data = await withContinuation(of: Data.self) { continuation in
  eventually {[continuation = Optional(consume continuation)] in
    // resume the continuation if we haven't yet, trap if we try to twice.
    // (you could use ? to ignore spurious reattempts, or `guard let` to do
    // something more complex)
    continuation.take()!.resume(returning: someValue())
  }
}

I see a few people asking for us to wait to have a ~Discardable feature before delivering this, but I worry that perfect is the enemy of good here. Although we've introduced ~Copyable and ~Escapable in relatively rapid succession, they both have come with a large amount of work, which still isn't completed, to catch the language, compiler, and standard library up to full expressivity in working with those types. ~Discardable is maybe a more incremental change now that we already have ~Copyable, but it would nonetheless come with its own long tail of standard library updates and ecosystem catchup for non-discardable types to be fully expressive. Continuation as a noncopyable type will already be a massive improvement in static safety over the continuation types we have today, and having dynamic safety checks for dropped continuations seems like a tradeoff in line with other places Swift has only dynamic safety checks (index bounds, integer overflows, class exclusivity checks, etc.) As such, I think it's still worth introducing this type now instead of waiting possibly indefinitely for future language features.

8 Likes

I don’t know whether this is enough reason to not ship the type, but Continuations, like semaphores and rw-locks, break the chain of priority donation because it’s unclear which task is going to fulfill them.* I worry that having an un-prefixed “Continuation” type will make them seem like a normal thing to use rather than a fallback. Right now you have to go out of your way to use one, and hopefully this gives you a moment to pause and consider if it’s really the best option. (On the other hand, if it is, you don’t usually have any other choices.)

* I expressed this about another language’s continuations at one point and was informed that their runtime was able to track which tasks had access to the continuation, thus allowing priority donation to conservatively boost all of them. But I don’t think Swift’s runtime is at all the shape to support that.

4 Likes

Im not sure that tracking which task will have access to a given continuation is even possible. That would require prescience for the program to know what tasks will be run. The best that can be done is adjustments to the quality of service bucket or priority when a waiting operation is made. That would only apply however to cases that would not make forward progress else wise. Async/await in swift however allows for the other tasks and such to run (even in actor isolations they are recursive) so that if the program itself makes forward progress then the continuation would as well.

2 Likes

Yikes, I wouldn’t even want to do that based on a reachability analysis. Over-promotion is also a serious problem in a priority scheduler.

2 Likes

That's a good point. With Continuation as a noncopyable type, I wonder if we can do something similar to that other language, since only one context ought to have obvious ownership of the continuation at any point in time (although not all contexts are clearly associated with a specific task). Perhaps a custom move operator could dynamically look at what task the mover is running under? Do you know anything about how that other language's mechanism works?

I do think we should have some kind of async gating primitive in the concurrency library with built-in priority inversion avoidance, and we should encourage people to use that rather than continuations directly. The design challenge with such a primitive is all around how to reasonably give access to its internal data structures. The primitive inherently needs to track things like who's waiting at the gate and who's currently inside, and a lot of the utilities you'd want to define using the primitive (like a non-reentrant async lock) could take advantage of that information directly rather than having to duplicate it. But while that information never needs to change concurrently — we can expect uses of the primitive to be externally synchronized — it does need to be read concurrently when task priorities change. Anyway, it's a whole separate discussion.

5 Likes

To be fair, this type is already going to be hard to use even without ~Discardable, simply because it's non-copyable.

In your actor example, continuation is an Optional. Because of this, we are essentially just shifting the responsibility of the dynamic check for a double-resume to the user. When we call continuation.take(), we have to make a deliberate decision on how to handle an unexpected nil - whether to force-unwrap, use a precondition or an assert, log to analytics, or silently ignore it.

It feels very asymmetric that the responsibility for handling >= 2 resumes is pushed onto the user, while the responsibility for handling 0 resumes is hardcoded into the type via a deinit trap, giving the user absolutely no degree of control*.

Thinking about it more, my own reasons against ~Discardable actually come down to how it would compose with the rest of the language. Non-discardability is viral (an Optional of a non-discardable type becomes non-discardable), but we would still need a definitive point of destruction for these values inside classes and actors. The only viable places to safely consume a non-discardable property are where we know the enclosing object is dying and no further methods can be called on it - in deinit or a consuming method that discards self. To solve this, we would need an entirely new set of rules for deinit:

actor MyStateMachine {
  var continuation: Continuation<...>?

  deinit {
    // The compiler must enforce special handling of 'continuation' here
    if let continuation = consume continuation {
      continuation.resume(...)
    }
    // The compiler should also prevent calls requiring 'self' at this point,
    // because we consumed a part of it.
  }
}

And this feels to me like a lot of extra language complexity (and even more implementation complexity) for a yet-to-be-defined win. But I'd still prefer to have this properly explored before making a decision about this proposal.

Sure, I agree, and this pitch is arguably a good solution. But a solution with tradeoffs (a whole spectrum of them) can be written by anyone in their code today. The proposed version is functionally no different than those user-space wrappers.

And if this is just a good-enough tradeoff, what is the urgency to lock it into the standard library right now (with such a cool name)?


* We can give the user control over how to handle an unused continuation, while keeping a reasonable default behavior:

public protocol UnusedContinuationHandler {
  func handle()
}

public struct DefaultUnusedContinuationHandler: UnusedContinuationHandler {
  public init() {}
  public func handle() { fatalError() }
}

public typealias Continuation<Success, Failure: Error> =
  NonCopyableContinuation<Success, Failure, DefaultUnusedContinuationHandler>

public struct NonCopyableContinuation<Success, Failure: Error, UnusedHandler: UnusedContinuationHandler>: ~Copyable { ... }

@inlinable
public nonisolated(nonsending) func withContinuation<Success, UnusedHandler: UnusedContinuationHandler>(
  of type: Success.Type = Success.self,
  unusedHandler: UnusedHandler,
  _ body: (consuming NonCopyableContinuation<Success, any Error, UnusedHandler>) -> Void
) async throws -> Success {
  try await withUnsafeThrowingContinuation { (continuation: UnsafeContinuation<Success, any Error>) in
    body(NonCopyableContinuation(continuation, unusedHandler))
  }
}

@inlinable
public nonisolated(nonsending) func withContinuation<Success>(
  of type: Success.Type = Success.self,
  _ body: (consuming Continuation<Success, any Error>) -> Void
) async throws -> Success {
  try await withContinuation(of: type, unusedHandler: DefaultUnusedContinuationHandler(), body)
}

This at least removes the asymmetry.

5 Likes

The problem with ~Discardable is that it either requires (1) absolutely every other system implicated in the consumption of the value to have annotations about exactly when and how it's called or (2) exactly the sort of dynamic cop-out that people are annoyed about in this proposal. (1) is much bigger than just called-once function values, it's called-once methods and one-of-these-is-called-once method sets, which also means some kind of use restriction or type state on the enclosing object, and... it's just a morass of language complexity. And you can't do things like storing an Optional<MyDiscardableValue> and consuming the value out of it when you use it, because that optional type is still ~Discardable and so whatever stores it still has to satisfy the static restriction that the optional value is known to be consumed before deinitialization, so the optionality solves nothing.

4 Likes

Big +1 on this.

I use CheckedContinuation everywhere but it’s still a massive hassle to go through my code and work out why I’m seeing log messages from misusing it.

And having to constantly write:

continuation?.resume()
continuation = nil

to ensure I don’t accidentally double resume the continuation isn’t exactly the worst pattern in the world but it’s still a hassle.

Much like @KeithBauerANZ I do have questions around dynamic storage for ~Copyable types.

About half my use of continuations follows the pattern of an identifiable action that resumes a continuation on completion, then while the action is running, subsequent attempts by other clients to start the same action get added appended to the continuations to resume.

@dnadoba has addressed the non-stdlib collection types available – so I guess I need to learn about those – since without them, my ability to use this new ~Copyable type will be limited to single client scenarios.

Maybe a followup question though: would this effectively deprecate CheckedContinuation? Is there a scenario where CheckedContinuation would continue to be used?

1 Like

A quick followup after reading some other replies… I always hate to see fatalError anywhere because in a long enough lived codebase, legacy codebase, it feels like every fatalError eventually finds a way to trigger in prod (and just because you get the stack trace, doesn’t mean you understand the problem).

So the idea of ~Discardable is very appealing to me. HOWEVER for my purposes, I would really want to work with collections of ~Discardable which means collections that are ~Discardable if their contents are ~Discardable and some kind of aggregate way of consuming all contents. And any type that holds a ~Discardable would be required to either be ~Discardable itself or have a deinit function that cleans up all of its ~Discardable members, correctly, rather than letting them go. What a massive endeavour.

But geez it’d be so nice.

4 Likes

I wouldn't expect any of those complex features to be required for an initial introduction of ~Discardable, if it ever happens.
We already have two existing language boundaries that inherently provide the guarantees we need ("executes exactly once" and "the value is inaccessible afterwards"):

  1. When a local variable leaves its scope.
  2. When an instance of the enclosing type is about to be destroyed (inside deinit or a consuming method).

If the compiler could diagnose an unconsumed non-discardable value in these two cases, that would be enough to transfer the responsibility for the dynamic check onto the user, and eliminate the need for the standard library's Continuation to hardcode a fatalError.

2 Likes

The overall idea is great.

If there is idea about ~Discardable protocol, I don’t quite understand why we also need a new Continuation type right now.

This new Continuation is:

  1. still not fully compile-time safe.
  2. can be implemented in existing codebases.

The second point is particularly important. If a new Continuation type is added to the standard library, most iOS / macOS / tvOS / watchOS apps won’t be able to adopt it for several years because it will only be available on the latest OS versions. In practice, that means real projects will likely just implement a similar wrapper in their own codebases instead of waiting 2–3 years for adoption to become feasible.

On the other hand, ~Discardable significantly improves the design of Continuation, making it possible to enforce proper usage at compile time. ~Discardable also seems broadly useful on its own.

If I understand correctly how ~Discardable will work, then there should be no differences between OS versions. The only difference is that with a newer toolchain, ~Discardable becomes available and the compiler can provide proper diagnostics, enforcing a Continuation must be explicitly consumed.

In that case, it should be relatively easy to provide a backport with the same API in a project’s own codebase, while still benefiting from strict compile-time guarantees when building with the latest toolchain on latest OS.

For these reasons, I believe it would be better to propose ~Discardable first and then build Continuation on top of it.

4 Likes

I’m glad I read through the thread before commenting, because you captured exactly what I wanted to say down to minute details, immediately after reading the proposal. I definitely think that a continuation as proposed is way too easy to reproduce manually and a ~Discardable feature would make a huge difference not only for this, but many other cases where destroying a value can fail and/or requires parameters.

Could you please elaborate on why you think this would be complicated to use?

Considering this type is ~Copyable, shouldn't it be simple to make it a compilation error to let the value expire without explicitly consuming it inside the type itself? That way, the only way to for the value to expire would be to discard self.

Isn't it conceivable to introduce a simple ~Discardable with severe limitations, enough to support the one use case of allowing ~Copyable types to require an explicit consuming call for cases exactly like the proposed Continuation?

Over time, these limitations would be gradually lifted, as part of a vision to expand a special case protocol into a coherent language feature.

1 Like

This seems great, with the exception of not being able to store them in a normal stdlib collection. If the intention is that people add a dependency on swift-collections for those use cases that should be documented imo.

One thing I'm not clear on: do you see the existing continuations as having any value after this + call-once closures are added? What would the decision criteria on whether to use them be?

Slightly outside the scope of this pitch, but I think it's important to flag: presumably the generated objc completion handler bridges should use this, and when that change is made it's important that the mangled name of those generated functions change too.

It's conceivable, but I'm not sure anyone would like the result in practice. The constraints on such a type are severe—you can't even reassign a variable of such a type without explicitly consuming the old value first. It would be impossible, rather than merely inconvenient, to capture them in closures, without some wrapper that attaches a default destructor to a value of the type anyway. We can catch up the language in some ways, but those ways still imply a lot of follow-up work: we could add run-exactly-once closures, for instance, but the ecosystem would still need time to adopt them where possible (and it may not always be possible to use them). Currency types like Optional could be generalized to contain ~Discardable types, but they would need very differently-shaped API surface to deal with not being able to implicitly destroy their contained values.

1 Like

Having a third Continuation type (and possibly fourth once ~Discardable is available) seems confusing and/or ambiguous enough that it would make a complex concept (Continuation) even more confusing for Developers.

I wonder if the approach couldn’t be somehow adjusted so it would be an upgrade of CheckedContinuation rather than a whole new type.

If this needs to be delayed until ~Discardable/exactly-once closures is implemented (and CheckedContinuation can always be replaced by the new Continuation type, so be it.

Adding a new “checked” type now, with future plan to potentially add yet another, and no plan to deprecates and remove any to clean up sounds like it will only clutter the space.

Concretely: I would prefer to either:

  1. Wait until we can fully replace/upgrade/depreciate CheckedContinuation
    or
  2. Add clear and concrete future plans towards clean up. And write the specification so we don’t need to introduce a new type with the advent of ~Discardable (e.g., Missing resume could be specified as Runtime trap or Compile-time error).
2 Likes

While I think there's a lot of good here, I don't think any effort should go in until it can be known that ergonomics will be improved. The concept of continuations should be abstracted further, rather than requiring direct usage of instance methods, being able to look like the following, without requiring this sendability. (And perhaps most importantly, parameter packs will need to be updated to account for a list of optional parameters that come before an optional error, to account for that very common old spelling.)

The general point of continuations is just to convert "promise" representations, and using them directly doesn't provide a great developer experience.

func `continue`<each Success: Sendable>(
  _ future: (@escaping (repeat each Success) -> Void) -> Void
) async -> (repeat each Success) {
  await withCheckedContinuation { continuation in
    future { (argument: repeat each Success) in
      continuation.resume(returning: (repeat each argument))
    }
  }
}

func f1(parameter: some Any, completion: @escaping (String, Int) -> Void) { }

var v1: (String, Int) {
  get async {
    await `continue` { f1(parameter: "url", completion: $0) }
  }
}
func `continue`<Success: Sendable, Failure>(
  _ future: (@escaping (Result<Success, Failure>) -> Void) -> Void
) async throws(Failure) -> Success {
  try await withCheckedContinuation { continuation in
    future { continuation.resume(returning: $0) }
  }.get()
}

enum E: Error { case c }

func f2(
  parameter: some Any,
  completion: @escaping (Result<(String, Int), E>) -> Void
) { }

var v2: (String, Int) {
  get async throws(E) {
    try await `continue` { f2(parameter: "url", completion: $0) }
  }
}

I have a question, just to make sure I’m really understanding things. My own use of continuations has come down, possibly exclusively, to two uses - bridging and “task gating”.

Being ~Copyable, won’t this pattern be difficult to support?

func myAsyncBridge() async {
  withContinuation { continuation in
    // this is an old API. Even if the language one day gained the ability to express that this closure is called only once, this API will never be updated.
    someCallbackBasedAPI { value in
      // doesn't this mean the capture here is invalid?
      continuation.resume(value)
    }
  }
}

The second use case is gating, as a meaning of synchronizing access to an actor across awaits. I’m not sure I understand how a ~Copyable type would serve that use-case either. Normally I put these continuations into collections. Can I do that with this kind of type?

(As an aside, I’m extremely interested in seeing something added to the language/stdlib to make this particular use-case unnecessary).

I think this design is very cool. But even if this type existed today, I don’t think I could actually use it. Help me understand what I’m missing!

1 Like