SE-0296: async/await

The proposal here is up for review without cancellation. If you want to make a case for why it shouldn’t be accepted without a particular cancellation implementation/behavior then I think you will need to lay out why you think that’s the case. How do you think it should work?

This isn’t too dissimilar from my argument earlier that I don’t think this should be accepted unless certain other things from the other specs are accepted also. But in my case those things happen to already be part of those specs so I don’t have to work quite as hard to make a case for stopping progress. :sweat_smile:

Thank you, this greatly helped my understanding of the world envisioned by the authors of these proposals.

4 Likes

I guess I’ll add that to my conditions. async/await without that part would be usable, but pretty ugly in common use cases.

At what point do we have to say “these proposals may be discussed separately, but should be approved as a unit”? Is there a process for that?

2 Likes

I'm having difficulty understanding the underlying mechanisms of async/await, so would appreciate if someone could explain.

In the callback/completion world a context can have only one stack and there is only one way it can be unwound. Callbacks may or may not capture data (by copying) but they never mess with the stack.

In the async/await world though it is possible that something that we think of as a "context" may end up having multiple stacks, though they will be unwound synchronously within the given context (actor).

If my understanding is correct then it has some serious implications. Firstly, it should be clearly explained that async/await is not a "drop-in replacement" for callbacks even though for example the automatic translation of Objective-C API's might create an impression of that.

Second, if await captures my entire stack it means I need to rethink the way I solve problems. For example, I prepare data to be sent to a URL task, then I await for its completion with all the data stuck in memory while it's not necessary.

Third, does this all mean async/await with its stack switching come with a runtime overhead compared to callbacks? Can it be optimized to an extent that the developers would not need to worry about the efficiency when switching from callbacks to async/await? Because otherwise, you know, give me a good reason I need to convert other than readability and less boilerplate of course.

And finally, something as complex as say a URL task that can provide cancel, pause, resume and progress reporting can no longer be a simple async interface, it should in fact become a full actor with multiple entry points... and say callbacks for progress reporting?

Then what's the benefit of switching to the actor model (devil's advocate mode on) if the asynchronous callback interfaces are already simple and safe enough?

And a follow-up question: what's the equivalent of weak self in the async/await paradigm? Let's say a view controller is long gone from the screen but it's still awaiting with petentially a lot of unnecessary captures on the stack and potentially also unnecessary code that will be executed when the suspended function resumes. I guess it comes down to the unresolved problem of cancellation?

AsyncSequence may be a better fit for progress reporting in this case, I guess.

AsyncSequence may be a better fit for progress reporting in this case, I guess.

That's interesting and elegant, though it means I'm going to have at least two suspendable functions: one awaiting for completion and the other looping on the progress sequence. So two suspended stacks (partial tasks) vs. none in the callback model.

I'm really curious now how these old interfaces will be rewritten with the new concurrency paradigm. Especially curious about SwiftNIO because I have a suspicion that maybe Swift's actor/async model in its present form is not quite ready for time critical domains like high performace network servers, or as I mentioned in a separate post, in audio. Though right now it's difficult for me to be constructive without trying, examining the generated code and debugging it.

1 Like

I feel like I'm missing something fundamental, but have been through the proposal a few times, and don't see what I am looking for.

First, I think this is a great direction for the language. Take existing patterns like completion blocks, and make them pretty. That's the way to go.

I also think the proposal does the following very well:

  • Explains what async/await is replacing. Ie why we want it
  • Explains the calling of async functions and blocks well
  • Explains how you compose an async function out of other async functions

But I'm missing a big one in the list: how do you actually create a general async function? I could see nothing there that was not implemented in terms of some other async function.

To make it concrete, if I have some existing call to CloudKit

   func documentIdentifiersInCloud(completionHandler handler: @escaping ([CKRecordZone]?, Error?) -> Void) {        
        let fetchZonesOperation = CKFetchRecordZonesOperation.fetchAllRecordZonesOperation()
        fetchZonesOperation.fetchRecordZonesCompletionBlock = { zonesByZoneIdentifier, error in
            handler(zonesByZoneIdentifier.values, error)
        }
        database.add(fetchZonesOperation)
    }

(Methods like this are my bread and butter.) I want to make this an async func instead of having that ugly completion block. How do I do that?

It feels like I'm missing some keyword. Somehow that function would have to have an await of sorts after the database.add, but I am not aware of a standalone await, or a way that the fetchRecordZonesCompletionBlock could signal that it is finished so that the documentIdentifiersInCloud can return.

Is the idea that I should just block the return until the completion block has been called? Eg. using a dispatch group? That would not be such a clean solution IMO

1 Like

@drewmccormack

My understanding is that, it would be either the underlying 3rd party API providing async'ness for you, or alternatively you create an actor which makes all its public entry points automatically async. Essentially your actor will be your new isolated thread executing stuff concurrently and consequently making all its external interaction async.

