I disagree. Very often asynchronous work is I/O bound, and what you want to do is initiate the work (start the I/O) and then do something else (perhaps starting additional asynchronous I/O) before waiting for the results. The concurrency comes from the fact that the "work" being performed happens outside of your process (perhaps even outside of the CPU entirely).
The current design means that if you're initiating that kind of work on a single-threaded executor then you are forced to either wait for each result immediately or forced to use more complex syntax (which as far as I can tell has not even yet been proposed). That makes this syntax both harder to use and harder to learn.
I believe that I/O-bound asynchronous APIs are and should be more common than CPU-bound asynchronous APIs, which means this pattern of starting working that doesn't actually use any CPU time is going to be common. It should be easy to do that from the main thread. That, in my mind, is one of the huge benefits of async/await. Simplifying the complexity of UI code that starts and waits for asynchronous results is a huge win for async/await, but this design makes that win much smaller.
That's not what I said, though. Here's an example to illustrate:
func fAsync() async {...}
func fSync() {...}
func caller() async {
async let t1 = fAsync(); // okay
async let t2 = fSync(); // should be an error
}
None of that has to do with whether you used await in fAsync or on the same line as async let. The difference is that the expression in the first line could have awaitinstead, whereas the expression in the second line could not. So if the expression itself has no asynchronous call (therefore it could not contain an await) then the assignment to an async let variable should be disallowed.
My argument is that the async let syntax should be reserved for calls to async functions, and that it shouldn't change in any way how that async function gets called (i.e., which executor it uses or which thread context it is initially called on). It should only allow for separation of the initial call from the suspension point. It should not be used to introduce multithreading. If you want to run some synchronous CPU-bound function concurrently then you should have to use an API for that. That API may use an async function to allow you to await it, but it should be an API or at least a syntax that has a clear scope.
In my experience in both using async/await with UI code in C# and in helping other people to learn how to use async/await in C#, I believe strongly that blurring the lines between "waiting for asynchronous results" and "introducing multithreading" is going to make the feature both harder to use and harder to learn. It makes it difficult for people to form a clear mental model of what async/await actually does. It's much easier to understand and use when it consistently means "a new syntax for waiting for the results of asynchronous results" and not "a new way to implement multithreaded code". We have a strong need for the former, but I'm not sure we have much need at all for the latter.
Note though that non-async function are implicitly converted to async. Differentiate the two in this manner would have to be an exception to that rule.
You can place a single try await at the beginning if there are no async closures involved. It's one of the benefits of not having a Future-like type as building block for asynchronicity:
At first sight it seems a clear and intuitive design. I have a few considerations, though:
await meat alone would give a warning of unused value, so you would be forced to use _ = await meat. The same applies for the other try await isolated expressions;
you can await a value without realizing it, e.g. by using await print(meat), would that count as awaited too?;
if you move await meat on a different line you may need to move the await somewhere else.
That would certainly be less cluttered. The code example in the proposal led me to believe the individual arguments would each need to be annotated. (Why wouldn't the spelling you mention almost always be preferred to interweaving the keywords with the arguments?)
The proposal includes the example:
{
async
let
ok = "ok",
(yay, nay) = ("yay", throw Nay())
await ok
try await yay
// okay
}
which led me to believe that await ok or try await yay in the code example would not cause a warning. The proposal doesn't explicitly say one way or another.
I would imagine so, since it needs to be be present to print it.
I was thinking of await meat sort of like guard let item = optionalItem. If I move a reference to meat or item before the appropriate line, I'd need to move where I await or unwrap.
But for me at the moment, it's less about whether it should or shouldn't work this way, and more about clarifying how these things are currently intended to behave.
If async let is used in a UIActor context, would the spawned task still be run concurrently? Or does this depend on whether the initialiser is also annotated with âUIActor`?
I'm not sure if you're asking me or the pitch's authors. My understanding is that as currently written the answer is that it depends on how the function (or class?) is annotated.
My argument is that it should not make a difference. Calling load1 or load2 directly with await or by using async let should make no difference in terms of which call stack is initially used by the call (before the suspension point) or which executor is responsible for the task.
Your example shows a reason why that's so important: your functions both use UI objects and therefore must run on the UI thread. If changing their caller from await to async let changes the thread that they run on (even if the caller remains on the UI thread) then it's trivial to introduce a bug in your application. That would make this feature really painful to use. The default behavior of async/await should as much as possible be to make code like this Just Work. Refactoring to change where the await happens by using async let should not break things.
Let's say we'd like to clear two stores concurrently.
@UIActor func logout() async {
async let clearCache = App.currentAccount?.clearCacheStore() //line A
async let clearData = App.currentAccount?.clearDataStore() //line B
await [clearCache, clearData] //line C
App.currentAccount = nil
}
async let makes the whole right hand expression a child task. So App.currentAccount may be accessed on another thread, and the complier will complain about possible data races here, right?
Solution A:
Original code with currentAccount annotated with @UIActor:
[line A, B] Two child tasks are formed.
[line A, B] The child tasks cannot complete, because they need to access currentAccount with an UI executor so the child tasks are suspended.
[line C] The logout function gives up control and awaits for child tasks to complete. This gives the child tasks a chance to get currentAccount with the UI executor and finish their remaining operations.
Solution B:
Tweak the async let declaration:
@UIActor func logout() async {
let currentAccount = App.currentAccount //line A
async let clearCache = currentAccount?.clearCacheStore() //line B
async let clearData = currentAccount?.clearDataStore() //line C
await [clearCache, clearData] //line D
App.currentAccount = nil
}
[line A] The currentAccount can be directly accessed because we are on a UI thread. Event if currentAccount is annotated with @UIActor.
[line B, C] Two child tasks are formed and executed concurrently.
[line D] Await for child tasks to complete.
Solution B looks more efficient that Solution A, is that right?
This makes me a little uncomfortable because a small difference in async let declaration makes a total different execution path.
The same goes for just about any closure that escapes, which would be common here. It can also be hard to spot given that Swift's trailing closure is meant to mimic control-flow syntaxes.
I still think that having a checkedTask instance would be the best approach. We need to freeze the Task (a.k.a suspend) for the Task instance to be valid so likely this goes hand-in-hand with task continuation (syntax TBD):
Task.isCancelled/local // 1
withContinuation { continuation, task in
task.isCancelled/local // same as 1
DispatchQueue.main.async {
task.isCancelled/local // same as 1
task.resume(...)
task.isCancelled/local // error
}
task.isCancelled/local // race
}
I think this could work with the current implementation without performance compromise. I could be wrong, though.
An await operand may also have no potential suspension points, which will result in a warning from the Swift compiler, following the precedent of try expressions:
An additional point I would like to raise in relation to async let (and presumably also task groups, considering that async let is further described as sugar to task groups) implicitly changing the executor is that this appears contrary to one of the intended use-cases outlined in the Task Local Values pitch, namely that the executor of a task be determined by a task local value. As described there, child tasks are to inherit their parent's task locals unless they are explicitly rebound to different values for that child task; therefore child tasks should also inherit the parent's executor unless it is explicitly rebound. The implicit executor-changing behaviour would violate this; not exactly an unreasonable thing, but more surprising than the alternative, in my opinion.
It is unclear to me at what point the concurrency with a new child task actually starts. What is the default behavior and how to customize it, for exemple starting a child task in a specific queue.
My current vision is the following, but Iâm not sure it is what the pitch says. Looking especially at the async let statement, the current task would let the child task start and suspend once before actually proceeding to the following statement.
In other words when an async let statement runs, a child task is created and the current task is suspended until said child task first suspends itself. No parallelism by default.
It works well with UrlRequests and other IO, that will suspend quite quickly and do not really benefit from hopping queues. (It might even be counter productive regarding run loops).
I think it also works well with worker task, or wanting a specific queue, with the help of some queue-hopping function (standard or custom).
// some IO that doesnât need to hop queues
async let updatedContact = webService.getContact(id: id)
// some heavy work that needs to be in a specific queue
async let gatheredData = doInBackground {gatherLocalData(contactId: id)}
I see that there is a 3rd draft for the structured concurrency proposal posted a ~week ago. Is there a discussion thread for it that I'm not seeing, or would you like to continue this thread that quiesced? I'll take a pass through it when I have time and want to send any feedback to the most appropriate place. Thx
We have more revisions coming based on the discussions in this thread, which should be ready in a proper 3rd revision within a week. It might be better to wait for that.
Thereâs a revised actors pitch thatâs fresh, though.