SE-0296: async/await

Special functions like deinit […] cannot be async .

A potential suspension point must not occur within a defer block.

How would we perform asynchronous cleanup if we cannot await within deinit/defer? For example, we might want to delete a file, which is an asynchronous task.

Seems like we can use runDetached (from the Structured Concurrency proposal) to start the asynchronous task but there would be no way to await the task? I think awaiting the cleanup task is important to establish back pressure so that we don’t potentially have an endlessly growing list of cleanup tasks if the cleanup tasks end up being more expensive than the actual work.

How would one implement progress reporting for an async function given that the async function cannot return anything either via return value or via inout parameter before completion of the asynchronous task?

Would wrapping the async function in a class with a progress property be the only way? That seems problematic since there could be multiple progress-reporting async functions in a class and a progress-reporting async function could be called multiple times.

I think I answered my own question. We could use runDetached (from the Structured Concurrency proposal) to get a task handle and then access the progress from the task’s local storage. Since every async function call has its own task, each async function call would be able to report progress independently.

Is that what the Swift Concurrency Roadmap authors had in mind?

+1 minus some minor points

Absolutely.

Yes

With the current proposal, one thing that seems to be missing for me is the ability to start an asynchronous function and await it later, which is possible in other languages, like JS.

This would enable something like

let x = asyncLongRunningTask()
let y = anotherAsyncFunction()
let z = await x;
let w = await y;

For me this would be desired, as it would enable expressions similar to JS' Promise.all:

let tasks = array.map { x in asyncMapper(x) }
let results = await Promise.all(tasks)

Because of that, I feel like a Promise type should be considered. Instead of async methods being implemented purely in the language, there should be an underlying type that enables this behavior. I would consider optionals as a precedent for this, which have a deep language integration but also can still be accessed through the Optional<Wrapped> type and created using Optional.some or Optional.none.

In that case, an async function would simply be syntactic sugar:

func foo() async -> Int { ... }

Would map to

func foo() -> Promise<Int, Never> { ... }

The concrete details of this Promise type would then be covered by the structured concurrency proposal.

Additionally, this would allow the user to call asynchronous code from synchronous contexts (and attach a completion handler if desired using something like .finally)

Quick reading

1 Like

That is in the structured concurrency proposal, with the spelling async let.

2 Likes

This is where the desire for "asynchronous handles" (@asyncHandler) comes from. It still needs a proper pitch---and probably a better name---but the idea is that it captures the notion of a function that is called synchronously (say, from a UI) but its body is an async task. So in your button-click example you might do something like this:

@asyncHandler()
func onButtonClick(button: Int) {
  let data = await download(url: url)
  myView.image = Image(decoding: data)
}

with is mostly (more-optimizable) syntactic sugar for:

func onButtonClick(button: Int) {
  Task.runDetached {
    let data = await download(url: url)
    myView.image = Image(decoding: data)
  }
}

Doug

10 Likes

Yes, this is a fair point. The Core Team has demonstrated before that they're willing to go back and fix mistakes in proposals if they come to light later on (we had a couple of those in the Swift 5.3 time-frame). If we were to accept async/await under the current set of assumptions about cancellation, then decide on a very different cancellation design as part of Structured Concurrency that requires us to rework part of async/await, we would.

... but even if that happens, I still consider it valuable to have settled the primary details of async/await before turning our energy entirely over to Structured Concurrency.

Doug

7 Likes

Yes, the overloading based on whether the context is async ensures that you'll get the existing request(_:) within existing non-async code and the new request(_:) within new async code, even if there's no contextual type information.

Doug

4 Likes

runDetached is probably your only option if you need asynchronous cleanups.

Yes, that's a reasonable approach.

Doug

1 Like

What do you think about my concern about back pressure? If that is an important (or valid) concern, should we try to change the proposal to allow await inside deinit/defer?

Banning await inside defer seems like an artificial restriction, so it seems easy to lift?

Could await inside deinit somehow be achieved by forcing functions that (implicitly) call the async deinit of an object to be async?

