[Concurrency] Asynchronous functions

I don’t know that I’d call that a common pattern exactly, but yes, we will sometimes be able to take advantage of that.

This pitch allows two functions that differs only by async-ness. This is nagging me a lot. I first thought that it's because that requires splitting the type system into two worlds, but actually, the overloading itself bothers me more. There are no ways to explicitly differentiate the two functions, and you can only rely on type checker to do the job. So it's worse than functions with only different return type, i.e., func foo() -> Int vs func foo () -> String, which is already ill-adviced. Most of the disambiguation comes from the await keyword (or the lack thereof), we can most likely maximize the number of async functions, which may be correct enough not to be an issue.

// func task1() -> X.      // 1
// func task1() async -> X // 2
// func task2(_: X)        // 3
// func task2(_: X) async  // 4

// async context 
await task1() // 2
task2(await task1()) // 3, 2
await task2(task1()) // 4, 2?

Ok, so a proper inference rule should make this mostly usable (except for cases like 4, 1 and what Chris already mentioned). A much bigger problem lies in a fact that await is actually the ceremony to indicate that you're calling a function that may suspend, and take arbitrary amount of time. Assuming that task1 sync and async versions are doing the same task (which this rule is tailored to), the only possible scenario would be that sync task1 is just a blocking version of async task1, otherwise, they wouldn't have async version. So what does it mean if I write:

// Inside async context
task1() 

Not just from a perspective of the compiler, but the users' intent, there are two scenario:

  • The user forgot to perform the ceremony and use await, or
    • We accidentally call a blocking version on a context that explicitly advice against blocking. Pretty bad. This could be considered actively harmful
  • The user intent to use a sync version of the function.
    • The user is intentionally blocking, which is equally as bad, now with more intention.

So we can say that calling sync version in async context would likely be ill-adviced. OTOH, using sync task1 at all is not very advisable, and is pretty much what async function is trying to eliminate. So APIs that have both versions would be far better off having only async version. We can also provide a more canonical version to wait on async task:

let result = Task.syncWait {
  await task1(...)
}

