SE-0317: Async Let

Yeah, this gave me pause too. If you think it through, autoclosures really shouldn’t make this possible.

An autoclosure takes something that looks like a normal value and makes it a closure instead, assigning an expression of type T to a parameter of type () -> T. This code:

func expensiveComputation() -> T { … }

@async let foo: T = { expensiveComputation() }

…is the opposite of what an autoclosure does: it’s taking a closure of type () -> T and assigning to a variable (the implicit wrappedValue parameter of the property wrapper) of type T.

But but but…is it possible to finagle a property wrapper it to make this line of code work if we use trickery inside the property wrapper, say, making wrappedValue a computed property?

@Foo let val: T = { expensiveComputation() }
TL;DR: No, it’s not possible. And it doesn’t support `let` either. (Expand for details)

I did some experimenting, and property wrappers are pretty clear about this, as it turns out:

@propertyWrapper struct Foo<T> {
    private var wrappedValueDeferred: () -> T

    var wrappedValue: T {
        get {
            wrappedValueDeferred()
        }
        set {
            wrappedValueDeferred = { newValue }
        }
    }

    init(wrappedValue: @escaping () -> T) { // ❌ error: 'init(wrappedValue:)' parameter type ('() -> T') must be the same as its 'wrappedValue' property type ('T') or an @autoclosure thereof
        self.wrappedValueDeferred = wrappedValue
    }
}

Ah, yes, but to the original question: can we make this work with @autoclosure? Almost! This compiles:

@propertyWrapper struct Foo<T> {
    private var wrappedValueDeferred: () -> T

    var wrappedValue: T {
        get {
            wrappedValueDeferred()
        }
        set {
            wrappedValueDeferred = { newValue }
        }
    }

    init(wrappedValue: @autoclosure @escaping () -> T) {  // ✅ OK
        self.wrappedValueDeferred = wrappedValue
    }
}

…but then when you use that property wrapper:

@Foo let val: T = { expensiveComputation() }  // ❌ error: property wrapper can only be applied to a ‘var'

Oops, OK, well, as the proposal states, they chose not to support async var for a reason, but let’s go with it:

@Foo var val: T = { expensiveComputation() }  // ❌ error: cannot convert value of type '() -> T' to specified type 'T'

…because, oops, right, autoclosure is doing the opposite of what we want. This works:

@Foo var val: T = expensiveComputation()  // ✅ no braces = OK; expensiveComputation() is secretly deferred

…but adding the braces was the whole point.

In short, property wrappers as constituted in the language now really aren’t a fit for the syntax Chris wants — just as Doug stated. (And I guess he should know!)

5 Likes

Is this definitely allowed? The proposal says:

The initializer of a async let permits the omission of the await keyword if it is directly calling an asynchronous function

In this case, it's not directly calling an asynchronous function, it's the result of the synchronous + operator, so I would think it should require an await. However, the trunk snapshot I have (18th May) does allow this, so I wonder whether it's the proposal or implementation that is correct?

2 Likes

The proposal more specifically states:

For single statement expressions in the async let initializer, the await and try keywords may be omitted.

In other words, await may be omitted when a single top-level expression is used. Technically, twoThing() + makeString() is a single expression, although that expression does have child expressions.

This works similarly to the rules for omitted return statements. Basically, if the right-hand side could be written without any newlines or semi-colons (without any statement separators), the await can be omitted.

This should be safe because:

  1. An await will be required when trying to access the value later.
  2. If the right-hand side has only a single expression statement that involves calling one or more async functions, then in most cases there will be no opportunity for any shared mutable state to be relied on after the suspension point(s) (because there are no further statements in scope).
1 Like

I agree that for the 'outer' suspension in the expression on the right of an if-let, we don't need an await, because the await when the variable is used captures this. So, if it's just calling an async function, this would be fine.

However, I'm not so sure about this one:

Expanding on Chris's earlier example:

actor Actor {

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

   func test() {
     async let result = "\(color) \(object)"
     print(await result)
   }
}

let a = Actor()
await a.test()
await a.mutateState()

There is a suspension between calling color and object where mutateState() could be scheduled. test() isn't expecting a suspension between them because there is no await on that line, but you could get "blue balloon" printed. Earlier conversions have highlighted the importance of explicitly marking these re-entrancies with await.

1 Like

Good point. This might present a potential high-level race. Although in this particular example it won’t because child tasks are guaranteed to complete before their enclosing scope exits. But someone else could call mutateState from a different context to cause one.

I’m not sure if having an await under existing rules does too much to solve this though, because the await could still be only used on the outermost expression, making it easy to miss the suspension in the middle of the expression.

If child tasks eventually are able to inherit their parent’s executor, this could perhaps also be mitigated.

1 Like

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.