If I understand it, the only way to make a "leaf" async function is to use an actor(?) A third party library could introduce async functions, but presumably in the library itself it is also forced to work with actors to achieve this.

If there is no fundamental way to make an async function short of adopting actors, I get an uneasy feeling about the design. It feels like async/await should be usable without moving to actors. I would expect some way to make my own async functions. (Perhaps that was what some other people were getting at with the absence of generators etc.)

Actors are not required for that, check out the structured concurrency proposal. Task.withUnsafeContinuation should be enough.

To elaborate, it would look like this:

func documentIdentifiers() async throws -> [CKRecordZone] {
  Task.withUnsafeThrowingContinuation { continuation in
    let fetchZonesOperation = CKFetchRecordZonesOperation.fetchAllRecordZonesOperation()
    fetchZonesOperation.fetchRecordZonesCompletionBlock = { zones, error in
      switch (zones, error) {
        case (nil, nil):
          fatalError()
        case let (_, error?):
          continuation.resume(throwing: error)
        case let (zones?, _):
          continuation.resume(returning: zones)
      }
    }
    database.add(fetchZonesOperation)
  }
}

In my understanding, this is what Objective-C async/await interop is doing under the hood anyway. You'd need to write these wrappers with Task.withUnsafeThrowingContinuation only where interop doesn't apply.

1 Like

This subthread started when I replied to one of the proposal authors who was responding to questions about cancellation. I am not trying to argue the cancellation issue in this thread. What I am trying to do is point out that it is not appropriate to defend this proposal on the grounds of a pitch that has not yet been accepted (and for which many questions of substance remain unanswered).

Whoa, nobody is trying to stop progress here. I haven’t even reviewed this proposal. All I’m commenting on is the review process itself. If @Karl’s legitimate questions about cancellation can’t be answered without reference to a proposal that is still in the pitch phase then something is wrong with the process.

I understand that this is a massive and important effort and it’s necessary to break it down. This is not easy to do. Personally, I think it would be appropriate to separate out the cancellation topic from this review, but only if cancellation is not considered settled with respect to this proposal. Specifically, we should leave the door open to suspension points implicitly checking cancellation by default (and if we adopt that design, then making await imply try).

If we’re not going to keep this door open then the cancellation topic should become fair game in this review. In this case, the proposal should be updated to address cancellation and should include a robust rationale as to why explicit cancellation checking is the right approach. Personally, I think this approach is prone to computation and resource leaks so I have significant concerns about it. These have not yet been addressed in depth by the authors.

5 Likes

I see. Thanks for the clear answer.

It still does feel a bit odd to me that in order to write an async function you jump into what looks to me like library land. I would have expected something along these lines in a complete, self contained async/await solution.

func documentIdentifiers() async throws -> [CKRecordZone] {
    let fetchZonesOperation = CKFetchRecordZonesOperation.fetchAllRecordZonesOperation()
    fetchZonesOperation.fetchRecordZonesCompletionBlock = { zones, error in
      switch (zones, error) {
        case (nil, nil):
          fatalError()
        case let (_, error?):
          continue myContinuance(throwing: error)
        case let (zones?, _):
          continue myContinuance(returning: zones)
      }
    }
    database.add(fetchZonesOperation)
    await myContinuance
}

Admittedly this is mostly a cosmetic change, but ultimately this whole proposal is a cosmetic change. We can already do all this stuff ourselves, it's just ugly (...as the proposal clearly explains).

1 Like

In your version, where does myContinuance come from? What is the value of await myContinuance? What is that statement doing? It's ignoring the return value. What is the pointing of awaiting the async op within the async method itself?

It's a good point. Maybe you don't even need it. Perhaps it should be either a special type of return statement, or part of the function declaration.

func documentIdentifiers() async(myContinuance) throws -> [CKRecordZone] {
    let fetchZonesOperation = CKFetchRecordZonesOperation.fetchAllRecordZonesOperation()
    fetchZonesOperation.fetchRecordZonesCompletionBlock = { zones, error in
      switch (zones, error) {
        case (nil, nil):
          fatalError()
        case let (_, error?):
          continue myContinuance(throwing: error)
        case let (zones?, _):
          continue myContinuance(returning: zones)
      }
    }
    database.add(fetchZonesOperation)
}

Update: thinking more, I would probably prefer a defer type mechanism. That way you declare the existence of the callback before it is used, and it doesn't mess up the nice function signature.

