SE-0317: Async Let

Given this from @Chris_Lattner3 over in another thread:

Also, [the use of async in async let] doesn't align with other uses of async in Swift which is very big deal as pointed out by many on this thread. async means "this is suspendable" not "create a new task".

I want to ask one more time why we aren't considering reversing this by making async let mean "asynchronous-interleaved-in-the-same-task" instead of "concurrent-in-a-new-task"?

As pointed out earlier in this thread, any desired concurrency in the RHS of an async let initialization typically comes from concurrency of the underlying system function. For example, when retrieving network data, the desired concurrency will occur at the level of the network access, not at the level of the code that sets up the network access. Typically there is no real benefit to making the setup code concurrent in yet another task.

If async let was actually asynchronous-but-not-in-a-new-task, that would also largely eliminate the discomfort about the RHS seeming to need braces to clarify that the execution context is different.

I guess I didn't properly understand before that no suitable execution mechanism had been proposed that would allow async let to (for example) run its RHS interleaved with other code in the current task.

However, it occurs to me that there is an execution mechanism that does something just like that: the mailbox/queue for actor isolation.

If, for example, Swift's tasks could have a mailbox similar to an actor mailbox, then the async let RHS could get interleaved behavior and could stay in-task, and so could avoid the semantic objections being voiced in this thread.

A side benefit (IMO) would be that async let outside of actor scenarios would be safer. IIUC, async let outside of an actor, as currently proposed, is thread-unsafe, because it permits cross-thread mutation of state.

That's a problem for all non-actor-related child tasks, of course, but it seems a bit more dangerous in async let because the unsafety isn't very obvious.

I’m not knowledgeable enough to critically reason about this topic, but your description sounds like an improvement. What are the downsides of that approach?

The obvious one is that I'm suggesting the use of an implementation feature (Task mailboxes) that doesn't exist! :slight_smile: Implementing something like this might be nothing like actor mailboxes.

Conceptually, the downside might be that the RHS behavior is *still too hard to explain or comprehend. I don't think so, since this is conceptually similar to interleaved behavior on @MainActor, but I'm probably too close to this to have an objective opinion.

A given task is never itself concurrent. You need a new task if you're going to interleave computations, which async let creates.

Perhaps what you're really asking for is for the right-hand side of an async let to inherit the actor context, so it runs on the same actor as the code that initiated it. Task { ... } does this, for example.

The initializer of an async let is treated as if it were in a @Sendable closure, so Sendable checking prevents data races introduced here.

Doug

"At the end of the function" is probably not a great answer if, e.g., this happened in a loop:

for await x in elements {
  async let y = doSomething(x)
  if someCondition(x) { continue }
}

We don't want to accumulate an dynamic/unbounded number of tasks that need to be completed at the end of the loop, so we'd really need to wait for the task associated with y before starting the next loop iteration.

Doug

5 Likes

Thank you for your response. This is a good reason. I have thought a little bit more about this. I see three different approaches:

  1. Require that every async let must explicitly be awaited on every path of execution.
  2. Implicitly await remaining async lets at the end of loops and functions.
  3. Implicitly await remaining async lets at the end of every scope.

Option 1 prevents the need for implicit awaiting except when an error is thrown. I prefer it because it is safe, easy to understand and allows us to easily switch to one of the other options at a later date if it turns out to be the wrong trade-off. Switching from one of the other options to Option 1 would be source breaking.

Option 3 would imply that every end of a scope where unawaited async lets could occur would become a potential suspension point. As a main point of the concurrency effort is that all potential suspension points are marked with await, this would be major problem in my opinion. I think this would be hard to teach: "You have to ensure that your invariants hold before every await; oh, and at the end of every if and else and do and for and while; but only sometimes." This could be a source of many bugs and race conditions.

Option 2 is sort of a middle ground. Here, invariants need to be ensured at the end of loops containing async lets that are not always explicitly awaited. I would assume that would be the case anyway most of the time. However, this could still lead to race conditions. In addition, invariants need to hold at the end of functions containing async lets that are not always explicitly awaited. I am not sure how problematic this would be.

