SE-0317: Async Let

I am -1 on this particulars of this proposal, but +1 on solving this. This proposal is a strong step in the right direction, but it is an overstep.

Something like this fits into the general direction of Swift. I haven't used similar language support anywhere else (I'm not aware of it existing). I put a bunch of effort into this in pitch threads and read the proposal carefully.


First, as an important technicality, it is highly irregular to have open questions in the proposal. The proposal should take a stand one way or the other and put the alternative in the alternative's considered section. This framing makes it difficult to understand what the proposal is proposing. I do include my opinion below though.


Getting to the actual review, I agree very much with the motivation section: we need to improve the syntax, safety and performance of the common "ad-hoc structured concurrent child task" case. This makes a lot of sense to me, and I agree that TaskGroup is syntactic overkill for this important special case.

However, this proposal is killing a mosquito (maybe a bird?) with a bazooka - it is conjoining a large pile of semantics and erasing a bunch of syntax, built into a new declaration modifier that we already use for other things in the language. I realize that anything in this space will invariably be syntactic sugar: I'm not arguing against sugar -- I'm arguing against this take on it.

There is a big spectrum from which we can produce solutions for this problem. The TaskGroup API is unacceptable as pointed out in the motivation section. I think we should explore a property wrapper based approach that will allow capturing the majority of the benefit of this proposal while maintaining some of the syntax that was jetison'd. This was not explored in the alternatives considered section.

I haven't implemented or prototyped this, but I think that instead of:

  async let veggies = chopVegetables()
  async let meat = marinateMeat()
  async let oven = preheatOven(temperature: 350)

  let dish = Dish(ingredients: await [try veggies, meat])
  return try await oven.cook(dish, duration: .hours(3))

we could build on the recently added improvements that allow async properties to go with a property wrapper + closure approach. This would give us a simple design that composes better with existing-Swift, and reduces the amount of reinvention:

  @ThrowingFuture let veggies = { try await chopVegetables() }
  @Future let meat = { await marinateMeat() }
  @Future let oven = { await preheatOven(temperature: 350) }

  let dish = Dish(ingredients: await [try veggies, meat])
  return try await oven.cook(dish, duration: .hours(3))

This approach would also be a HUGE improvement over the code in the motivation section. It has a number of differences from the proposed approach:

  1. It is dramatically less invasive on the core compiler, which is a good sign it will compose better with other features.

  2. It builds on the existing functionality we have to wrap a property (property wrappers) instead of inventing new type system rules through a decl modifier.

  3. It doesn't overload the async keyword to mean something different than the effect modifier. To point out one problem, throws and async are both effects and should be generally treated equally: we don't want to support try let x = .. as a deferred throwing feature.

  4. By using closure syntax, we more clearly delineate that the initializer expressions are not part of the enclosing scope. This matters because they do/should introduce a @Sendable check, they should change the behavior of actor self to require an sync jump, and will have other effects. This is load bearing syntax, not boilerplate.

  5. By making the closure explicit, it means that we can bring back the try and await modifiers in the initializer list and it makes sense what they mean and they are now consistent with the rest of the language.

  6. The use of closure syntax allows us to use multi-statement computation of the value, not just a single inline expression. This is more powerful.

  7. Using a property wrapper means that you get the existing property wrapper behavior with tuples, you probably don't get the fancy stuff described in the pattern section. I personally find this to be a /good/ thing. The pattern section describes a bunch of special cases that are confusing to me, seem like there are likely to have complicated interaction issues, and (in my opinion) should be split out to their own proposal if they stick around. If property wrappers + patterns aren't what we want here, we shouldn't "innovate" in core language semantics as part of this sugar proposal.

The major challenges I can see from a property wrapper approach are:

  1. Slightly more syntax. I don't consider this a downside because it makes the behavior much more clear. However, if this were a problem, we could change the property wrapper to take an autoclosure, and could introduce attributes to suppress marking modifiers. Such moves should be very carefully considered though.

  2. We want to make sure the value isn't copied and doesn't escape for safety and to enable on-stack optimizations. This is something that will make major progress with the Ownership Manifesto but we don't want to wait for that. I would recommend adding a special @_magic attribute to the Future property wrapper that enables this, and tie the hard coded logic to this attribute in the compiler. Keeping this orthogonal would allow this attribute could be used with other things in the Swift Concurrency design that shouldn't escape either (e.g. UnsafeTask was one random example that was discussed and was dropped because there was no checking for it).

  3. You want to do on stack optimizations for these values, and you want a special "destructor" for these values when they go out of scope. As the authors know, the Swift language model supports custom destructors, so those can be added once #2 guarantees these values don't escape. The actual optimization should be straight-forward to do in SILGen or in the SIL optimizer for types that have the @_magic attribute. Such optimizations should apply to anything with this attribute on it, which would be a lot more general than what is proposed here.


