SE-0317: Async Let

Hello, Swift community.

The review of SE-0317: Async Let begins now and runs through June 3rd, 2021.

Note that this feature builds upon the structured concurrency proposal, the third review of which is happening concurrently in order to better discuss common aspects such as naming.

This review is part of the large concurrency feature, which we are reviewing in several parts. While we've tried to make it independent of other concurrency proposals that have not yet been reviewed, it may have some dependencies that we've failed to eliminate. Please do your best to review it on its own merits, while still understanding its relationship to the larger feature. You may also want to raise interactions with previous already-accepted proposals – that might lead to opening up a separate thread of further discussion to keep the review focused.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager. If you do email me directly, please put "SE-0302" somewhere in the subject line.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at:

https://github.com/apple/swift-evolution/blob/master/process.md

As always, thank you for contributing to Swift.

Ben Cohen
Review Manager

12 Likes
  • Is the problem being addressed significant enough to warrant a change to Swift?
    Yes
  • Does this proposal fit well with the feel and direction of Swift?
    I am uncertain on this:

I think that the variation of requiring try in an "async let" initializer would probably be beneficial, unless it would be confused as throwing immediately, and not when awaited.
The variation of requiring an "async let" to be explicitly awaited would also be beneficial, but may be incompatible with Swift's automatic choice of the deinitialization point.

As I said when "async let" was being discussed as part of structured-concurrency:

Syntactically I think it would be better to have async replace await directly in an "async let" statement instead of being lifted to be in front of the let .

This makes the requirement that async appear in an expression only where await can more obvious. The example given as:

func order() async -> Order { ... }

async let o1 = await order()
// should be written instead as
async let o2 = order()

Would instead be:

func order() async -> Order { … }
 let o1 = async await order()
// should be written instead as
 let o2 = async order()

for which the first is more obviously wrong.


Similarly for the example of async on patterns:

func left() async -> String { "l" }
func right() async -> String { "r" }

async let (l, r) = (left(), right())

await l // at this point `r` is also assumed awaited-on

When the async is on the rhs, the fact that l and r will be awaited simultaneously is apparent.

func left() async -> String { "l" }
func right() async -> String { "r" }

let (l, r) = async (left(), right())

await l // at this point `r` is also assumed awaited-on

The only problems I see introduced with this syntax are the fact that the required error with async var is harder to justify:

async var x = nope() // error: 'async' can only be used with 'let' declarations

To:

var x = async nope() // error: 'async' can only be assigned to 'let' declarations

And the potential similarity of the syntax with that for a "futures" API.
However, I believe this is resolved by having "async let" values not be @escaping, as proposed.

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?
    A not-so-quick reading
4 Likes

Overall I'm quite happy with what we're proposing here.


My primary concern is about the try omitting. I'll write it up here so it's part of the official review.

Currently we propose to:

func boom() async throws -> Int { 13 } 

func hi() {
  async let b = boom() 
}

and neither the await or try are forced to be spelled out on the boom() call. The omission of await is very nice, and makes sense for single expression async let initializers.

The omission of try has me worried. There is no indication to me as implementor calling boom() that it actually throws and I maybe would like to check that error. I fear this might lead to suprising "well it failed, but I also never awaited on it, so what's going on" behaviors.

This also plays weirdly with cancellation which we want to cause immediately when the b goes out of scope. If the boom had to be written as async let b = try boom() it would be a bit more clear that it might throw and that I might want to think about this when writing my program.

The counter argument here is that if we do await the value, we're forced to try await it only there. I have mixed feelings about either spelling of this to be honest:

async let a = boom() // not clear that it throws

async let b = try boom() // might seem like it throws "here"
// but actually this is equivalent to:
async let b_real = { try boom() }

try await a
try await b
try await b_real

Overall I'm leaning to b being the cleaner perhaps, but I don't love a floating around try that isn't really covered with a do. The braces make it clear in b_real but that ends up too verbose.

Not sure what the best course of action is, but the transparency of not knowing if an operation throws I found a bit weird when working with async lets.

I could be convinced that it doesn't matter I suppose, because try await in real code will always show up eventually when awaiting a value. Perhaps other more complex rules i.e. "if the value is not awaited on, the initialization must be = try in order to enforce that there is SOME awareness that the thing can throw...?

8 Likes

Would async throws let a = boom() be an option? This would then mirror how throwing async functions are declared. It would also be clearer when looking at the code that try would be needed when awaiting a.

