Async/await status?

maybe just this? it's not async/await though

perform(inBackground: {
    generateImage()
}, onMainQueue: {
    imageView.image = $0
})

where

func call<T>(on q1: DispatchQueue, completeOn q2: DispatchQueue, execute: @escaping () -> T, completion: @escaping (T) -> Void) {
	q1.async {
		let r = execute()
		q2.async {
			completion(r)
		}
	}
}

func perform<T>(inBackground: @escaping () -> T, onMainQueue: @escaping (T) -> Void) {
	call(on: .global(), completeOn: .main, execute: inBackground, completion: onMainQueue)
}

along with other flavours of "perform" e.g. to specify explicit queue, etc.

I've been racking up my brain for a while, trying to figure out how to enforce the same execution context in a block with beginAsync/suspendAsync. The context of continuation is at the mercy of suspendAsync.body. So when we have

await taskA()
await taskB()

taskB is executed by the execution context dictated by taskA. Because of this, I'm convinced that there's no way to enforce, or even encourage same execution context in the same block without some contract outside of async/await between taskA, taskB, and their container.

Even with extra contract, I could only think of passing transform around:

func asyncMainContext(block: (transform: @escaping (continuation: () -> ()) -> ()) async -> ()) {
  beginAsync {
    block {
      DispatchQueue.main.async {
        continuation()
      }
    }
  }
}

// Task B defined similarly
func taskA(context transform: @escaping (continuation: () -> ()) -> ()) async -> ReturnType {
  suspendAsync { continuation in
    DispatchQueue.global().async {
      // Do Some Task A in the background

      // Continue wherever `transform` wants to
      transform { continuation(value) }
    }    
  }
}

So now taskA leaves the continuation context to transform, which is given by caller, and so we can call it like this:

asyncMainContext { context in
  // Each executed on the background thread
  // but continue back on the main thread.
  _ = taskA(context)
  _ = taskB(context)
}

Maybe there's an easier way, :face_with_monocle:.


We could go even further by having the transform block be provided by the beginAsync caller:

func beginAsync(transform: @escaping (continuation: @escaping () -> ()), _ body: () async throws -> Void) rethrows -> Void

func suspendAsync<T>(
  _ body: (_ continuation: @escaping (T) -> ()) -> ()
) async -> T

with call to continuation from suspendAsync.body instead invoke transform(continuation(t)).

And so the above becomes

func asyncMainContext(block: () async -> ()) -> () {
  beginAsync(transform: DispatchQueue.main.async, block)
}

func taskA() async -> ReturnValue {
  suspendAsync { continuation in
    DispatchQueue.global().async {
      // Do Some Task A in the background

      // This returns immediately because `asyncMainContext` transform returns immediately.
      continuation()
    }
  }
}

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 self to beginAsync after moving execution to it.

4 Likes

I think the important parts are 1) to disallow suspendAsync from arbitrating continuation context since it seems to do more harm than good for programmer's reasoning, and 2) to ensure that all continuation contexts within the same beginAsync are shared, but beginAsync should be able to nest, so that we can still use it to switch context if need be.

That's why I came up with the transform design, since closure decorator seems powerful enough.


What I'm unsure of, though, is whether it's actually a good design for each continuation block to be separated, without looking so:

dispatchContext {
  // Lest we forget to also wrap `initial`
  // inside the context
  initial()
  _ = await taskA()
  foo()
  _ = await taskB()
  bar()
}

Whether foo and bar should be executed on a different block (DispatchWorkItem in this case) but the same context (DispatchQueue) seems unwinnable to me. On one hand, it looks like a recipe for ill-formed/bad mutex, OTOH putting foo & bar in the same block means that it is now waiting for taskB to finish, which could be unexpected performance problem. So I'm not sure which one should be the default setting.

That's a fair point. Switching contexts as a side effect is definitely a two-edged sword. It can be useful if used deliberately, but if it's not an obvious (or even intended) part of a function's behavior it can really mess you up. So yes, if we're going to have a way to switch contexts, it would be best to have it come with a way to strictly define the scope of that switch, or otherwise make it obvious that the switch is happening.

async/await reduces the callback-hell when you're in an asynchronous function waiting on the results of another asynchronous function. The example with the table view cell function doesn't fit that pattern. In that case what you have is a synchronous function (this function must synchronously return a cell to the caller) that also needs to start some asynchronous work. You can't get around having either a nested block or a function call in that case. async/await can't prevent that.