func documentIdentifiers() async throws -> [CKRecordZone] {
    deferReturn myContinuance
    let fetchZonesOperation = CKFetchRecordZonesOperation.fetchAllRecordZonesOperation()
    fetchZonesOperation.fetchRecordZonesCompletionBlock = { zones, error in
      switch (zones, error) {
        case (nil, nil):
          fatalError()
        case let (_, error?):
         myContinuance(throwing: error)
        case let (zones?, _):
         myContinuance(returning: zones)
      }
    }
    database.add(fetchZonesOperation)
}

This should be discussed in the structured concurrency thread then. The proposal in review does not mention how exactly callback-based functions are converted to async functions, it only focuses on high-level interactions between async functions and the basic syntax of this. Other related topics are explicitly deferred to separate pitches and proposals.

I am strongly +1 on this proposal, it serves as a great foundation for the rest of the Swift concurrency model as laid out in the roadmap. We also need cancelation and actors of course, but this is a strong foundation on which to build those. I think the proposal authors have done a phenomenal job of providing the big picture framework as well as breaking the individual components down into smaller pieces that allow the details to be polished.

Yes, yes.

This is a highly precedented feature and I think it is mapped well into Swift, integrating with the type and decl system correctly. The proposal's approach to await marking fits very naturally
the existing Swift approach to try marking, and generally dovetails well with error handling in Swift.

I have put far more into these topics than is healthy. :slight_smile:

My only nit picky comment about the proposal is this sentence: "However, it introduces two new contextual keywords, async and await." and then goes on to explain that await isn't contextual. I would drop the word "contextual" from that sentence.

The fact that I only have one word to quibble about in the whole proposal is a pretty amazing testament to how well the pitch phases for this proposal were/are being run. Congratulations and thank you to the authors and team!

-Chris

12 Likes

I very much support async/await for Swift, syntactically it will just look so much nicer than callbacks, futures, ...

What I do struggle with are a few points this proposal left out. I'll list of few of them here:

1. What (if any) are the guarantees of the memory model?

I understand that arbitrary thread switches may occur after resuming from a suspension point. Is this okay?

func do() async -> Int {
    class RefHoldingInt { var int: Int }

    let ref = RefHoldingInt(int: 0)
    for f in 0..<5 {
        await something()
        ref.int += 1
   }
   return ref.int
}

2. Actors vs. executors vs. threads

I understand that this is mostly not part of this proposal, however, the proposal has to say

Accordingly, libraries that use threads directly for state isolation—for example, by creating their own threads and scheduling tasks sequentially onto them—should generally model those threads as actors in Swift in order to allow these basic language guarantees to function properly.

That I find a little bit confusing. I would usually expect a given actor to run on some executor. And for a given actor, this executor may be a fixed thread (say from a thread pool). But what I don't understand is how a thread should be modelled as an actor.

Furthermore, the proposal states that

Swift does guarantee that such functions will in fact return to their actor to finish executing.

which implies that these actors have some extra powers that are important for the async/await programming model. So I do think that at the very least this proposal should state what the implications of "modelling those threads as actors" are.

3. Overload resolution

For library authors, it will become important to provide a migration path from existing concurrency primitives onto newer ones. For example in the Swift on Server ecosystem, a large number of libraries use SwiftNIO's EventLoopFutures in a similar way to how async/await is expected to be used. Needless to say that async/await as a language feature can look a lot nicer and therefore, I'd expect most "user-facing" libraries to migrate from returning EventLoopFutures to becoming async very quickly. So a very common case would be to have a function

func request(_ request: Request) -> EventLoopFuture<Response>

and once async/await lands, a whole client may have two very similar functions (until they can cut a new major release and use async/await exclusively)

class MySomethingClient {
    func request(_ request: Request) -> EventLoopFuture<Response> // existing
    func request(_ request: Request) async throws -> Response     // new
}

and I'd like to know if we're guaranteed that the overload resolution will work when two functions have the same name and parameters but differ in return types/effects.

func existingCaller(_ client: MySomethingClient) -> EventLoopFuture<Response> {
    return client.request(Request.ping) // this should resolve to the -> EventLoopFuture func
}

func newCaller(_ client: MySomethingClient) async throws -> Response {
    return await try client.request(Request.ping) // this should resolve to the async func
}

My tests using the 5th December main snapshot show that this works fine but I wanted to confirm that this is actually guaranteed.

Very much.

Very much.

I've played with C#/F#'s async/await some time ago and I'm maintaining SwiftNIO. It's very hard to compare the proposed implementation to .NET or SwiftNIO because this proposal by itself doesn't give enough information.

Actors for example seem to sort of replace .NET's SynchronizationContext but it's impossible to say if what is proposed in just this proposal is enough or not.

On and off thinking about this since I joined the Swift on Server effort and multiple full reads of this proposal (and the sibling pitches).

3 Likes

Shouldn't your code example for overload resolution have the comments reversed?

You'd expect the EventLoopFuture<Response> overload to match the request(_:) that returns an EventLoopFuture<Response>, not the async overload