On the open questions in the proposal, these are deep questions that have to be resolved:

Under the current proposal, this function will execute the registered task, and before it returns the "Hello …!" it will cancel the still ongoing task registered , and await on it. If the task registered were to throw an error, that error would be discarded! This may be surprising.

Yes, I agree this is surprising. I see this as a syntactic sugar proposal for task groups, and the issue here is that it is sugaring away the try on the task group itself. I personally think that sugaring away the task group is the right thing. Given that, you get to this point:

It might be better if it were enforced by the compiler to always (unless throwing or returning) to have to await on all async let declarations.

The problem is that the compiler can't do that correctly - this is a dynamic property you're talking about, not a static property, and solving this in full generality is equivalent to the halting problem. We could do a flow sensitive approximation for this, but that will be conservative, and a pain for users in any case where you have conditional control flow.

There is also the question of what is the behavior you want - it seems like a thrown error should be catch able, even if you don't touch it. This means the dtor for the future throws:

   do {
       async let veggies = chopVegetables()
       if someCondition {
          // throws if veggies had an error.
          try await use(veggies)
       }

       // do we throw the veggies error at end of scope if !someCondition?
    } catch {
       // do something
    }

Alternatively you could have the "destructor" for the Future trap if the enclosed value throws, but I personally don't think that is an acceptable user model - it would be a huge foot gun.

That said, I agree with the last line of the section:

Another potential idea here would be to allow omitting await inside the initializer of a async let if it is a single function call, however do require the try keyword nevertheless. This at least would signal some caution to programmers as they would have to remember that the task they spawned may have interesting error information to report.

The functional issue here is that the destructor of the future throws when it goes out of scope if it hasn't been try awaitd dynamically. With Ownership we will have explicit destructors, and will have to decide if those are allowed to throw (C++ precedent says that is a bad idea, but this sugar proposal is forcing the issue). The best way to model this that I can see is to require a try on the let declaration - that makes it clear that the scope can be exited and composes with other try marking checks that depend on a syntactic marker. This would give you:

       // The try here means the dtor for veggies can throw?
       try async let veggies = chopVegetables()

alternatively we could invent a modifier on try, perhaps try(dtor) async let veggies ...

The big issue is that I don't see how to evaluate the tradeoffs here without usage experience.


Zooming back out, I'm in favor of addressing this problem, but I have two meta concerns:

  1. I prefer smaller features that compose together instead of mega features that pack a bunch of different things together into one specific language feature. We have a lot of values that want to be on-stack-not-escapable, so we should solve that problem. We have property wrappers already, so we should use them. If there are other things that "need" to be further polished off this sugar proposal then we can investigate them one at a time, instead of as a big bundle.

  2. This proposal appears to be trying to eliminate as much syntax as possible -- even though that syntax is /load bearing/. Swift strives for clarity and predictability, not for minimality of syntax.

-Chris

33 Likes

I'm just considering this portion of the proposed alternative in isolation:

@Future let meat = { await marinateMeat() }

Wouldn't this effectively create two ways to write an async function:

func marinateMeat() async -> Meat

Conceivably, if Future now exists as a type, couldn't I also write:

func marinateMeat() -> Future<Meat>

Would there now be two ways to do the exact same thing?

I agree – I’ll ask the proposal authors to move this section to the alternatives considered section. For the purposes of the review, though, the proposal does take a stand, as that section describes, so we should just treat this as another “alternatives considered” entry.

1 Like

That's fair, let me move it to represent our stance on this -- that awaiting "always" does not work well. The verbosity explosion is too high.

We worked out a middle-ground approach with Kavon that we'll post in this review thread for consideration.

1 Like

Yes, that is the whole idea of this proposal - it converts async'ly produced values into a future, allowing you to conveniently spawn multiple subtasks and rejoin them in an ad-hoc way.