Which option do the authors propose?

1 Like

I’m a bit confused. There are possible suspend points in my code where there’s no await if I use async let?

1 Like

Yes, since the async let is creating a child task and the child task cannot outlive the parent scope.

If you ignore the result of the async let by not await:ing the result of the task, the task will automatically both be canceled and await:ed.

But, if I understand correctly, you will get a warning from the compiler about the unused (async) constant in the async let statement.

2 Likes

So the only "hidden" suspend point is the end of the scope in which the async let was created? If so, that doesn't complicate reasoning about flow that much, as you are obviously in an async context anyway and whatever is calling your code expects it to be a suspend point overall.

I am imagining a situation where there's two execution paths and each path depends on a long-running async let value independent of the other, but the branch depends on a third long-running task's result. I'd want them to run concurrently to avoid unnecessary waits. Is the best thing then to start each path by cancelling the other path's task, so it has time to respond to the cancel (especially if these operations have expensive rollbacks required to cancel)?

Is there any way to get the Task.Handle from the variable to call cancel() on it explicitly, or is implicit cancellation the only way a Task created by an async let may be canceled?

Another point of view would be, in the default scenario, there shouldn’t be any hidden suspension points in the code, since you really are expected to await any asynchronous value introduced by async let in the scope. The main reason for using async let is to retrieve or calculate a value asynchronously and not to first perform all that work, and then just throw the result away.

Failing to await could just as well have been treated as an error. But as I understand from the proposal, it’s allowed to give a greater flexibility at the cost of making the code a bit harder to reason about. Especially the first time you encounter this special case.

I did have to re-read the proposal(s) again to try understanding how explicit cancellation might work for async let. But I couldn’t find any indication that it’s possible. Maybe someone else knows?

The Task introduced by async let is hidden IIUC, and therefore you have no handle to invoke a cancel() method on. The only thing you can do is await to get the value (or not). But as in your example, I suppose you can often rely on the implicit cancellation if you just ignore await on one of the long running calculations. And actually check for the cancellation in the Task as well.

Otherwise I assume you have to use a task group or an unstructured Task to get an actual handle to call cancel() on.

My imagined example is probably better suited to manually creating tasks that could then be managed than using async let for unmanaged child tasks.

On the naming, I think it should parallel the async and/or asyncDetach blocks, whatever the name we decide on.

It's taken a while to sink in, but this is the piece that I've misunderstood ever since the formal proposal appeared. This is actually a design goal, not a detail of how async let happens to be implemented right now.

I understand the coherence of this goal. It simplifies the conceptual model because every task has a single path of execution (that is, a single task can only have a single continuation "in flight" at any one time). That's simple to grasp and I have no problem with it as such.

However, I don't think this is the right model for async let. I think that async let (however spelled) is a foundational part of Swift concurrency. For developers new to Swift or new to concurrency, I think this is the second most basic concurrency-related syntax to learn, after the basic await syntax.

In other words, I think async let is going to be a concept to learn before learning about explicit child task APIs, and therefore it should read similarly to sequential code, without ceremony or additional syntax on the RHS of the =.

OTOH, I think that tasks should have ceremony and syntax, including at least a set of braces around the scope of each task. That's what is required for grouped child tasks, and for both kinds of unstructured tasks.

At this conceptual level, I don't think it matters (from a justification point of view) whether the async let task is concurrent or overlapped, or what kinds of thread-safety guarantees can be given. Those are downstream conceptual details.

What matters, I think, is that we're stuck with a dilemma, that async let shouldn't look like a task, but that it can't not look like a task in the current design.

1 Like

I agree if the end of the scope is the end of an async function. However, if the end of the scope is the end of an if block, one may very well overlook this potential suspension point.

I think there is currently no warning if the async let is awaited on in one path of execution (e.g. in an if) but not in others. Here, you have a potential suspension point without await and without a warning.

