Rethinking Async

I have been reading the various threads on Async/Await, and I am coming around to the idea that we really need to rethink the problem entirely.

@Nevin pointed out that await on the call site isn't really helpful to the caller because having each line wait for the previous to complete is the standard way in which things work. We should only really be marking the case where we spin off a separate execution path... because that is the point at which we need to use different reasoning.

What if, instead of marking functions as async, we allow Types to be marked async:

let x: async Int

This looks a lot like a future, but has several important sugary nuances that allow it to fit much more naturally with the existing Swift control flow (avoiding having to chain things like .then{}).

The big change to the compiler would be augmenting return to be able to be used from a nested execution context, but in those cases, it would return an async variable (aka future) instead of the naked type:

func myFuncReturningAsync() -> async Int {
    myQueue.async{
         ///Do Calculation here in background
        return myInt  ///This returns as an async Int
    }
}

There should be automatic promotion of variables to async versions when necessary (similar to optionals). For example, if there are multiple return points where some are Int and others are async Int, the non-async points are automatically promoted to async.

This means that an Int could be passed to an async Int parameter, but the opposite is not true. You would be forced to recover an actual Int (see below) to pass the value to an Int parameter.

To get back to the naked type, we would use await:

let x: async Int = myFuncReturningAsync()
let nakedX: Int = await x

Note: await would work similarly to try in that a single declaration would also await nested scopes, which allows for waiting on multiple variables to resolve.

We can also build functions into CGD (etc...) that take an async variable and call a closure on a queue when it has resolved:

myQueue.await(x) { nakedX in

}

One last bit of sugar for real world use. We should be able to await an array of async variables and get back an array of the naked types:

let myArray:[async Int] = ...
let nakedArray: [Int] = await myArray

So far it just looks like we have essentially sugared futures to avoid the usual boilerplate which makes them hard to read, and that is one possible implementation, but I want to point out that the compiler actually has a lot more freedom here to optimize things and implement in various ways. Await could still use coroutines, for example.

Let's use what we have so far to take things a step further and return to the idea of marking the function itself as async:

func myAsyncFunc() async -> Int {
    myQueue.async{
         ///Do Calculation here in background
        return myInt  ///This still technically returns as an async Int
    }
}

Calling back to @Nevin's point, this should essentially work like any other function (avoiding the need to deal with it's asynchronous internals at all):

let nakedX = myAsyncFunc() 