A more general discussion we should have is "what is the Result equivalent for async and what is it called". We need that type, and having it would make this sugar feature much easier to reason about. This concept is commonly called a "Future" or "Promise" in existing systems.

-Chris

2 Likes

@kavon and myself made some time to discuss the concerns and tradeoffs in depth and arrived at the following design that we're quite happy with and would like to put forward for consideration.

We both have spent considerable time working with async let and, personally, feel like this strikes the right balance between between the "always force await" and "never force await" extremes that this proposal has explored so far (initially "always" and now "never" forcing awaits on async let bindings).


We think there are two points of tension in this proposal with code like this:

let g: () async throws -> T = ...

func f() {
    async let x: T = g() 
}

The problems in the above are:

  1. A throwing initializer expression for the async-let does not throw during the binding, but during the use of the bound variable.
    1. Writing try around that expression is confusing since it wrongly implies a do/catch around it would have a chance to catch an error but it never would.
    2. Not stating anything about the possibility of thrown errors is also undesirable as it makes it hard to realize detailed error information might be available, but has been silently ignored.
  2. Implicit cancellation in the example above means that the task is essentially created and then immediately cancelled, which could result in hard to notice, learn and remember "why isn't my code doing anything" situations.
    1. We want to discourage the use of fire-and-forget async-let bindings, because they are not forgotten. This immediate cancellation applies whichever way the rule about when cancellation happens: when leaving the scope in which its lexically defined, or the point of last use.

Proposal: disallow un-used async let declarations

There is one simple rule that can solve both of these problems: all async-let bindings must be used at least once. This rule is equivalent to "unused variable" detection in present day Swift, but should be escalated as an error for async let bindings.

A consequence of this is that async let _ = ... bindings should be disallowed. This is good, because rarely would such binding achieve the expected effect: such async let would immediately cancel the resulting task, yet the result of it would always be awaited for implicitly at the end of the scope in which it was declared. This is not a short-hand for fire-and-forget, while it might look like it is! The proper way for fire-and-forget operations is to express them using an un-structured task, like so: Task { await fire() }.

How does this rule solve the above concerns? It would result in code like this:

func f() {
    async let x: T = g() 
    try? await x
}

or

func f(maybe: Bool) {
    async let x: T = g()
    if maybe { 
        try? await x
    }
}

Requiring at least one use has these benefits:

  • It exposes to the programmer the fact that the initializer expression throws, but only at its use site. Thus, the try only appears at the location where the error may be actually thrown.
  • The programmer expresses that there exists at least one code path in which the task is not cancelled. Of course, this doesn't guarantee that the explicit await of that task will ever be dynamically reached.
  • It discourages the use of async-let for fire-and-forget tasks, since you cannot forget about it. Thus, in the example above, it becomes apparent that the await is happening immediately after the async-let, so the async-let is not needed at all.

This rule has one downside that we can think of: a Void-returning async-let requires you to await a void value. If many async-lets are in the same scope, it can be annoying to do this. But, the visual overhead would be at most one statement at the end of the scope that looks like this:

{
    async let a = ...
    async let b = ...
    // ...
    async let n = ...

    // some other useful work

    guard cond else { 
        // no awaits needed here,
        // and we still get cancellation!
        return
    }

    // even more useful work

    try await (a, b, ..., n)  // <- one-line overhead
}

On the other hand, now the programmer has no chance of accidentially allowing these tasks to be cancelled. We also think that Void async-lets may be somewhat of a bad pattern and these may often better served with fire-and-forget Task{} or explicit TaskGroup operations, after all, let declarations should be optimized for carrying actual values and not purely for scheduling of Void operations.

-- Kavon & Konrad

19 Likes

I was considering that async let x = foo() is effectively sugar for let x = Future { await foo() }, (with extra features to ensure that it doesn’t escape the scope and you only have to await it the first time you do x.value). In this sense the async before the let does mean ‘capture the effect in this variable’.

We already have the equivalent for the throws effect - Result:

let y = Result { try bar() }
let z = try y.get()

equivalent to

throws let y = bar()
let z = try y

I’m not suggesting it’s worth doing this - just that putting throws before the let would be consistent.

7 Likes

I agree that the async isn't the effect meaning on the right hand side of the =, where a child task is spawned. However, in terms of the variable itself, it is effectively attaching the effect to the variable, because you have to use await to then access the value from the variable.