What it does do, again, is simplify the code within an asynchronous function that calls other asynchronous functions. It turns a chain of callbacks into straightforward synchronous-looking code.

I also feel like a lot of the examples miss the biggest advantage, which is how much simpler error handling can be. When you have error handling in a chain of callbacks it gets really messy. That's because error handling introduces more code paths, and in many cases you end up having to basically define a state machine to ensure all the cases get handled. Consider something like this:

func doSomethingAsync() async -> Int {
defer {
    cleanup()
}
do {
    let result1 = try await getFirstResult()
    let result2 = try await getSecondResult()
    return try await combine(result1, result2)
} catch {
    return -1
}

Consider what that looks like with callbacks, where each call back would have to separately handle any error by performing cleanup and then calling the final completion handler. Imagine how brittle that code would be if you needed to change how cleanup works or track more information as you go.

What async/await does is make that complex state machine implicit in the same way that closures implicitly turn variables that look like simple locals into fields on an object you never even have to think about. The code just makes sense as you read it. It detangles the mess to reveal the logical structure of what you're actually trying to do. What's why this is such a powerful feature. It doesn't just remove nesting. It makes complex code clearer.

7 Likes

indeed, it becomes hairy.

in your example will getFirstResult and getSecondResult run in parallel to each other or sequentially?

I have difficulties understanding what try await would mean. Do you call a function that may succeed or fail to call an async function or are you calling an async function that might fail?

Regarding context switching: one could just make this mostly impossible whatsoever in async code. If we think of async functions as functions with completion handlers that are called on return and of ``àwait```simply as a function call, the mere fact that

let x = await foo()
return await bar(x)

is written in assignment style prevents you from doing something along the lines of

DispatchQueue.main.async{
return foo() //error
}

If we do need to switch to another context, that would be done at the periphery. So, you chain a lot of async functions or combine them using some function like

//can run both closures in parallel (if a proper context is injected),
//but completion handler is once again abstracted away
// to allow more imperative looking code 
zip<T,U>(_ closure1: () async -> T, _ closure2: () async -> U) async -> (T,U) 

And evetually you actually run you long chain of async functions using a single global function like this:

startAsync<U>(serialContext: Context, parallelContext: Context, completionContext: Context, closure: () async -> U, completion: (U) -> Void)

The idea is that the outermost function is called using the serial context and every time the compiler hits the return keyword, the continuation will also be called on that serial context. Additionally, there needs to be some primitive to get access to startAsync's parallel context. When the compiler sees the last return, the underlying continuation will be called on startAsync's completion context. This is the point where you actually may change contexts.

From my experience, you don't do any deep nesting at this peripheral stage. Most of the time, you just put the value produced by the long async procedure back on the main queue and mainipulate the UI or the app state with it. All the deep nesting should be done by chaining async functions that will run in the same context, and if you do want to start any new async procedures at the periphery as a sideeffect, you can once again call just chained processes and avoid all deep nesting.

Note that this doesn't mean that I propose this syntax for starting an async process in particular, I just wanted to demonstrate that theoretically it shouldn't be toooo difficult to implement or too hard to understand. Also note that

let x = await foo() 
let y = await bar()

would then definitely run sequentially. However, the compiler should be able to find out that these terms don't depend on each other and warn you that the code could be optimized with zip - a warning that you should be able to suppress though, as this optimization doesn't always pay off.

With all the discussion so far, it should run sequentially. Ideally

| Starts with `doSomething` context
| Goes into `getFirstResult`
  | `getFirstResult` computes value in different context, ending the parent's context block
| `getFirstResult` returns value to parent's context, initiating a new context block
...

To run concurrently, you'd need some support from libraries, like using Future, or explicit parallel (nursery) block.

I think both, but more importantly the latter since that's where it gets ugly on traditional setup.

Do you mean the periphery near the place you call the async functions (beginAsync), or near the place you create async functions (suspendAsync).

By periphery, I mean where you launch an async process from, say, the main thread, e.g.

//somewhere in your model/controller code
func helloWorld() async -> String{...}

//somewhere in your UI code -- the periphery

startAsync(helloWorld) //using default arguments for execution contexts
{(response: String) in
     myTextField.content = response
}

In the above example, helloWorld would be called using a brand new execution context and the response closure would be called back on the original thread where you called startAsync. In this design, this is the only point where you actually move from one serial execution context to another one, the only other context changes being between serial and concurrent contexts.

Example design of concurrent contexts: using completions, what could an implementation of ziplook like? Well:

zip<S,T>(_ closure1: ((S) -> Void) -> Void,
         _ closure2: ((T) -> Void),
         _ completion: (S,T) -> Void){//desugared signature

var s : S? = nil 
var t : T? = nil 

let myCompletion = {
//reads the serial context injected from outside
withContext{
guard let s = s,
      let t = t else{ return }
completion((s,t))
     }
  }

//reads the concurrent context injected from outside
withConcurrentContext(iterations: 2){(idx: Int) in

   if idx == 0{
      closure1{s = $0; completion()}
   }
   else{
      closure2{s = $0; completion()}
   }

  }

}

One could desugar that further so async functions do not only take completions, but also contexts that are passed down by chaining. Again: syntax/function names debatable (I for one have problems with introducing new global functions that appear to do black magic if you don't know the details), but I think you get the gist, and the approach should inherently make sure that contexts can only be changed using startAsync, i.e. actually running the code.

I would hope that a language provided async/await would support situational concurrency, that is a function requests concurrency and if it is called from a context that supports initiating concurrency it gets concurrency, if not it is arbitrarily sequentialized:

func foo() async -> Int {
  let a = async getA()
  let b = async getB()
  return merge(await a, await b)
}

func calledOnConcurrencySupportingContext() {
  // inner `async` run concurrently
  let x = await foo()
}

func calledOnSingleExecutionContext() {
  // inner `async` run sequentially, in arbitrary order.
  let x = await foo()
}
1 Like

I'm not sure what it means to fail to call an async function. If you look at it from the perspective of the function being called, you have something like this:

func getFirstResult() async throws -> Int {
    let dataIsAvailable = await waitForResultsToBeAvailable()
    if !dataIsAvailable {
        throw MyError.FailedToGetFirstResult
    }
    let result = processData()
    return result
}

When that throw happens you're already outside of the original call stack. Using callbacks you wouldn't be able to just throw the error. You would have to instead pass the error to the callback (either as a separate argument or using a Result type), and then the implementer of that callback would have to check whether there was an error or not.

With async/await the compiler can do that work for you. It still fundamentally does the same thing. It has to transform the throw call into calling a callback with an error, and it has to transform the callback into something that checks for an error and handles it as the caller would expect (either rethrowing it or falling into a catch clause). And it can also handle that defer block in all of the cases that it needs to.

By letting the compiler do that work you end up with something that's much easier for us humans to reason about. It's much clearer from this code that cleanup will always get called than it would be if you had to write the same code with a mess of nested completion handlers.

1 Like

I'm not aware of any implementation of async/await in any language that by itself supports concurrency. That is not what async/await is for. You can use async/await to interact with code that has concurrency (for instance, using multiple queues to do work in the background), but using await or async does not by itself make code run on a different queue or run in parallel. It just simplifies a pattern where you can't get a result immediately in the same stack frame (i.e., the result will come back asynchronously, for whatever reason). This is not a concurrency feature.

I don't currently professionally code, and have not actually used any implementation of an async/await feature, I am just making inferences based upon the definitions of "async" and "await", and the desired goals of the feature.

To be fair, this is a commonly misunderstood aspect of async/await in other languages as well, especially for people new to the feature. I think just in general people often conflate "asynchronous" with "concurrent", but those are not the same thing. async/await is for dealing with asynchronous code, not concurrent code.

As one example where this difference is important, consider a function that just calls a completion handler after some delay using an NSTimer. It expects to be called on a thread with a run loop (probably the main thread), and it invokes its completion handler on that same thread. That's an asynchronous function, but there's no concurrency involved. The completion handler will never overlap in time with any other code from the same thread as the caller, which means you don't have to worry about using any mutexes or other thread safety tools.

In C# this can look as simple as this:

async Task PerformAfterDelay(Action action, TimeSpan delay)
{
    await Task.Delay(delay);
    action();
}
2 Likes

I've been thinking about how to nudge async into concurrency a lot. The syntax is quite drooling to support dependency-graph style concurrency at least to some extend.

Most important of all, is the fact that we have been supporting concurrency without async/await, so the worst case is just to go back to callback/future. So no, asynchrony code isn't concurrency, but does it empower concurrency any more than normal synchronous program does? Does it facilitate a better/easier-to-reason syntax? ... is the question I have been asking, if at least implicitly.

I came to the conclusion that, to have async syntax support concurrency almost natively, it'd at least need to be able to construct continuation block before suspension, i.e., the suspendAwait needs to suspends after the body has completed, not before. Otherwise, we'd need make do with the mix of old techniques.

Note that everything would still be no overlap, inline with asynchrony. If the continuation is called before body finishes, then it'd be suspended until then. Though I'm inclined to agree that this could have a pretty big overhead (since it also needs to detect whether continuation is called before body finishes) if it is implementable at all (I haven't thought that far).

1 Like

I'm sorry, I didn't understand this part.

1) let result1 = try await getFirstResult()
2) let result2 = try await getSecondResult()
3) return try await combine(result1, result2)

i wasn't entirely clear. i didn't mean concurrency as in "two threads". imagine getFirstResult and getSecondResult use NSTimer under the hood and do their work piecewise. both have 10 pieces to complete before they return the result. suppose each step is done "instantly" (e.g. 0.0001s) but they can only do the next step > 1 second from the previous step. so we are talking about 10 timer invocations per each. the overall time sequence before line (3), taking the advantage of the fact that (1) and (2) are independent could be "parallel":

0s getFirstResult-piece.1
0s getSecondResult-piece.1
1s getFirstResult-piece.2
1s getSecondResult-piece.2
...
9s getFirstResult-piece.10
9s getSecondResult-piece.10

after which point line (3) "return try await combine(result1, result2)" can start executing as it now has both needed inputs.

or, without taking that advantage the sequence can be "sequential":

0s getFirstResult-piece.1
1s getFirstResult-piece.2
...
9s getFirstResult-piece.10
9s getSecondResult-piece.1
10s getSecondResult-piece.2
...
20s getSecondResult-piece.10

so it will take twice as long until step (3) can start executing.

threads/mutexes are not involved here, all pieces are run cooperatively on the main thread.

pseudo code without using NSTimer:

async func getFirstResult() -> Result {
    var result: Result

    for i in 0 ..< 10 {
        result = result + doWorkPiece(i)
        await sleepAsync(1)
    }
    return result
}

ditto for the getSecondResult

the question is whether the discussed async/await allows only for the second scenario or for both scenarios, and if both does it "just work" or do i use a different syntax to express the desired behaviour.

To facilitate concurrency, say, we write it like the syntax suggested upthread:

   #A
1. let result = suspendAsync { cont in
     #B
2.   nonBlockingCall {
       #C
3.     cont(...)
4.   }
5. }
6. ... = await result

We'd need to get control back after suspendAsync finishes so that it can continue to prepare another task. The point here is that cont is already created at line 2, and passed into nonBlockingCall, but the function is never suspended until line 6, which should be where cont picks up on. Contrary to Chris' proposal, where suspendAsync never returns until cont is called, so the compiler can suspend context #A, then create cont before diving into #B, then #C.

However I dance around, it seems that the continuation block needs to be created, so that it can be passed into the forker, before the corresponding suspension occurs. Or we could just use Future... (which is essentially a manual continuation/suspension)

async/await by itself does not allow for the two calls to overlap because line 2 will not be reached until getFirstResult has completed. await means "wait for this asynchronous call to complete before proceeding". Notice that in this case there's even a return value, which means that assignment to result1 can't be made until getFirstResult has entirely finished its work. The compiler isn't going to try to notice that result1 isn't used until line 3. That's not what this feature is for.

In fact, that would be an entirely new semantic for the language. There are languages that work like that. They're called data flow languages. I think it would be quite a big task to try to change Swift's semantics to behave like that, and it could be quite confusing to mix those two behaviors in one language.

If that's the kind of behavior you want then a futures/promises library can help. What C# did was start with a really great futures library and then build async/await to interact with that library. That allows for you to do things like this:

async Task<int> GetFirstResultAsync() { ... }
async Task<int> GetSecondResultAsync() { ... }

async Task<int> DoSomethingAsync()
{
    try
    {
        Task<int> firstResultTask = GetFirstResultAsync(); // no await
        Task<int> secondResultTask = GetSecondResultAsync(); // no await
        return await CombineAsync(await firstResultTask, await secondResultTask);
    }
    catch (Exception e)
    {
        return -1;
    }
    finally
    {
        Cleanup();
    }
}

In this case we start both asynchronous tasks and then wait for both to complete before proceeding. That way they can overlap. I've been trying to push for Swift's async/await feature to interoperate with futures libraries for exactly this kind of use case. It give you the best of both worlds, and it also allows for cleanly interoperating with code written with different styles. You can switch back and forth between using futures or using async/await depending on your needs. It also allows a clean way to convert callback-based code into awaitable code because there are Task implementations that allow for you to manually mark them as completed with a result or completed with an exception (see TaskCompletionSource).

1 Like