SE-0317: Async Let

I'm not supportive of @magic approach, but Task.scoped{} Task{} Task.detached{} could align well with each other, TaskGroup.async{} looks inconsistent with Task-style concurrency primitive api.

This doesn't seem to be correct. The async on async let is not a signifier like await or try, it's still an effect, it's just an effect on the declaration rather than a function. In that regard I don't find it a misuse at all. In fact, the symmetry between the various uses of async make the feature rather easy to learn, rather than requiring new vocabulary for every slightly different use.

4 Likes

As a friend points out off-forum, my analogy to throws / try is broken: strictly speaking, await is the effects handler for async, and it already has a separate name. And the analogy further breaks down there: try eats the effect in question, whereas await propagates it upward.

My whole post, then, only stands as a qualitative analogy about the danger of overloading a term too much, not as a strict isomorphism between asynchronous code and error handling.

What’s really bothering me, then? Kavon said it succinctly:

As I understand the proposals at hand, we currently have this situation:

Creates a task Introduces async effect
(1) async on a declaration :white_check_mark:
(2) async let :white_check_mark: later in enclosing scope
(3) group.async :white_check_mark: later in enclosing scope
(4) Task { … } :white_check_mark: only if getting result
(5) Task.detached { … } :white_check_mark: only if getting result

In previous proposals where (4) was named async { … }, the meaning of “async” got really confusing. Now, IIUC, Task and Task.detached (and other functions that call them) are the only things that can create tasks without introducing the async effect into their enclosing scope, and are thus are the only bridge from non-async code into async code. Is that correct? If so, then we can at least say that async, whatever else it means, always indicates the presence of an effect somewhere in the current scope…though not always right at the point of use. Thus 1-3 must propagate up through the call chain until they hit 4-5. That’s clearer, at least.

What’s still bugging me is that we are using the same word for both 1 and 2-3. Does “async” create a new task or not? In particular, it muddies two things that should be clear:

  1. It should be clear that foo() async does not in fact create a child task when called. (Prior experience with Javascript et al should not be a prerequisite for understanding this.)
  2. It should be clear that 2-3 introduce task boundaries, as Chris wrote above:

I’m not sure I love either the syntax particulars or the word “future” here:

…but this general line of thinking does potentially address my concern.

6 Likes

This is interesting! It potentially addresses two concerns I had in the pitch phase:

1. Passing async lets as futures/promises

The answer might look like this:

@Future let thinger = { ... }
someHelper($thinger)  // passes a future; someHelper won’t block on thinger
                      // unless it needs the value

func someHelper(_ thinger: Future<Thinger>) { ... }

2. Mixing “async lets” with manually created child tasks

Here’s a start of a solution to that:

return try await withThrowingTaskGroup(of: ??????????) { group in
  for n in 0..<veggieCount {
      group.async {
        CookingTask.veggies(try await chopVegetables(n))
      }
  }
  @Future(group: group) let meat = { await marinateMeat() }  // tying async/future variable to a specific group
  @Future(group: group) let oven = { await preheatOven() }

  // …but wait, how do we get the veggies out?
  // What is the result type of this task group? Hmmmmm.
}

So not a solution, not quite, but at least a design space for one.

(Not proposing discussion of either of these; just saying that the property wrapper approach opens up future directions in a nice way.)

2 Likes

Ok thank you for clarifying, I guess the concern I was trying to raise is that I thought at least part of the async/await design was distinctly that you would never have to think about a Future/Promise.

func marinateMeat() async -> Meat
func marinateMeat() -> Future<Meat>

Afaict this is two nearly identical spellings for the same exact thing. Generally speaking that's not a bad thing imo ([T] being equivalent to Array<T> is a significant shorthand that most would agree improves usability of arrays), but in this particular case, I personally am not sure how async -> T is a significant usability improvement over -> Future<T> or vice versa.

This is why the suggestion to have Future and async is so confusing to me. Basically I'm asking "why do we need both?"

1 Like

This isn't quite right outside the narrow case covered by this proposal.

Tasks and the values produced by them are different in important ways. This proposal doesn't cover it, but you can have a single task that yields multiple values over time (generator style) and binds them as it goes. It is also fine to return multiple values that resolve at different times. For example, to put this into fictional syntax terms, it is perfectly fine to have something like:

func x() -> (Future<Int>, Future<String>)

Where there is a single task completes each future at different times.

This proposal doesn't provide support for these advanced cases, but I don't think that conflating these together in the broader lexicon of concurrency is a good way to go.

-Chris

2 Likes

