SE-0317: Async Let

Yeah, this is going to be one of the most fraught parts of the new model. We’ll all have to get used to the fact that every time there’s an await, visible mutable state may change without notice. The sequential appearance of async/await code makes concurrency look dangerously easy! That is one of its downsides.

Thinking aloud here, we need to make sure (in most cases) that an object/actor’s invariants hold before every single await, not just at the end of every public function. I imagine we’ll develop a toolbox of common practices to help with this, e.g.:

  • Isolating complex state manipulations that temporarily clobber invariants to non-async functions.
  • Holding more intermediate state in local variables, then writing it all back to ivars in one non-awaiting swoop at the end.

Whatever the ultimate terminology and syntax, I see the async let proposal as being helpful on this front, e.g. with implementing that second pattern.

Does this really help? Partial tasks can still execute interleaved even in the same executor / execution context, so you can still encounter surprising mutations after an await. Cross-context communication is strictly sendable, and thus can’t cause surprise mutations.

Overall, I think async let is going to be really helpful for concurrency and has a great underlying model. This is one case where I think a small tweak could help clarify things.

See this example:

func slowNetworkCall() async -> String { ... }

actor Actor {

   var state: Bool = false
   var color: String { state ? "red" : "blue" }
   var object: String { state ? "balloon" : "frisbee" }
   func mutateState() { state.toggle() }

   func test1() {
     // This could have inconsistent `color` and `object` 
     // But - there's an explicit await, so we know to watch out
     let result = await "\(color) \(slowNetworkCall()) \(object)"
     print(result)
   }
   func test2() {
     // This could also have inconsistent `color` and `object`
     // But there's no explicit await, so we might not spot it
     async let result = "\(color) \(slowNetworkCall()) \(object)"
     print(await result)
   }
}

Say you changed the definition of result in test1() to make it async let (as in test2()). You're currently allowed to drop the await. I think it would be preferable to only allow this to be dropped if there is a single suspension point in the expression on the right side of the equals - possibly also only if this at the start. You would then have to write:

async let result = await "\(color) \(slowNetworkCall()) \(object)"

which would give you the same amount of warning about possibly reentrancy as you get for the non async let case.

1 Like

I have a question to the authors: The proposal states that "A async let that was declared but never awaited on explicitly as the scope in which it was declared exits, will be awaited on implicitly." What is meant by scope in this context?

I ran a test using the current Xcode beta:

actor A {
    var value = 0
    
    func increaseValue() {
        print("Increasing value")
        value += 1
    }
    
    func testScope(probablyFalse: Bool) async {
        do {
            value = 0
            async let a: String = {
                sleep(1)
                await increaseValue()
                return ""
            }()
            
            if probablyFalse {
                await a
            }
            print(value)
        }
        print(value)
        print("End of test()")
    }
}

Calling testScope(probablyFalse: false) prints the following:

0
0
End of test()
Increasing value

Given the above quote, I would have expected the following output (because the do scope ends before the second print(value)):

0
Increasing value
1
End of test()

However, I actually prefer the behavior shown by the current beta to only await implicitly at the end of a function and not to implicitly await at the end of dos (or ifs). The reason is that there is no await between the two print(value) statements. Thus, the programmer would not expect a potential suspension point between the two statements.

Can you please enlighten me?

1 Like

Yeah you're right. I was thinking that if it inherited the actor's executor and was auto-isolated to that actor, it would be able to synchronously access the state atomically.

1 Like

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