Async/await status?

note that it is relatively painful to achieve this "parallel" sequence of execution with callbacks. i wish it would also be somehow possible with async/await, otherwise we'd have to switch between the two worlds.

error handling omitted:

func getFirstResult(execute: @escaping (Result) -> Void) {
    ...
}

func getSecondResult(execute: @escaping (Result) -> Void) {
    ...
}

func combineResults(_ r1: Resut, _ r2: Result, @escaping (Result) -> Void) {
    ...
}

func getTotalResult(execute: @escaping (Result) -> Void) {

    var result1: Result? = nil
    var result2: Result? = nil

    func combineResultsIfReady() {
        if let r1 = result1, let r2 = result2 {
            combineResults(r1, r2, execute: execute)
        }
    }

    getFirstResult {
        self.result1 = $0
        combineResultsIfReady()
    }
    getSecondResult {
        self.result2 = $0
        combineResultsIfReady()
    }
}

and sequential execution (when needed) is easier. again, no error handling here:

getFirstResult { r1 in
    getSecondResult { r2 in
        combineResults(r1, r2, execute: execute)
    }
}

It is possible with async/await if async/await interoperates with futures APIs. In addition to the example I showed above, C# also has Task.WhenAny and Task.WhenAll, which are functions that take an array of Tasks and return a new Task that completes when any or all of the given Tasks have completed. That result can then in turn be awaited. I.e.:

Task<int> result1Task = GetFirstResultAsync();
Task<int> result2Task = GetSecondResultAsync();
await Task.WhenAll(new [] { result1Task, result2Task });
return await Combine(result1Task.Result, result2Task.Result);

When await can be used to wait on an object conforming to some futures-like interface then it becomes very powerful because it can be composed in a bunch of different ways.

Example:

func signUp(nickname: String, password: String, repeatPassword: String) throws async -> Response{

guard password == repeatPassword else{
throw ...
}
guard passwordIsComplexEnough(password) else{
throw ...
}

return await signUpBackend(nickName, password)

}

There‘s a clear usecase for that order of events, I think.

Or you use the semantically fully equivalent result monad ;)

@Nevin, as usual, has described some issues here much more eloquently than I could.

I'm still working my way through this thread, so apologies if this has been resolved by now...

I would like to see a similar, but slightly different approach to await/async. The proposed solution handles the common case where you want to chain together a bunch of asynchronous calls into a linear order, but I feel it misses an opportunity to allow us to easily spin up multiple asynchronous tasks at once and then wait for them all to complete.

In addition to being able to call await on an async function:

let x = await myAsyncFunc()

you should be able to use the keyword async as well to start the task without waiting for it to complete.

let x = async myAsncFunc() //Returns an async Int
let y = async myOtherAsyncFunc()

You would then need to use await on any resulting values before they could be used (it should behave similarly to try in that a single await can operate on the line)

let z = await x + y /// Await all the things

Not only does this allow you to spin up multiple tasks before blocking, but it also allows you to avoid blocking entirely if you don't care about the result:

_ = async myAsyncFunc()

I believe this gives most of the flexibility of futures (and futures could be easily built from it, if desired), but still gives the compiler the freedom to optimize things differently behind the scenes in each case.

Note: The difference in thinking here is that we are also able to think of the VALUE itself as async. That is, the functions are returning async Int (not just asynchronously returning an Int). Once we have that, we can start to pass these asynchronous things around (and potentially even consider storing them at some point) in a way that falls out naturally from Swift's normal rules. That starts to become really powerful...

5 Likes

If we are going to do async/await then the async/await model should be very similar to what C# already established specifically around cancellation of dependent task in a group and async streams. Choosing the context thread seems to be orthogonal.

Innovation is not a Goal Unto Itself

Being different from all other languages is not a goal. Language design is not a new field. It is better to borrow good, proven, ideas from other languages than it is to be novel in areas that don't need to change. This is pragmatic language implementation, not open-ended language research.

https://github.com/apple/swift/blob/2c7b0b22831159396fe0e98e5944e64a483c356e/www/Philosophies.rst#innovation-is-not-a-goal-unto-itself

1 Like

This is essentially Futures then, so I don't think it's worth the effort of introducing a new keyword and plumbing all this through the type system when this can easily implemented in a library (or several, with just the right behavior for any specialty use-case).

I think with trailing closures the interface could get close enough to what you describe to not be a hindrance, something like let x = Future { await myAsyncFunc() }.

One thing that might be useful to have though could be an Awaitable protocol or something like that, so one could just await myFuture instead of await myFuture.value().