We should emphasize that being able to pass off a future for a child task is an anti-feature. Allowing handles to a child task to escape the scope of their parent task would break the guarantee we want to make about child tasks, that their lifetimes are always bounded by a scope, and they stop executing and consuming any resources after that scope is ended. It could be interesting and useful to turn the Task handle for unstructured/detached tasks into a property wrapper—that could definitely reduce the Hamming distance between async let and unstructured concurrency when you find your use case has grown beyond the abilities of structured tasks, but your tasks are still "mostly" structured—but, by itself, async let shouldn't be thought of as sugar over futures, but as sugar over simple task groups. To that end, I think the syntax is justified, because it addresses ergonomic and readability issues with task groups that are classes of things we've historically taken efforts to improve:

  • Generalized task groups require introducing at least one layer of indentation for the withTaskGroup { } block. Although this is necessary in their full generality, since if you want to conditionally add work to a group in an if block or loop, we need a task group scope that's independent of any single statement scope, there are many cases where the set of child tasks is fixed, and the desired scope for the child tasks match the scope of a block statement that already exists in the code. Many times in Swift's evolution, we've taken pride in tearing down "pyramids of doom", and although one extra indentation is less of a Giza and more of a Bass Pro Shop pyramid, I think saving the indentation is still worth it.
  • Task groups in their full generality also need to allow for 0, 1, or N child tasks, and so their primary interface for consuming child task results is as an AsyncSequence. However, in the same category of cases where you have a known number of child tasks, each with known result types, the homogeneous, indefinite-length nature of an AsyncSequence is suboptimal. You're forced either to force-unwrap the Optional result of next() to get the value of a single child task, or set up a local enum type to manage heterogeneous results from multiple child tasks, and then assert that you've seen one of every enum payload by the time you've iterated through all the child tasks. Not only is that a lot of boilerplate, but in Swift, we generally like language features that let your code be correct by construction, relying on trapping runtime assertions only as a last resort.
16 Likes

This isn't 100% related to async-let, but I wanted to answer your question here: you can loop through the values returned by tasks within a group using for-await:

return try await withThrowingTaskGroup(of: Veggie.self) { group in
  for i in 0..<veggieCount {
      group.async {
        try await chopVegetable(i) // returns value of type Veggie
      }
  }
  async let meat = marinateMeat()
  async let oven = preheatOven()

  for try await veggie in group {
    // do something with veggie
  }
  await oven // .. do something with oven ..
}

Async-let is already designed to work within a task group's block (or vice versa). If you want to have a heterogenous type returned for the group, you can change the type passed to the of argument label to be an enum that accounts for the various cases. Alternatively, just use async-let in the group, like I wrote above.

3 Likes

If the subtask that yields oven is a direct member of group itself (as opposed to a child task), then that approach can only work if oven is a Veggie. Thus my “but wait” comment in the code.

(And yes, the heterogenous results enum approach of the original example from the structured concurrency proposal addresses this, but it would certainly seem odd if the type passed to withTaskGroup constrained every async let inside it!)

I assume, then, that when you write:

Async-let is already designed to work within a task group's block (or vice versa).

…that means async let must create a child task of the task group?

Perhaps that is always sufficient! It’s not clear to me whether there’s ever an important difference between the “heterogenous results enum” approach and the “separate child tasks” approach. I’d just originally wondered whether the pitch paints us into a design corner around that question; knowing that there’s a path to address it should it ever matter would give me confidence to ignore it entirely for now. An escaping design-space future, if you will.

The async-let task is not a member of the task group. It's just scoped to the same block/closure that group is.

No. The task-group object creates tasks that are subtasks of the task that calls group.async. The lifetime of those tasks is limited to the block in which group is defined, because exiting that block implicitly awaits the tasks in the group. Otherwise, you can write arbitrary code in that block, such as async-lets or ordinary let's.

If you define an async let at the top level of that group's block, then its lifetime also happens to be limited to the block in which the group is defined, just like any other let-binding appearing in that block. Because those tasks are tied to an async-let, they'll be cancelled and awaited when going out of scope.

3 Likes

Ah! I see. Not what I expected, but it makes sense. Thanks for taking the time to explain!

This implementation approach further suggests there’s really no particular need to be able to assign an async let to one specific group, and I’m just fretting over hypothetical nothings.

1 Like

On the subject of try I find that nearly all asynchronous action is subject to failure: databases, filesystems, networks, long computations, etc.

I’d prefer calling async imply failure rather than set myself up for a career future where nearly all async requires extra boilerplate.

1 Like

Does this feature allow for optional binding? Can you if async let?

No, because to fulfill that you’d have to actually get the value out of it — so it can’t “just” async but must also await. In which case, it becomes just if let x = await.

(Same goes for guard)

3 Likes

I don't like the @future idea at all because, in addition to adding a bunch of boilerplate (and an additional level of {brackets}), it implies that you can break the structured concurrency guarantees. If I have a property wrapper, then that is something I can pass around (or would least expect to be able to pass around) and use to break out of the defining scope.