I’m also curious what the rationale is for disallowing await in a defer block. It wasn’t explained in the proposal, and it doesn’t seem obvious.

The rationale for deinit and property getter/setters makes sense to me, but the defer part doesn’t.

You can always refactor away the need for a defer if you were to actually hit a case where this is importer.

Perhaps, but I don't think it fits well with the goals of the proposal. It means having potential suspension points that are somewhat arbitrary, e.g., at every try and return where there is such a defer, you would have an implicit potential suspension point.

You would have a type that can only ever be inside an async, never put into any type with a non-async deinit (e.g., you couldn't create an array of such types). No, this isn't a restriction that can be lifted: it is fundamental to the model.

Doug

What do you mean by “arbitrary”?

defer is conceptually just code that runs at the end of the function. I see why it has special behavior with regards to try: we want to avoid nested error throwing. I don’t see why it should have special behavior with regards to await.

It turns any try, throw, or return when there is an active async defer into an implicit “await”. That seems reason enough not to support it.

Doug

1 Like

Ah, I see what you are saying. That makes sense. Thanks for answering all my questions!

+1 on the proposal.

While I generally like this [meaning the proposal] approach (coming from C# normally), I also wonder about how a few workflows would be implemented. Sure, Promise.all (or Task.WhenAll in C#) can be done with a loop of awaits, but what about:

let x = asyncLongRunningTask()
let y = anotherAsyncFunction()
...
// wait for any of x, y with a timeout.
// return any task/promise that completed.
let z = Task.any(x, y)

This is something I used several times in my C# coding, at least, usually for graceful shutdowns and the like. But it doesn't seem easy to implement this with this proposal, unless I'm missing something.

1 Like

Correct, such operations are not possible to implement with just this proposal.

These operations are enabled by TaskGroups which are part of the structured concurrency proposal - please check the evolution pitch about structured concurrency (sorry for short reply, on phone)


Okey got my hands on a laptop...

Implementing any would be done as:

Task.withGroup(resultType: T.self) { group in 
  let x = await group.add { await asyncLongRunningTask() }
  let y = await group.add { await anotherAsyncFunction() }
  return await try group.next() // returns whichever completed "first"
}

Note though that this is NOT part of the async/await proposal and will be proposed as part of the Structured Concurrency proposal;

...and that task groups are a low-level building block, they’re pretty verbose but are the only safe way to achieve such dynamism, we could offer helpers for awaiting on any first completed task handle etc.

4 Likes

These operations are enabled by TaskGroups which are part of the structured concurrency proposal - please check the evolution pitch about structured concurrency (sorry for short reply, on phone)

That's very good to hear; I must have missed that when I scanned through it.

...and that task groups are a low-level building block, they’re pretty verbose but are the only safe way to achieve such dynamism, we could offer helpers for awaiting on any first completed task handle etc.

Yes, and I don't want to convey that I necessarily think the C# approach is better. It has its problems as well, since the Task API is already old enough to contain much legacy and it makes it more complicated to use correctly, in some cases. I find the approach in Swift promising.

1 Like

Agree, that’s why I wasn’t originally planning to bring up cancellation. I jumped in when it seemed like those details were influencing this review. As long as the door is kept open to changes around cancellation pending the other reviews I’m happy with this proposal as it is.

Hmm…

If @asyncHandler is more optimizable than Task.runDetached, and it still allows async code to be called from a sync context, we might expect people to include a function like this in their projects:

@asyncHandler()
func asynchronous(_ f: () async -> ()) {
  await f()
}

Heck, even if there’s no difference in optimization, people might write that anyway because it reads better than Task.runDetached at the use-site.

That way they can write code like this:

func foo(url: URL) {
  // Do some synchronous stuff here
  print("above async block")
  asynchronous {
    let data = await download(url)
    print("download complete")
    myView.image = Image(decoding: data)
  }
  print("below async block")
  // Do some more stuff
}

That’s kind of how I would expect to call async code from a sync context in the first place. I shouldn’t need to invoke anything Task related unless I want to access the task itself, eg. for cancellation or monitoring progress.

1 Like