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.
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).
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.
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.
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 anasync
function. Furthermore, overload resolution prefersasync
functions within an asynchronous context, because such contexts should avoid synchronous, blocking APIs when there is an alternative. When overload resolution selects anasync
function, that call is subject to the rule that it must occur within anawait
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 anasync
function. [...] Furthermore, overload resolution prefersasync
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.
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
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.
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
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
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
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 anasync
function) introduces a suspension point. Any suspension point must occur within an asynchronous context (e.g., anasync
function). Furthermore, it must occur within the operand of anawait
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
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.