1 Like

I would be in favour of this change since it seems to solve the issues that are being discussed.

I also don’t really like the alternative of making a Future here. We’re gonna have a hard time explaining why you have a future type than can’t escape. Instead using this nice async let syntax somewhat sidesteps that discussion entirely.

4 Likes

This sounds good to me, but what are the semantics if you exit a scope normally without waiting? Should that cancel any un-awaited async lets, the same way that throwing would?

1 Like

An early return, right? Like in the example we have in the last snippet:

    async let a = ...
    async let b = ...
    // ...
    async let n = ...

    // some other useful work

    guard cond else { 
        // no awaits needed here,
        // and we still get cancellation!
        return // cancel a, b, ..., n and await on them all
    }

    // even more useful work

    try await (a, b, ..., n)  // <- one-line overhead

yes, we would expect a, b, n to be cancelled and awaited, the same way as a throw in the same position would.

1 Like

If an async let is required to be awaited at least once, does the result of that await also need to be explicitly discarded?

I.e. in this

Should it be _ = try? await x? This would be consistent with the normal Swift rules about discarding the result - because x represents a deferred call to g().

I understand that this is a concurrent binding. I’m just not sure what the difference is in practice between “async let is a concurrent binding” and “async let transfers the async effect from the call to the variable”.


Either way, this feature only makes sense to me as designed if it can be understood as the effects being transferred to the declaration. That’s a mental model I can grok, and something that I think is teachable. That suggests that the declaration should be async let for non-throwing functions and async throws let for throwing functions. (And it also suggests the existence of a throws let which executes a throwing function but delays the throwing, and requires try when used, i.e. a sugared Result with the same semantics as this.)

If that mental model is wrong, then this feature is probably misnamed, and should be named something else, like future let.

4 Likes

This should follow typical Swift rules.

Specifically here, I think it might want to be equivalent to await g() in the sense that it depends on whether or not the function is marked @discardableResult or not if this should trigger a warning or not.

Either way, it should not be a compile error, but a warning to write that await x - consistently with how such code would be treated today.

1 Like

I don't know whether somebody mentioned it already but for a partial application of a throwing func no try is necessary for the let in actual Swift:

func f() throws -> Int { 42 }

let f1 = f // no try or throws here

try f1()

All you’re doing here is storing a reference to the function in f1. It doesn’t actually execute f until the last line. throws let f1 = f() would execute the throwing function immediately, but delay the throwing until you access f1 with try, akin to capturing it in a Result. Just like async let executes the function concurrently, but delays the waiting until you access the value.

1 Like

I already know that!!!

I haven't tried a preview of Swift and therefore I don't know what the type of an async let is, but for a moment, as a thought experiment, let's assume that it is just like a partial application. To get the value only a simple () would be sufficient, no need for an await, it is implicit:

func g() async throws -> Int { ... }

async let g1 = g() // assumed type: g1: () throws -> Int

print(try g1()) // no await necessary here

If I am understanding this approach and all the concurrency proposals correctly (I'd be surprised!), one thing I like about this approach is the potential API design it might unlock.

For one, could a developer treat the property wrapper like a task handle and do things like cancel tasks? e.g.:

@Future let meat = { await marinateMeat() }

// ...later:
$meat.cancel()
if $meat.isCancelled {

If that is a possibility, perhaps group wrappers could be introduced, generic over the result, where the task group be passed into the closure? e.g.:

@GroupingFuture<CookingTask> let cookingTask = { group in
    // spawn three cooking tasks and execute them in parallel:
    group.async {
      CookingTask.veggies(try await chopVegetables())
    }
    group.async {
      CookingTask.meat(await marinateMeat())
    }
    group.async {
      CookingTask.oven(await preheatOven(temperature: 350))
    }
}

// later, cancel this group of tasks
$cookingTask.cancel()
2 Likes

I agree with this, except it should be a warning IMO. The reason is if you temporarily comment a block of code later in the function you probably don't want the async let above to become a hard error. Or if you are writing the function, it'd be a bit harsh for Xcode to show an error as soon as you've written the async let line.

async let _ could be an error though.

3 Likes

I agree on the above. The autoclosure semantics currently proposed should be explicit regarles of how we resolve the left side of the expression.

this is more of a future/promise so we should either call it that or something close to that. async is too overloaded.

1 Like