which is essentially sugar for calling await on a version which returns async Int. That may seem strange/useless, but there is one last step. We can tell an async function to actually return that async version (aka don't await) by prefacing it with async:

let x = async myAsyncFunc()
let nakedX: Int = await x

So what does all this get us?

It gives us functions which:

  1. Act like normal functions when called normally
  2. Allow you to just return from a nested execution context when you have the resulting value and have it "just work"™
  3. Allow you to easily treat them asynchronously when desired
  4. Requires an annotation at the call site in the asynchronous case (which helps the caller to think through the implications... they are also forced by the compiler to await the value at some point in order to use it)
  5. Allows easier reasoning about the execution context flow, since await always resolves in the current execution context
  6. Have a natural syntax for passing/returning async variables as first class parameters

Let me give a concrete example of how this makes real world code simpler and easier to reason about. Grabbing an example from another thread, we would currently write:

func getImages(urlList: [URL], callback:(UIImage)->()) {
  myQueue.async {
    for url in urlList {
      fetchContents(of: url) {
        for imageURL in extractImageURLsFromHTML($0) {
          downloadImage(imageURL) {
            processImage($0) {
              callback($0)
            }
          }
        }
      }
    }
  }
}

let allImages:[UIImage] = //Getting this array left as an exercise for the reader

This could be rewritten as:

func getImages(urlList: [URL]) async -> [UIImage] {
  var images:[async UIImage] = []
  myQueue.async {
    for url in urlList {
      let contents = fetchContents(of: url)
      for imageURL in extractImageURLsFromHTML(contents) {
        let image = downloadImage(url)
        let processed = processImage(image)
        images.append(processed)
      }
    }
    return await images
  }
}

let allImages = getImages(urlList: urls)

Notice that we still need to use GCD to switch queues. async doesn't provide concurrency by itself... that is done by the internals of the async function. However, assuming all of these calls are async themselves (and can take async parameters), we can use their internal concurrency to make our function concurrent:

func imageURLs(in urlList: [URL]) async -> [URL] {
  return urlList.flatMap { url in
    let contents =  async fetchContents(of: url)
    return async extractImageURLsFromHTML(contents)
  }
}

func getImage(url: URL) async -> UIImage {
  let image = async downloadImage(url)
  return async processImage(image)
}

func getImages(urlList: [URL]) async -> [UIImage] {
  return async imageURLs(in: urlList).map { async getImage($0) }
}

let allImages = getImages(urlList: urls)

Notice here how we are able to easily break up the function here into much simpler pieces using standard Swift mechanisms.

In conclusion, we get the benefits of futures, but we also get progressive disclosure that futures can't provide. These async functions behave like any other synchronous function to the caller, unless we specifically ask them to be asynchronous. When we get an asynchronous value, we can pass it around like a future, but we also get automatic promotion so we can pass in normal values just as easily.

I feel like this is much easier to reason about overall... especially for callers of the functions.

16 Likes

My first, and biggest concern, is that the most natural usage of this design:

let a = asyncTask1()
let b = asyncTask2()

let c = await merge(a, b)

is the one that forces everything into one execution block. If this is in serialised context, e.g. main dispatch queue, it is definitely stalling until asyncTask1, and asyncTask2 finishes.


I don't see how we can achieve that syntax on its own. There has to be some bottom function that convert sync functions to async ones. And within that bottom function, we can pretty much escape the return block, so return doesn't really fit there.

More concretely, if I have, say, GCDAlpha, I don't see how I could make my own API to support that return syntax:

extension AlphaQueue {
  // Doesn't help even if `block` itself is `async`
  func async<T>(block: @escaping () -> T) async -> T {
    // What do I do now?
  }
}

I think let nakedArray = myArray.map { await $0 } would be about as clear without adding much complexity to the feature. You also have control over which items you're awaiting.


I don't really see how this could work with coroutine. Could you please elaborate some more?


PS

Should be GCD there.

2 Likes

I disagree with your entire premise. async/await, as implemented in other languages, solves a real problem. If you're brushing this off as "not really helpful" then you are either misunderstanding what it does or just not appreciating the benefits that you get from it. Other languages didn't go to so much trouble to implement this feature for a trivial improvement. It makes a significant difference in clarity and maintainability of asynchronous programming, which is a very common task in modern applications.

If you want to discuss new ideas to solve different problems then that's fine, but that's orthogonal to what async/await does, and it does not replace the need for async/await. Your proposal is not an alternative. It's a different feature to solve a different problem.

2 Likes

Hmm? This one looks much more like C#'s async/await that the ones in the other thread even.

If you want it to work like C# then you don't need async as a keyword at a call site or as a decorator for a type. That's trying to bake futures in as a language feature, which is not how C# does it, and not something I would recommend.

C#'s async/await works with any futures type that conforms to a certain pattern of available methods (including extension methods). It doesn't tie you to just one built-in type, which is what this design appears to do.

2 Likes

I'm not saying it's doing exactly what C# is doing, but there are certainly similarities like letting async functions return a checkpoint (Task<Int> & async Int) to be waited later with async, which is already a big departure from Chris' design.

Respectfully, I don't think you need two threads for this same conversation. Do you want me to close the last one?

2 Likes

Shouldn't we wrap this one up, and go back to the other thread instead?

PS

It think there are three-ish; Async/await status?, An extensible Syntax for effects like e.g. async-await, and this.

I'll let the three of you come to a consensus, but please pick one.

3 Likes

I think we could close this one, and we all can go back to Async/await status?, until the design ends up being more fleshed out.

Respectfully, I think @Jon_Hull has started a different conversation, and it's not the conversation happening in any other currently active thread. @Jon_Hull is proposing a different solution to the underlying problem, and it's a direction I very much want to discuss.

What would be good is if we could agree to keep discussion of the implementation details of other proposed solutions out of this thread. Those belong in their respective in-progress threads.

2 Likes

These are not the async/await discussion forums. You don't need separate threads for every new idea. When we have multiple threads for the same broad topic, time and time again what we see is that the same people end up saying similar things in all the threads and it becomes very difficult for other people to follow and participate. This is already a very high-traffic conversation; the participants are writing collectively writing tens of thousands of words a day. The sheer volume makes it unapproachable.

Therefore, there is going to be one active conversation thread from here on out. I am happy to merge threads or close them as the participants like, but if I don't get clear signal, I will just do what I think is best. This is your opportunity to shape the outcome.

14 Likes

FWIW, I agree with @QuinceyMorris. The reason I started a new thread is that although some of the terms are the same, I am actually talking about a completely different approach and way of thinking about the issue. I feel like if I had posted this there, it would have been rejected/ignored for being off-topic.

That said, I will defer to your judgment. I hope this much needed conversation can actually happen somewhere though...

I acknowledge that your position is reasonable, and I share your frustration about the nature of the discussion.

However, there are valid counterarguments to the rationale you've expressed:

  1. If you try to constrain multiple discussions to one thread, the effect is that any one discussion is constantly being derailed by others, and no one can follow the topic anyway.

  2. If a discussion gets too long, newcomers can't afford to invest the time to read the whole history, which result in a lot of repetition of ideas that have already been discussed.

So, I think a better model is to allow new discussions to start, and let them die away after a few days — which is what has actually happened to a number of these async/await topics, including ones that I've started.

Specifically, though, if you're asking for input about what threads to close, I say close those that have >70 posts (or somewhere around there). Experience has shown that longer threads don't really go anywhere.

8 Likes

Actually, I don't have a strong opinion which thread it's gonna be.

Okay. Since it seems to be people's preference, and since there are links to the earlier threads, we can continue in this one.

1 Like

OK, currently we have two categories of ideas for async/await to tackle both asynchronous and concurrency problems.

  1. async routine/function coroutine for async operation
  2. async T/value/result for concurrency - parallel async

I think 1) already well solved by traditional async/await pattern both in swift and other similar traits of lang C#/JS...

