SE-0317: Async Let

In those terms, then, I'd state my reasoning like this:

Typically, the local concurrency of async let x = is not important (because that portion of the code consumes typically very little time and doesn't benefit from parallel execution), but local asynchrony is important (because that allows more than one non-local concurrent task to proceed in parallel).

Even recognizing that the mistake here was due to a proposal-reading race condition (ha!), I would venture this confusion would not happen if the alternatives were spelled as follows:

Task { await work() }

group.childTask { await work() }

@ChildTask let result = { await work() }

Just saying. Not that “child task” is the ideal name here — just that a better name could help.

Agreed. (I hope we can all agree that whatever the name is, it should be consistent between the unsugared and sugared forms.)

Yes. It’s worth noting these aren’t equal valid but competing readings of the proposal: 2 is correct, whereas 1 is basically wrong unless you squint pretty hard. This becomes clearer in the desugared form, using my alternative name from above:

await withTaskGroup(of: Widget.self) { group in  // [a]
  group.childTask {      // [b]
    await makeWidget()
  }
  doInterveningWork()
  useWidget(await group.next()!)  // [c]
}

Note that there is no await at [b]. Spawning the child task doesn’t introduce the async effect. The async effect doesn’t appear at all as we create the task group until we get the value, at [c].

The effect then propagates up the call chain at [a]. Why does the withTaskGroup itself have an async effect in the first place? Why must we await it? Not because it spawns child tasks! No, it’s because it awaits its child tasks’ results (or their cancellation).

This all becomes less clear if we use group.async, and murkier still with the proposed sugar where async is in fact a keyword:

// ([a] is now implicit, and migrates to the enclosing block)
async let widget = makeWidget()  // [b’], corresponds to [b]
doInterveningWork()
useWidget(await widget)          // [c'], corresponds to [c]

Does the line [b’] introduce the async effect? In other words, is there a suspension point at that line of code? No! That line of code that says async is synchronous.

We can try to make reading 1 make sense if we squint and say, “The let on line [b’] carries a deferred async effect that appears later when you use its value, sort of the same way an async function isn’t async until you call it.” That’s a bit garbled, though; it’s really the hidden value — a child task — and not the variable itself that has certain operations that have the async effect.

It would be more accurate to say that async let places the value inside another hidden value that’s responsible for spawning the child task now and await its result later…a wrapper for the property, if you will….

2 Likes

But programmers are not required to spell it like that, and it's unlikely that they will, because this would be equally valid:

@Future let b = { await self.doThing() + makeString() } // bind
// ...
await b // use

Later on, if there is a bug during the test2 method with an unexpected task suspension, now they have to discern between two awaits: recognizing that one is in a closure of test2, so the suspension isn't actually happening at the binding; and the other at the use of b, which is the real place where there would be a suspension of the task that invoked test2. The omission of await around a single-expression initializer for an async-let just reduces that noise:

async let b = self.doThing() + makeString() // bind
// ...
await b // use

So, I don't understand how { await ... } is load-bearing. It requires thought to exclude the await when trying to narrow-down an unexpected suspension, and thought to ignore the closure when determining the type of b, since it is not (X) -> Y, but simply Y. That extra syntax just seems like it will be noise once people learn about async-let.

The reason why I added a use of the async-let binding, is because I think the moment we have at least one use, any lingering confusion about what is happening disappears. Hence the earlier posts by @ktoso and I about this principle.

3 Likes

Has any consideration been given to not including let in the declaration? Since async lets are really their own thing (and the proposal's comparison to normal let bindings aren't particularly convincing to me given the numerous differences), might it make sense to instead do something like (to borrow an example from above):

async widget = makeWidget() 
useWidget(await widget) 

and make async a keyword by itself in this context? If we didn't want to overload async in that way we could equally use something like task/future/deferred.

On a different note: I'd be slightly opposed to making the syntax for fire-and-forget tasks heavier. In one domain I work in (game engines/entity component systems), it's common to dispatch concurrent tasks that mutate some state in-place, and having to have a list of await (systemOne, systemTwo, systemThree, systemFour) at the end of functions would feel clunky. Cancellation also isn't particularly useful in that context, so the early cancellation wouldn't be an issue – the tasks would just ignore it.

1 Like

I think the proposal addresses a significant piece of the concurrency puzzle, but I do share concerns about the design as proposed. I've studied the proposal in its various stages of development and mulled over the version as proposed for several days before summing up these thoughts. (Though hastily composed for lack of time; please forgive any sloppiness in writing or in the thinking behind it...)

I'll start with an issue that hasn't been brought up at all in this thread:

Autoclosures

The proposal states:

[...] in order to make it explicit that the await is happening inside the closure rather than before it, it is required to await explicitly in parameter position where the auto closure is formed for the argument:

func greet(_ f: @autoclosure () async -> String) async -> String { await f() }

async let name = "Bob"
await greet(await name) // await on name is required, because autoclosure

I understand the impulse here, but this rule is unprecedented in Swift.

For the most part, an autoclosure is appropriate only when understanding the "inside-the-closure"-ness of the argument wouldn't trip up the end user. Otherwise, it'd be more appropriate for the function to take a "garden variety" (i.e., non-auto) closure.

I'm thinking specifically of binary operators such as && and ||, for which the use of @autoclosure on the right-hand side permits short-circuiting to be implemented in Swift itself without magic. Users don't have to know anything about autoclosures to understand how to use an operator such as &&.

With this proposed rule, however (unless I'm misunderstanding), users would have to await explicitly on the right-hand side of && if they're looking to evaluate [the result of] an async let, but not on the left-hand side. This would leak a weird interaction between autoclosures and asynchronous programming to users who shouldn't have to think about this at all, and I think it's a tell that the considerations at play here aren't properly balanced.

(Of course, we could take inspiration from inout and allow operators to have a special exception here, but I don't think the analysis above is really any less surprising for ordinary functions.) In brief, I'd argue that the raison d'etre of @autoclosure is to allow the "inside-the-closure"-ness of an argument to melt away at the use site, and the proposed rule that would specifically call it out when awaiting an async let undermines that well-defined purpose.

Implicit context

No one has mentioned it yet I think, but I have to assume I'm not the only one to have found this example jarring:

assert(Task.isCancelled) // parent task is cancelled
async let childTaskCancelled = Task.isCancelled // child-task is spawned and is cancelled too
assert(await childTaskCancelled)

That the authors felt it important to comment on the meaning of Task.isCancelled is telling. Let me rewrite without comments and refactor just a tiny bit--

let a = Task.isCancelled
assert(a)
async let b = Task.isCancelled
assert(await b)
// Wow, these refer to *different* tasks.

This seems to be really the same problem as that which others have struggled with regarding the elided try: not requiring it seems inconsistent in some ways, but require it and all of a sudden you've got a problem because you can't surround the line with a do block and actually catch the error.

It seems to me that in both cases (the Task.isCancelled example and the question regarding try), the absence of braces actually demonstrates how they would be load-bearing if they existed, a visual marker of a different context to help users literally see what's going on. Consider:

let a = Task.isCancelled
async let b = { Task.isCancelled }
// Now it's clear that the second `Task.isCancelled` is in a different context.

func f() async throws -> Int { ... }
do {
  // If we required `try`, then you'd write...
  async let c = try f()
} catch {
  // Can't catch the `try`, but why?
}
do {
  async let d = { try f() }
} // ...well of course you can't catch the `try`.

I suppose that there is precedent for the brace-less spelling proposed for async let in lazy let, which also requires no braces and implicitly defers evaluation of the right-hand side. But unless I'm mistaken, that spelling is not susceptible to the issues seen here.


Overall, I think the proposal is right to look for an ergonomic way of spawning child tasks, but these two issues suggest to me that some fine-tuning is necessary in terms of what's elided and what's required by the sugared syntax proposed.


Non-suspending await

The proposal states:

[...] It might be possible to employ control flow based analysis to enable "only the first reference to the specific async let on each control flow path has to be an await ", as technically speaking, every following await will be a no-op and will not suspend as the value is already completed, and the placeholder has been filled in.

I agree with an earlier comment that it would be good to know more about how much and why the feature would be held up if we worked towards this end. It seems to me that we run the risk of diluting the meaning of awaits if the preferred way of spawning child tasks causes await to be used pervasively where all but the first use can't actually suspend.

Some control flow-based analysis (or at least, from the user perspective, something that feels very much like it) already exists in a common scenario in Swift: inside an initializer, the compiler is perfectly aware of when every stored variable has been initialized, and it is not shy about making that known :slight_smile:

A version of Swift in which the compiler is not shy about making it known that it's keeping track of when awaiting an async let can actually suspend or not seems very much in keeping with this.

14 Likes

The naming here unifies all of the pieces of structured concurrency around async: async functions, group.async and async let provide normal control flow for asynchronous and concurrent code (try, throw, return, if, etc. all do the same things as in synchronous code), obey normal lexical scoping rules (e.g., child tasks never persist beyond the scope in which they are introduced), need to be await'd to get results, inherit important context like priority and task local values, and rigorously . async is the tool you should reach for for structured concurrency, but that's what gives you a programming model that's easiest to reason about and is the most optimizable.

Unstructured concurrency involves working with task instances, so new unstructured tasks are created with a completely different syntax that emphasizes task creation: Task { ... }. With unstructured concurrency, you get none of the affordances to help manage concurrency well. We absolutely need to have support for unstructured concurrency, but it's not the first thing one should reach for.

The naming emphasizes that the core distinction is Structured vs. Unstructured. Renaming async let or group.async means that we end up with more than these two categories, or multiple bits of divergent terminology within the same category (e.g., with async functions and group.spawn and Task.detached, why would I have any reason to think that spawn is part of structured concurrency?). So while it might make some things more explicit (yes, group.spawn spawns a new task of some sort!), it loses cohesion within the more important groups.

I also think it's fairly hard to justify that position that group.async { ... } is unclear about the fact that it's initiating new work running asynchronously from the call site, given that we've been using DispatchQueue.async to do exactly this for many years.

Doug

10 Likes

I think it got lost because I made these comments as part of a very long post, but the property wrapper usage above doesn't actually fit well with the design of property wrappers (or properties in general). When you have a let or var, the type of the pattern and the type of the initializer expression expression are supposed to match:

let <pattern> = <initializer-expression>

Property wrappers codify this notion intentionally: the wrappedValue property and init(wrappedValue:) parameter type are meant to match. This property-wrapper formulation being discussed:

@async let foo = { expensiveComputation() }

violates that basic principle: foo is of type T, but the initializer expression is of type () -> T.

Only if those features are desirable and generalizable. My laundry-list of new features we'd need to invent contains some that are almost certainly not something we would ever want (e.g., async deinitializers).

Your if statement argument is an odd straw man. This isn't "eliding braces", it's "make the initializer type line up with the pattern". If I have this:

func f() -> Int { ... }
let x = { f() }

should I expect x to have type Int? I sure hope not, so why would I assume that for

async let x = { f() }

or

@Future let x = { f() }

?

AFAICT, there is exactly one thing you can put before let x = y that would make the types of "x" and "y" not line up, and you complained about as part of this same thread: if let. if let lets the types of x and y differ because the former is the unwrapped form of the latter's optional type, a little convenience that we now regret. Requiring braces on the right-hand side of async let or @Future let makes precisely the same design mistake.

So, I have a pretty big problem with this paragraph above. I wrote a fairly detailed write-up covering a number of problems with the property-wrappers approach, and it got ignored. We did explore the property-wrappers approach in depth because this felt like something that should be possible with property wrappers. It's no accident that Swift 5.4 got support for property wrappers on local variables: it was a step toward using property wrappers here. We redirected effort toward completing effectful properties ahead of more foundational concurrency-related changes because it was another step toward using property wrappers here. We explored this path, and our initial intuition was wrong. async let is different enough and important enough to require its own language support; the review proposal looks like the first pitch because the first pitch was close to the right design.

I don't know if any of those things improve your "confidence", but at the very least you could respond to the specific points that have been made against the use of property wrappers rather than asserting that those points haven't been made.

Doug

13 Likes

This would be a great keyword to use if it didn't already have a different (very specific) meaning in Swift

1 Like

I actually think this is a valid point. I think there is an alternate model where async (or whatever keyword we decide on) becomes a type modifier that can be inferred. Thus you could have:

func myAsyncFN() async -> Int {
    return 5
}

let x: async Int = myAsyncFN()
let y = await x

In many cases you could just infer the type:

let x = myAsyncFN() // x has type `async Int`

Note that this also mirrors nicely with how throws gets passed around in types:

let xFN = myThrowingAsyncFN // This has a type of `() async throws -> Int`

(Note: I am generally not in favor of async throws let because it feels unnecessary most of the time, but I could be talked into it as a type modifier (e.g. let x: async throws Int), since it would be inferred most of the time and would thus create less extra noise)

Because the type is async Int you can't pass it to anything asking for an Int because they aren't the same type (just like you can't pass Int? to an Int parameter. Trying to do this would give an error telling you to call await. Attempts to return it wouldn't work because we can disallow async modifiers on return types, so you can't write func myFunc -> async Int without getting an error. I think you might want to pass it as a (non inout) parameter, but let me know if that breaks the structured guarantees.

Overall, I feel like a type modifier composes very nicely with the rest of Swift!

The main issue with the type modifier is with the automatic cancellation if the variable is never awaited on, since someone might try to use the func for side effects, and there is no indication of the cancel behavior without the marker. I think that async let may have similar issues anyway, and I think we should give a little more thought around automatic cancellation.

All of that said, I find async let to be very intuitive overall. I was able to get the idea instantly!

I think @Future/Future<T> (or whatever we call it) is much better at creating/representing detached tasks, since it affords all of the things you would want to do with a detached task (and most of those obviously aren't things we would want for a child task). I would expect property wrappers to behave like normal property wrappers, and if some suddenly have restrictions that others don't, it is a recipe for confusion. For child tasks, I find async let an order of magnitude more intuitive for the behavior it would have.

I think this solves most of my fears about automatic cancellation!

3 Likes

The premise here seems to be that a mental model of the sugared form must be a valid model of the desugaring. I don’t accept that; in fact, I think it’s contrary to the purpose of high-value sugaring, which is abstraction. C programmers don’t usually think about for loops in terms of if and goto, or anonymous scopes and while, any more than a pianist thinks about the harmonic nodes of individual strings.

It’s possible, and sometimes useful, to think about async let in terms of hidden task groups and further-hidden task handles inside the groups, but I think it’s pretty clear that this isn’t the intended day-to-day mental model.

7 Likes

That’s a very good point. And I know the braces on the RHS of the assignment were one of Chris’s main wishes.

My apologies for missing this before!

Well sure, because for all those years, async wasn’t a keyword in the language.

I’d be equally satisfied to preserve async let / group.async and instead rename the function declaration modifier to awaits, which actually describes its meaning much better (and aligns it with throw / throws):

func f() awaits {
  async let foo = work()
  otherStuff()
  grobulate(await foo)
}

…but it seems like folks want to stick to the terminology precedent of Javascript and Python.

await is part of structured concurrency, but it isn’t named async.

To be clear: I like structured concurrency a lot, and I like the design principle of making it both the easiest thing to reach for and its own safe(r) syntactic island. I’m in favor of aligned terminology here. I just think the word async is getting too overloaded.

Revisiting / expanding my chart from above:

| | | Part of Structured Concurrency | Introduces concurrency (creates a task) | “Suspension point here” | Getting result requires await later | Enclosed code may jump exec context |
|----:|--------------------------|:-:|:-:|:-:|:-:|:-:|:-:|
| (0) | await | :white_check_mark: | | :white_check_mark: | | | |
| (1) | async func | :white_check_mark: | | :white_check_mark: | :white_check_mark: | | |
| (2) | async let | :white_check_mark: | :white_check_mark: | | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| (3) | group.async | :white_check_mark: | :white_check_mark: | | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| (4) | Task { … } | | :white_check_mark: | | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| (5) | Task.detached { … } | | :white_check_mark: | | :white_check_mark: | :white_check_mark: | :white_check_mark: |

It’s not at all clear to me that 1-3, and only 1-3, must have the same name.


Addendum: It occurs to me that a fairly strong piece of evidence that “async” means different things in 1 vs 2–3 is that s/async/awaits/ works nicely here and makes perfect sense:

func f() awaits

…but doesn’t make sense at all here:

awaits let foo = work()

To be clear: I’m sure the renaming of the function modifier to awaits is just completely off the table at this point (though I’ve now managed to convince myself it’s a brilliant idea and will make that mental substitution for my own benefit). My point is merely that the term substitution helps clarify what I see as the two very different meanings of the modifiers in those two contexts.

9 Likes

Yes, some sugar is abstraction. And some is just sugar, pointedly not introducing new abstraction surface.

Abstraction is not the characteristic that distinguishes high and low value sugar. For example, you can get quite far in Swift without ever typing Optional or Array, but I certainly wouldn’t say that ? or […] are different abstractions from their unsugared counterparts. The docs introduce ? and […] using names “optional“ and “array” from the get-go, and those are the words programmers use to refer to them. The sugared forms don’t introduce or drastically reshape the API surface of the named form of the types; they just give convenient access to a subset of it. Sometime there’s some dramatic hidden glue (optional chaining is nontrivial!), but even that is still gluing together familiar pieces of the same abstraction without really hiding it. When you do finally encounter Array or Optional or .some in the wild, I’d say that’s a learning bump, yes, but not a jump down one layer of abstraction. It’s just expanding one layer of syntax.

I see this feature (async let as sugar for group.async) as being more akin to the ? sugar for optionals. You seem to be viewing async let vs group.async more like the difference between Array and Unsafe[Mutable][Raw]BufferPointer. It’s a good question which one is the better analogy here!

Well, that’s the question, isn’t it? I’m not sure that is pretty clear. (See for example the visibility of Task.isCancelled in code that uses only async let.)

This is an important design question: over the course of progressive disclosure, as a developer learns about Swift’s concurrency model, what do we want to feel like a little learning bump vs. crossing a high protective wall?

When a developer who is used to async let wants to introduce an arbitrary number of structured concurrent tasks, should that involve learning entirely new terminology? Or should it unfold from the terms and mental model they already have? Should that feel like climbing up a step, or crossing a high wall?

What about when they move from structured to unstructured concurrency?

My gut feeling is the first should feel like just a step, the second like a high wall. That’s debatable!

1 Like

We could rename async let to Task { … } and rename Task { … } to Task.unstructured { … }. That way everything that creates a task actually has "Task" in the name somewhere and it's now clear when things go unstructured. And we get the braces.

func makeDinner() async throws -> Meal {
  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))
}

Apologies if these are dumb questions, but I do not yet understand concurrency. Given the code in the proposal, exactly when are the functions in the async lets called? At the point of assignment, or not until the last two lines? If it is the former and the chopVegetables() function throws, does makeDinner() throw immediately regardless of where we are in the execution, or is that error "waiting around" until the try veggies happens to be thrown?

1 Like

Right away when the async let line is encountered.

It's waiting around until the (first?) access where you need to use try.

the existence of autoclosure muddles this metal model for me. I would expect that to be supported.

2 Likes

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