SE-0317: Async Let

In both cases, the result is something that must be accessed via await. I think that's close enough to justify using the same word.

I was also initially worried about this, but after thinking about it more, writing try or await on the initializer for b here is not a good solution: it adds confusion, because that specific statement is not where the parent task (responsible for executing the function) will observe those effects. As someone else has already mentioned, wrapping it with do-catch would be incorrect, and can lead to more confusion when the error that they thought they were now catching is not being caught at all:

    func hi() {
      do {
        async let b = try boom() 
      } catch { ... }
    }

The main tension here is that the design proposes that we drop errors from unawaited async-let bindings. Keep in mind that those async-let bindings are also implicitly cancelled by then. So, it makes a lot of sense to preserve the behavior of dropping those errors, because people are likely to use try Task.checkCancellation() to throw a cancellation error from the async-let task, to stop their work early. Or, in real code, they will otherwise not care about the error from that unawaited async-let, because they're already returning a valid result (or throwing some other error) from the function.

This is really the only situation where this problem comes up, but I think that it should be an error to have a totally unawaited async-let: the rule would be that you have to await it in at least one path in a function, which also forces you to acknowledge its throwing-ness.

Async-let's its implicit cancellation behavior means its not a true fire-and-forget, and the compiler should prevent usages like by using the rule I've suggested (or something else). The async-let task will be implicitly cancelled before being awaited when its lifetime ends, so if it's executing a call into some other API that actually checks for cancellation, then the code in that task is not running to completion! It would be better to use one of the other kinds of unstructured tasks for this case, because it's not really fire-and-forget if you're (implicitly) awaiting its completion.

4 Likes

I agree; I'm overall OK with (but not thrilled about) using async with these two different meanings: effect and task. I am mostly just uneasy about adding any more confusion to that set-up by having an effects specifier like throws in async throws let x = ... too, since it could suggest that the async in there is the effect meaning, and not the task meaning.

How are these different in practice?

A few notes to the above comments:

  • yeah, async let is NOT fire-and-forget. It's more like a "fire, but don't forget" :wink:
  • I agree that forcing the async let x = try ... is weird because indeed a catch surrounding this would never trigger so it muddies the meaning of try.

The idea that people propose with async throws let is quite interesting... It is true that we're expressing an effect on the property after all, isn't it? In the same way as a computed property may be throws and async due to SE-0301: Effectful properties

I really wonder if assuming I'm not interested in errors is a viable approach. It feels quite different from the usual "in your face" with having to cover code with try and throws in Swift.

@kavon Your proposal to always force an async let to be awaited on at least some path is interesting. but a bit weird to me. It would indeed force the try to appear "somewhere" I suppose...

In either case, marking the "async throws let" or forcing the appearance of a try somewhere, at least there is some way to notice the potential throwing. I personally quite like the throws let-effect idea, it seems consistent with the rest of the language -- a declaration (here, a variable declaration) specifies effects -- so a function does with throwing and async, and an async let might as well... :thinking:

2 Likes

Well, in practice, this is just an asynchronous program (not concurrent; no tasks are created):

func f() async -> T { await E }
let x: T = await f()
G // "some other stuff"

and E will always evaluate before "some other stuff". But this is a concurrent binding:

// inline f, move it's effects, 'async', in front of let
async let x: T = await E  // might be a warning that `await` not needed
G // "some other stuff"

so "some other stuff" now can happen before E completes. Of course, if you actually are required to use the bound value of an async-let at least once, then this non-equivalence becomes a bit more clear when you are forced to write await around the access to x in the second version, but not the first.

Otherwise, it's hard to speculate how folks might become confused, so I can't think of a better answer. When we were discussing effectful properties, the design was centered around putting effects specifiers close to the accessor, not in front of the let. The reasons for why I avoided putting async or throws in front of a var doesn't really apply here:

Since async-let is meant to be a short-hand, it doesn't really make sense to have an accessor like effectful properties do. Requiring the right-side of the binding to be a closure () async -> T and then the variables type is just T was also thought of early on, but it turned out to be unpopular, I think.

1 Like

It seems a bit confusing to me that with task groups child tasks are only cancelled if an error occurs, but with async let child tasks are implicitly cancelled at end of scope.

I wonder why that is? If it's because child tasks couldn't otherwise be selectively cancelled, I guess we could allow e.g.

async let myValue = ...
$myValue.cancel()

If we really want this implicit cancellation only for async let, then I would vote for implicitly cancelling as soon as the property cannot be awaited anymore, like @michelf mentions above.

Perhaps a bad example, but this does look like scoped futures, and I don't think you would expect those to self destruct at end of scope. (I realize this is voluntary.) The whole syntax seems to indicate that these are value generating things, and that their values are important. Ignoring their values and just letting them be implicitly cancelled does not seem like the right way to use this feature.

2 Likes

Responding to a few comments upthread, I will also write up a formal response:

I agree. I had to double take reading this code in the motivation section:

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

  let dish = Dish(ingredients: await [try veggies, meat])

My question is "what is that try doing on veggies? I had to go read the definition of chopVegetables to figure out what was going on. I think it would be much more clear and composable if we required:

async let veggies = try chopVegetables()

Rationale: async arguably makes sense to swallow an await, but not /all/ effect modifiers.

I completely agree with this observation, this is muddying the meaning of the word async, blending in a different meaning than the effect modifier. This is not creating an "asynchronous let" - that doesn't even make sense, constants are not asynchronous! This is creating a future let or deferred let or something like that. In fact, we're creating something much closer to:

let x : Future<_>

If the design goal was to tie this to effect modifiers, then we should also be discussing adding try let x = someThrowingThing() for deferred error checks.

Right, this is pointing out a third problem - that the delineation between the "body" of the task being created and the enclosing function is not clear. We really should have braces mixed in here to make it clear that this is both a 1) different scope, and 2) a separate execution context that is being passed down.

This matters a lot because you have @Sendable checks, actor self behavior changes and other things that happen at task boundaries. This needs to be visible in the Swift code IMO.

Yes, sort of. There is a bigger issue here that the declared type of the value is being modifier. For example, if you have async let x : Int = stuff() that the type of x is not an Int, it is Future<Int>, and the accessor to x provides a projection with a modifier type that isn't the declared type. We have a way to do this -- property wrappers.

-Chris

3 Likes

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().