It also feels really heavyweight, whereas I imagine async let allowing the compiler to use lots of little optimization tricks where it doesn't actually ever have to allocate a future object in many cases.

async let seems much more intuitive and readable to me (although I still slightly prefer my original suggestion of let x = async myAsyncFunc())

2 Likes

Actually if you read through the full suggestion, it says "We want to make sure the value isn't copied and doesn't escape for safety and to enable on-stack optimizations.".

I think whether we use async let or @Future it's just some kind of sugar over task group. The compiler has the flexibility to do further optimizations in both cases.

try is used to mark places that can throw errors. The declaration of veggies cannot throw an error; only the uses of veggies can throw errors, which is why we have:

async let veggies = chopVegetables()
//...
try await veggies

It's like the difference between forming a closure that can throw, and calling that closure, as @jazzbox points out:

let fn = { throw SomeError() } // no `try` here
try fn() // the try goes here, where you can observe the error

I've been trying to make property wrappers work as an async let replacement for the better part of a year, and I've come to the conclusion that property wrappers aren't the right tool for this task: they cannot provide the semantics we need for child tasks without a pile of magic attributes to customize their behavior, at which point we've lost all but the most superficial benefits of using property wrappers. I'll explain point-by-point since you have a long list:

I doubt this would pan out. Aside from the special rules about initialization and the need to try or await on access, async let is just a let throughout the core parts of the compiler. Most of the complexity of the implementation is in later compiler stages, to ensure that we create/cancel/finish child tasks at the right places and that we take advantage of the scoped model to eliminate excess heap allocations. That complexity doesn't go away if we use property wrapper syntax here, but because you need to invent various magic attributes to get the right semantics, you actually have more work to do (and more chances for failure) because you have to work backwards from pseudo-generalized code to the specific efficient implementation.

Property wrappers need to be heavily customized to get the semantics we need for child tasks. Some issues I found while exploring this:

  • Property wrappers always create a backing storage property (_veggies). We would need to invent an attribute to make that backing storage property inaccessible to users, because we don't want to imply that there is a way to refer to the child task. The fact that one cannot access the futures behind child tasks is one of the core aspects of structured concurrency.

  • You need some kind of deinit on the backing storage property to handle implicit cancellation. We don't have those on structs or enums, so either we need to implement that (which I suspect we'll do some day as part of move-only types) or we need some kind of forced stack allocation of classes to avoid the cost of heap traffic here.

  • The deinit isn't even enough for waiting for the task at the end of the scope, because it cannot be async and our reference-counting model wouldn't wait until the end of the scope to call deinit anyway. So, we'd need to invent some kind of attribute, protocol, or ad hoc protocol for "run some code at the end of the lexical scope" that ties in with these property wrappers. I'm not sure that's something we would even want as a general feature: we've pretty consistently stated that using "with" blocks to introduce a lexical scope is the only wait to get guaranteed lifetimes for operations.

  • Property wrappers currently only work with var, because the actual variable being declared is a computed property. Either your examples need to use var or we need to consider allowing property wrappers to be defined as a let.

The benefits we get from using property wrappers here are mostly superficial. I think we'll end up in the uncanny valley where what we have looks like a property wrapper, but is indeed so customized for this one purpose that the resemblance doesn't aid understanding: it would be better to present this as its own separate language feature.

The overloading is pulling together the structured asynchronous and concurrent constructs under "things that are await'able".

As others have noted, we could do the async throws let thing and it would both be consistent across effects and also address the concerns about not knowing that the initializer could throw.

This point is actually completely independent of property wrappers. We could require

async let veggies = { try await chopVegetables() }

However, we should not require the braces. The type of veggies is [Vegetable]. It is not ThrowingFuture<[Vegetable]> or () async throws -> [Vegetable]. The initializer expression for a declaration should match the type of the pattern in the declaration, so the initializer expression should be the expression that produces [Vegetable].

Now, we could of course bend the rules for async let and require the braces despite the above. It's actually harder to bend the rules with property wrappers, because property wrappers really want the type of the wrappedValue property and the type of the parameter of init(wrappedValue:) to match. That means you don't really get this syntax:

@ThrowingFuture var veggies = { try await chopVegetables() } // doesn't really work

you need to either do something like this:

@ThrowingFuture { try await chopVegetables() }
var veggies

or use autoclosures to get brace-free syntax:

@propertyWrapper
struct ThrowingFuture<T> {
  var wrappedValue: T { get async throws { ... } }
  init(wrappedValue: @escaping @Sendable @autoclosure () async throws -> T) { ... }
}

@ThrowingFuture var veggies = try await chopVegetables()

We can decide to do this independently of whether we use property wrappers. I think async throws let offers the most promise.

We have an established pattern for this: { ... multi-line-closure ... }()