As I understand it, the currently proposed model is described in this post and refined in this post.

1 Like

That’s also my concern - we have structured concurrency but this structure is not really visible in the syntax - besides a little overloaded keyword


1 Like

When you start using async let it will probably be easy to miss that point. But I think it will feel more natural if you look at it as part of structured concurrency. If you declare and initialize a new variable in an if block, the variable will be deinitialized at the end of the block and you cannot use it after that scope.

The same applies to async let – any “asynchronous value” created with async let must be cleaned up at the end of the scope in a similar way. Either you await the value explicitly or the compiler/runtime will do it implicitly for you. Otherwise the Task would escape the scope.

I guess this is something we just have to learn. The potential suspension point is created by the actual async let statement. Any asynchronous work initiated by an async let statement will always be awaited for at the end of the scope (at the latest).

At the same time, I think we need to separate two use cases. The most common use case is when you initiate an asynchronous computation (parallel by default) with async let and you later await the result before using it. Simple and elegant, if you ask me. This is what you should teach a beginner.

But there is also the more advanced use case when you are only interested in the asynchronous value if some condition is fulfilled. E.g. in case you need to abort for some reason. Since the Task handle is hidden – which is the reason the async let feature is lightweight and simple to use – you cannot explicitly cancel the computation. At the same time, the structured nature requires it to be awaited before leaving the scope.

In this case the asynchronous value will be implicitly canceled and awaited for (automatically behind the scene). I don’t see any better way to handle this situation, except not allowing it in the first case. An unnecessary limitation, in my opinion.

Ah, I haven’t tried that myself. But it makes sense if you think about it. Not awaiting the async let at all is either an error (a misunderstanding of the feature?) or you just haven’t finished the code yet. A warning is reasonable.

But if you have at least one path awaiting the async let you are most likely applying the “advanced use case” and want to discard the result of the async let in those other paths, to be implicitly canceled and awaited for. Otherwise you would have to explicitly await in every other path and still not use the result. This would, in most regards, be harder to understand and unnecessarily clutter the code than the implicit cancellation and awaiting.

But of course, it’s a trade off. Allowing the more advanced use cases makes it harder to understand where and when the code can potentially suspend.

1 Like
for element in list {
    async let x = f(element) // Should the compiler warn here?
    if maybe(element) { 
        element.y = try? await x
    }
    // Should the compiler warn here?
    // Or if x is not awaited above, should it just be implicitly awaited here?
    // Or should we require an `await continue` here??
}

I guess the author would probably want to spawn all the elements and only use the value of some, so this should really use a task group instead.

My question is if the compiler should

  1. Warn about using async let in a loop
  2. Warn about potentially un-awaited async lets in a loop
  3. Require e.g. await continue
  4. Somehow allow not waiting on a child task at the end of each pass through the loop
  5. Just assume the author knows what (s)he is doing, and run all the f()s sequentially.

Edit: Updated with await continue.

1 Like

One other option may be to require an await on exiting a scope with a dangling async let. If the compiler identifies a branch where one or multiple async lets have not be awaited on it could offer to insert an await return, await throw (if the scope ends by throwing) or await break (this last one may be tricky). This wouldn’t force you to explicitly handle every single async let, but it would force you to mark the suspension point.

Now to me it doesn’t seem the most elegant and might just become something that we slap on the end of scopes to make the compiler happy. It also moves the needle back towards requiring await everywhere which the authors identified as a pain point when working with async let.

Final disclaimer: I have no experience in compiler work, so I do not know if this is in any way feasible or desirable.

2 Likes

I said previously that it's fine to have an implicit await at the end of the function, but I need to change/clarify that opinion: it's fine at the end of an async function because there's already an await from the caller at the call site, and we're sort of awaiting inside of that call boundary. But I think the await needs to be explicit at the end of a scope when in the middle of a function.

4 Likes

This is my take as well, but it leaves the question of how to do it elegantly.