Maybe this thread Rethinking is focused on parallel async or concurrency representation by async/await pattern.

assuming func asyncFunc(...) async -> T

async let result1 : T = await asyncFunc(...) 
async let result2 : T = await asyncFunc(...) 
/* define async result but not call/branch async func immediately - 
`lazy async` or Future/Promise async representation */

await result1, result2 //actually call the asyncFunc and wait/suspend for return in parallel directly 

or call asyncFunc(...) async -> Void Actions in parallel

await asyncFunc(...), asyncFunc(...), ... //parallel async

Conclusion, await async Value for concurrency async, get async results in parallel; and await async Func for traditional async get async results in sequence.

Alternative pattern: using ViewBuilder equivalent AsyncBuilder {...} eDSL to build async calls in parallel but this is out of this topic scope.

1 Like

It’s certainly in async/await’s favour that it’s been adopted by so many languages. Having so many developers familiar with the concept makes it valuable in its own right.

That said, popularity alone is not enough to justify adding async/await to Swift. Many of those implementations have holes and sharp edges that I think we can improve on. See the discussion about modify accessors and how we’d like to express handling coroutine cancellation in the language, where many others simply acknowledge that it’s a dangerous trapdoor and continue.

There are other language designs for composing asynchronous tasks in to pipelines which I think could be just as powerful (if not more so) than async/await as we see it in popular languages today.

2 Likes

I see a lot of people mentioning designs that doesn’t immediately await function calls to facilitate concurrency. Fair enough.

What I don’t understand is the claim that it’d just work with coroutine. Could someone elaborate more on that. Or do they just treat async Int as native Future wrapper?

@John_McCall said:

With respect, I'd encourage people to think of Chris's old paper less as the "current design" and more as a conceptual starting point. It's trying to get us all familiar with some of the basic problems and make sure we're using the same terms to talk about them. If Chris's intrinsics don't let us express what we need to express, well, we need different intrinsics, that's all. In this case, beginAsync needs to also take some description of the current execution context that will get passed down to the async function so that it can resume itself there if need be. Generally this intrinsic would get used in the implementation of e.g. DispatchQueue.async , which can very easily describe the queue to beginAsync after moving execution there.

I agree. I think there are several different issues that are getting conflated into "async await":

  • There is a well understood effect system and state machine transformation that is enabled with the async and await keywords. There is general cross language and industry alignment about what these do and why, and LLVM has increasingly good support for this (thanks to John and others).

  • There are Swift standard library functions that are required for converting between the effect and other ways of modeling the same concepts. These would take low level forms and may have high level equivalents (like a "future" type). These are precedented in things like withoutActuallyEscaping(:do:) and Result for the @escaping and throws systems, respectively.

  • We probably want higher level APIs and patterns for dealing with cancelation and timeouts across async domains. There was a recent twitter thread with Joe Groff and others on it discussing this blog post and this library both of which are really well written and motivated, and have ideas we should be inspired by IMO.

  • There is a discussion about what is the underlying execution model for threads.

My point is that these are all separable discussions and orthogonal features - the results of which should compose in a beautiful way, but this is a "manifesto" level discussion just for async/await, not to mention actors and all the other stuff in the concurrency manifesto I wrote 3 years ago - time flies!

-Chris

22 Likes