which I think is sufficiently salted (we're not supposed to block a thread anyway) and would half the API surface. So I think we should just reject functions that differ only by async keyword.

I'd even be happy if we reject overloads, and have no implicit conversion, in case the actual usage go either way.

4 Likes

Don’t you have this unclarity at use site with throwing functions too? Whether to add a try or not is often not obvious either.

We already rejected throwing overload (by treating them as redeclaration). So if you call foo, there's only one version, throwing or not. If you forget to try, it'll fail to compile, which is pretty much half of the point for ceremony (the other half being on the reader side).

1 Like

True.

BTW is it possible to mark a method async without it await-ing inside or will the compiler complain on that? Sometimes you want a method to have an async signature just to be “future proof”.

It should be the same as throws, so probably yes.

3 Likes

Can’t you achieve that behaviour by using runDetatched? It would be interesting how that could be automated with @discardableResult


On a separate note, how does it work when multiple async expressions are used together? E.g. await syncFunc(asyncFunc1(), asyncFunc2())
Do they run sequentially, or an implicit nursery is created and they’re run in parallel? The only thing the proposal seems to specify is that there may be a suspension point in that call


I know that it is explicitly called out, but I would’ve liked async functions to be supertypes of equivalent sync ones. As the same way a throwing function “may” throw, an asynchronous function “may” suspend.

1 Like

I think the rule is simple: When inside an async function, it will pick the async variants, when outside it will pick the sync variants:

The ability to overload async and non- async functions is paired with an overload-resolution rule to select the appropriate function based on the context of the call. Given a call, overload resolution prefers non- async functions within a synchronous context because such contexts cannot contain a call to an async function. Furthermore, overload resolution prefers async functions within an asynchronous context, because such contexts should avoid synchronous, blocking APIs when there is an alternative. When overload resolution selects an async function, that call is subject to the rule that it must occur within an await expression.

Note also that both instances use the word prefer

Given a call, overload resolution prefers non-async functions within a synchronous context because such contexts cannot contain a call to an async function. [...] Furthermore, overload resolution prefers async functions within an asynchronous context, because such contexts should avoid synchronous, blocking APIs when there is an alternative.

The current toolchain actually force the selection in face of ambiguity (though preferring isn't much better as I described above):

func a() async { }
func a() -> Int { 1 }
func b() { }
func b() async -> Int { 1 }

func c() async {
    await a()
    b()

    a() // Error: Call not marked with `await`
    a() as Int // ok
}

So we're not just providing different functions, but splitting the world into two. Sync context cannot call async overload, and vice versa. This goes much beyond just shadowing. If we provide a way to sync-ly wait on async function, wouldn't its use case disappear entirely? Then we'd be stuck with this sync/async duality due to source compatibility.


Also what Chris said about types with different return type:

await a() + b() // sync a + async b

To me this looks like a complex type system.

1 Like

I agree, this is very concerning to me. To add to the pile, remember that the await marker only even applies if you call the function in question. There are also good reasons one would want to name unapplied functions unambiguously, which await doesn't help with.

Unrelated question: what lives at the intersection of async functions and keypaths?

-Chris

5 Likes

Key paths work with properties and subscripts, which currently cannot be async. If we allow async properties and subscripts, we would naturally have a concept of async key paths. I think it is more likely that we would just disallow key paths from being formed with such things, the same way we disallow then from using e.g. mutating getters.

Key paths with actor restrictions — especially global actor restrictions — may be more important to model, but fortunately can (in principle) be modeled in the type system as a sort of qualified key path — it doesn’t need a whole new dimension of KeyPath type.

7 Likes

Makes sense, thanks!

-Chris

Additional question: just as with error handling, it is possible to satisfy async protocol requirements with sync implementations, right?

protocol P {
  func doThing() async -> Int  // requirement is async
}

struct S : P {
  func doThing() -> Int { return 42 } // impl isn't async
}

I shouldn't have to write wrappers to do this. This relates to the whole subtyping and overloading discussion.

-Chris

8 Likes

I think it should be supported.

async function in protocol allowed to be implemented in value type in sync way, how about the vice versa?

protocol P {
  func doThing() -> Int  // requirement is sync
}

actor class actorClass : P {
  func doThing() async -> Int { return 42 } // impl is async
}

Definitely not, for the same reason that you can't implement the requirement with a throwing function. It doesn't have a subtype relationship and the caller won't be prepared for the suspention/throw.

-Chris

10 Likes

We should probably reject "try await" to be consistent, yes.

To clarify, it is not the presence of await that changes overloading. When you are within an asynchronous function, we prefer async functions. When you are within a synchronous function, we prefer synchronous functions.

If you're talking about the implementation of the type checker, no, it does not make it more complicated. The implementation was straightforward and aside one bug we've found in the initial implementation, it hasn't surprised us yet.

For one thing, overloading allows us to avoid having to suffix "Async" or "Asynchronously" onto names that might collide with synchronous functions.

I don't think the issue is limited to Objective-C interoperability, although it does show up there. Extisting synchronous, blocking APIs are likely to get augmented with asynchronous APIs, and we want the asynchronous code to prefer to use those new asynchronous APIs.

It works with throws, and async does exactly the same thing.

As just confirmed above, if you're passing a closure that is fully synchronous (has no await in it anywhere) to a function that takes an async closure, you can use async in. I understand that you'd prefer to trade overloading for subtyping (and it's worth investigating that), but this particular example is a tiny syntactic optimization of the kind that a Fix-It could introduce when it comes up.

The throws one keeps the error parameter but changes its type to (), so your call looks like:

try object.callThrowingThing(a, error: ())

It's quite baroque, and I wish we hadn't done that. The corollary for async would be async: (), which I wouldn't want to do. If we dropped overloading of async and non-async, I'd lean toward appending an Async or Asynchronously suffix to the base name.

Doug

2 Likes

We haven't mentioned the dispatcher, how do the continuations resume? The old callback way may not involve dispatching, the callback is invoked just in place sometimes, so how do we guarantee that the coroutine is always run on a fixed queue?

All executors allow you to submit work items to them. So in the worst case, the task suspends itself and submits an item that resumes running it on the right queue.

Hi, awesome proposal. Thanks for all the efforts.
Is there a technical need for this statement

A call to a value of async function type (including a direct call to an async function) introduces a suspension point. Any suspension point must occur within an asynchronous context (e.g., an async function). Furthermore, it must occur within the operand of an await expression.

One of the issue of async/await is that it divides the world into 2. As this function color post states. In order to use async/await in existing codes, we'll need to mark functions as async all the way up to main function.

Is it possible to introduce a Promise type to convert the async function back to plain function?

func foo() async -> Result {...}
func bar() {
  let result = try foo().wait(timeoutSecond: 10)
}

Or even use a type based approach instead of async/await keyword?

func foo() -> Promise<Result> {...}
func bar() {
  let result = try foo().wait(timeoutSecond: 10)
}

Any methods that can convert suspension points back to plain old callbacks(promises) can greatly increase adoption rate.

Similar things in another languages
C#: await 運算子:以非同步方式等候工作完成 - C# | Microsoft Learn
JS: async function - JavaScript | MDN

3 Likes

This can be implemented by simply wrapping the Task.runDetached method like this:

func asyncWrapper<T>(_ operation: () async -> T, completion: (T) -> Void) {
    let _ = Task.runDetached {
        let result = await operation()
        completion(result)
    }
}

This is useful, look forward to a polished way to do so.

5 Likes