The writing here makes it sound more complicated than it is; mostly, it's describing the impact of transferring the effects from the initializer to each of the bound variables.

As I said much earlier, we went far down the path of trying to make property wrappers work, based on the same intuition you're describing here. It's part of why Swift 5.4 got property wrappers support for local variables, and effectful properties came along. But the semantic model of async let isn't actually amenable to property wrappers, and structured child tasks described via local values are important enough to justify their own sugar.

Doug

17 Likes

Incidentally, my hope is that explaining this is how you do a multi-statement thing to await will flow back some explanation to the common misuse of braces around variable initialization i.e. it is common to see let x = { singleStatement }(), which is unnecessary (you can just write let x = singleStatement) and having more places in the language that follow this pattern will help with the wider understanding.

I completely agree. There are strong reasons why error handling has multiple "words in the lexicon": throws for the effect, try for the marker, Result for the "handle" type when erasing to an uneffectful function, and do/catch when introducing a new catch-processing region. I think that all these things are substantially different and are worth different "words" to clarify them.

It depends on what you mean by this. The entire point of this feature is that /scoped/ concurrency is a valuable thing and that "passing off" is critical within that scope. This is analogous to what non-@escaping closures provide (in the non-concurrent domain). Neither allows escapability but both allow "passing off" down the call stack.

This is really the fundamental issue touched by this proposal: escapability. Swift doesn't have a first-class concept for this. It can't have a fully principled answer to this until we get to ownership, so we're all obliquely discussing tradeoffs about various approximations. We should bring this tradeoff discussion front and center if we want to get to the core of the issue.

My claim is that we should pick a short-term approximation that is absorbable into more general future language support -- rather than inventing weirdly-novel (in a bad way) language mechanics that will always have to be special even as the rest of the language develops. I'd rather we make a short term hack that dissolves away instead of overly polishing the special-case-of-the-day and living with it forever.

Agreed 100x, this is true about a lot of computation and enables a lot of optimizations, e.g. the stack allocation optimization we already do for non-escaping closures. That's exactly the same thing we should do for child tasks. I'm arguing that we should embrace this as a general pattern instead of special casing it again.

No, because the proposal is magic syntax that doesn't compose correctly with the meaning of let in other parts of the language. This is a very serious issue in my opinion and this symptom gets at the root of my concern about this proposal.

Right, that's why we should consider new decorators, like try(dtor). The notion of a destructor than can throw (if that is the model we think is best -- still very unclear from the discussion) is a fundamentally new/unprecedented notion to Swift. We shouldn't try to paper over it with existing constructs IMO.

The entire point of property wrappers is to provide open library-based extensibility for properties. Adding a few new features to them seems like an investment that will pay off many-fold into the future.

My metapoint is that this proposal is presented without alternatives - there is no exploration of the design space, and it isn't clear to us community members what attempts to leverage existing language affordances have been made. Furthermore, the design of this feature has not evolved significantly since its original pitch many months ago (even though the base language has taken significant changes) so I don't have a lot of confidence that this was "first-principled" since then.

All of the things you point at as weaknesses of property wrappers seem like things that would be better solved by making property wrappers more powerful, rather than inventing a one-off language feature that doesn't provide the library-based extensibility that advanced Swift programmers have come to love/expect.

I agree this works, but are you seriously arguing that people should use this anti-pattern? If that were better, then we should be consistent. e.g. would you argue that we should forbid braces on if statements? You could always use expressions to emulate them after all:

  if cond {
    doThing()
    otherThing()
   } () else {
     ...

instead of:

  if cond {
    doThing()
    otherThing()
  } else {

... while possible, would this make Swift a better language?

There is a simple and clear alternative on the table that would fix this for the important new language feature that this proposal is discussing. I think this affordance deserves to feel first class and build on the existing support we have for this sort of thing.

-Chris

4 Likes

Not to weigh in on many of the other issues, but this concern seems overwrought given how many forms of let already exist in the language where they don't compose together. let, if let, and case let have different semantics and are used to do different things by both requirement and convention. async let is simply one more which, like if let or case let, requires a bit of understanding on when it should be used before the user is fully comfortable with it. So I don't believe questions exploring the meaning of new syntax in an evolution proposal are the same thing as the questions users may have when encountering the feature the first time, or a point against the proposal itself.

Personally I found the proposed wrapper syntax far inferior to async let. async let is easier to read, easier to write, and easier to understand. Property wrappers are not a solution to every problem that involves properties and I'd hate to see Swift turn wrappers into the proverbial hammer seeing everything as a nail. Something as common as async operations like these deserves a more elegant solution.

I'd really rather not have to write hacky code every day just because there exists the potential for future issues. This is not a "special-case-of-the-day", it's a rather common situation in handling multiple async statements that will be used on a regular basis. That deserves special handling.

6 Likes