I start to think that await should NOT return you back to the original context. There are two main reasons.


First, it is very easy to do something like this:

mainContext {
  await task1() // Run in background context

  // No work here, but we should hop back onto 
  // main context regardless.

  await task2() // Run in background context
}

When task1 returns from background task, it needs to hop onto the main context, just so that it could hop back to background context in task2 again. This could easily be a huge and unexpected delay, and it'd be a behavior that's very hard to get out of.


Secondly (and more importantly), it's even an uglier footgun that the previous behavior. In a scenario like this:

mainContext {
  task1()
  async task2() // Run in background context
  task3()
}

task2 will switch to background context, and finish the first main context block (containing only task1). When task2 finishes, it creates a new execution block on the main context and run task3.

There's nothing we can say about task1 and task3 other than these two:

  1. task1 is executed before task3, and
  2. (a) Both are in mainContext.

Rule 2 actually doesn't buy us anything. While task1 and task3 run on the same context, they still run on a different execution block. In fact, it is the mentioned footgun, because it looks very similar to mutual exclusion. In fact, it would be if we replace rule 2(a) with:

  1. (b) task1 and task3 are in the same mainContext execution block.

So mutual exclusion would be a very reasonable (mis)interpretation for those unfamiliar with the syntax.

I think forcing context into async blocks is a very dangerous concept with the reasoning above.


So instead, I'd suggest that async guideline be something along the line of don't assume execution context in the async block. And so we have an explicit context block.

beginAsync {
  // No context assumption
  let result1 = await task1()
  let result2 = await task2()

  await mainContext {
    // Main context, and SAME execution block
    self.image = getImage(result1, result2)
    self.name = getName(result2)
    ...
  }
}

with mainContext be something along these lines:

func mainContext(block: () -> ()) async {
  suspendAsync { cont in
    DispatchQueue.main.async {
      cont()
    }
  }
}

I get what you are saying, but my hope is two-fold:

  1. We can use Swift's normal control flow instead of a separate api reproducing control flow

  2. The compiler can optimize things behind the scenes for us in a way that isn't possible using a Future API

I worry that this would become hard to reason about. Looking at the call site, I would never expect the execution context to change. Any change should be explicit.

That said, I do think we should allow concurrency (yes, I know that Async/Await doesn't usually actually control concurrency... but that is also a major point of confusion in other languages). That is what the async I proposed is for:

let x = async myAsyncFunc() ///Executed on a background thread

We could pretty easily allow control of where it executes when desired:

let x = async(on: myQueue) myAsyncFunc() ///Executed on myQueue

(Note: If we don't want to tie it specifically to CGD, we could have a protocol defining an execution context which Queues adhere to)

In both cases, await would always bring it back to the current context

let y = await x ///Current context blocks until x resolves

Ideally, we can make return async-aware in async functions, so that we can just return directly instead of the back-to-main dance that we do right now (and also solving CGD's issue of not being able to return to the current context easily). You just return the value directly from whatever context, and await takes care of getting you back to where you started. More work for the compiler, but it makes everything MUCH simpler to reason about...

One thing I realized, is that there's actually never mentioning of synchronization context in the async block. It's more-or-less the same as execution context that provides no mutex guarantee, like thread. For all we care, it's something that's executed in sequential order, which is honored.

I wanted to achieve that too, but it massively complicates the design with coroutine model. Since now the continuation block needs to detect whether it is suspending for this particular continuation, and the suspension needs to detect whether the continuation block has been called beforehand.

Further, we still haven't decided on a lot of things. What should happen if the continuation block is called before the caller suspend? Does the call to continuation block block (not a stutter)? What does the coroutine control flow look like?

The only model I could see that can achieve concurrency without explicit structure (nursery block, explicit Future return type) is the Future sugar, which I don't exactly see the benefit of.

Here's the thing: we're greedy. You don't just hop onto a particular synchronization context just for some API contract. Often you hop onto it to ensure mutual exclusion for your code, which is nigh impossible with async block. At best it's giving you false hope. At worse, false sense of security.

And as I said in reason 1 above.

With a throwing async function try await would handle a thrown error either in the same call stack or in a continuation. That's yet another way that this makes things simpler compared to the callback-based code, where you may have to handle the two cases entirely differently.

A new thread was created, and since people expressed a general desire to use fresher threads, I'm going to close this one. This thread has wandered pretty far from its original purpose anyway.

4 Likes
Terms of Service

Privacy Policy

Cookie Policy