Related to this, I’m wondering what would be shown when inspecting the type of a in an IDE (eg option-click in Xcode). This seems related to the possible future direction of how async let variables might be passed to another function (from pitch thread #3)

It would be useful to distinguish async Int (which requires await the first time it’s accessed) from async throws Int (which requires try await).

12 Likes
  • What is your evaluation of the proposal?

Very positive. For me this is one of the most important parts of the concurrency story. It's a clear indication that everything fits well together and I'm very happy that we landed on this syntax. Explaining it is very easy thanks to just moving an await from the right to an async on the left of the binding. It makes for a very nice symetry.

  • Is the problem being addressed significant enough to warrant a change to Swift?

Yes. Without this async/await only gives us sequential operations and having to write all the TaskGroup code for simple scenarios feels too cumbersome. Making it so easy to switch from sequential to parallel is great for exploration and refactors.

  • Does this proposal fit well with the feel and direction of Swift?

Totally. As I said I'm very happy how it looks. There are concern about the try, and they may be legit, but it feels to me that having to try on the awaiting place instead of the declaration makes total sense.

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

Not extensively enough to do a fair comparison.

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

I've been following all the concurrency proposals since the start and tried them on the recent snapshots.

1 Like

Yes, that’s just what I was thinking - I think it strikes the perfect middle ground. When using async let myself, having to mark try only when awaiting felt very natural, and very clearly conveyed the fact that the throwing happens at the await, and not the async let. But if we’re doing more of a “fire-and-forget” kind of async let, on which we never await, it would be weird if try were never marked anywhere. So I think what you’re proposing here is a good solution.

2 Likes

This would be perfect! It also has an important pedagogical benefit: you can then explain what’s happening as the effects async and throws being transferred from the call to the variable, which makes it obvious why you have to await and try the variable before you can use it.

4 Likes

At first I was concerned about omitting the try in the async let expression, until I understood that the try instead happens when the value is accessed. This makes sense to me because try indicates the site where exception handling explicitly happens, and it does not happen during the async let assignment.

I'll also add my +1 to the async throws let idea. It's a good way to flag a throwing expression without using try, and it builds on the parallels between async/await and throws/try.

2 Likes

Currently, it is required to cover every reference to a async let using the appropriate try and await keywords, like this:

async let yes = ""
async let ohNo = throwThings()
_ = await yes
_ = await yes
_ = try await ohNo
_ = try await ohNo

This is a simple rule and allows us to bring the feature forward already.

What are the obstacles to addressing this now? Also, I was surprised that this was not discussed in a bit more detail under Future Directions.

If you aren't concerned about try in async let, why would throws be necessary? The async in async let does not serve the same purpose as async on functions or await on statements, so I don't see why we would want anything like throws either.

1 Like

Because it's still useful to visually mark the throwing expression, and as I said I don't think try is appropriate there because it's not really where the "try" (the exception handling) happens.

1 Like

Except it’s not a throwing expression. The async there doesn’t mark an async function but the fact that the let is asynchronously set.

2 Likes

In my opinion, the most straightforward solution to this problem is requiring to await every async let declaration. This way, one must write try or try? or try! at the await site and the reader is aware of a potential error.

This would also leave the door open. If it turns out to be too bothersome to always await every async let declaration, this requirement can be dropped later. That would not be possible the other way around.

I would guess that in most use cases, this solution would be more or equally verbose as just requiring to await every async let. In addition, this would add throws as a second keyword besides try to scan for in order to find every possible point where an error might be thrown.

2 Likes

Thinking on this in relation to the different syntax I suggested, it would make more sense if, when async and try, async precedes try, unlike with try and await:

// proposal + use `try`:
async let b = try boom()
// my suggestion above + use `try`:
let b = try async boom()
// current suggestion + use `try`:
let b = async try boom()

This causes the try to be properly syntactically bound by async, as the errors are semantically bound by the child task.

Normally try indicates that something can be thrown, and that you can catch it. But this does nothing:

do {
    async let x = try doSomething()
} catch {
    // will we ever catch something?
}

We never await on x, so it will never throw. The catch is useless. My opinion is that it's better to not have a try where there is nothing to catch.


Overall I'm happy with this proposal. But I wish a task would be cancelled immediately when the control flow ensures it won't be needed anymore. For instance:

func test() async {
   async let result = startLongTask()
   ...
   if Bool.random {
      print(await result)
   } else {
      // implicit cancellation here
      print("skipped!")
   }
   ...
   // implicit `await result` here
}
12 Likes

There's also the problem that the word "async" in an async let does not mean the same thing as the effects specifier async on a function type. An async let creates a new task to run the code on the right-side of the equals, but a new task is not created when calling an async function. So, I think it would be a mistake to write throws, which is also an effect, next to that async.

1 Like

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?