SE-0317: Async Let

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

I can see how you see it this way Jon, but there is really only one bug here: if let implies an optional unwrap. We actually discussed forcing people to write:

if let x = foo() {

as:

if let x? = foo() {

which would have made the language far more consistent w.r.t. pattern matching, and would have completely eliminated the need for the if case construct. However, we were in the the Swift ~1.1/1.2 timeframe and we decided that this would unnecessarily uglify the common case and force people to learn about pattern matching early. As a consequence of that (IMO in retrospect, "short sighted") decision we have a lot of complexity around let and var that we didn't need to have.

However, that history doesn't imply that we should continue the antipattern. The proposal here doesn't have similar justification, and the failure to compose isn't warranted. While it was costly, we did make if let compose, we were just forced to spell it if case let x? = instead of if let x? = due to the compatibility issues.

I respect your opinion here, but I find it hard to feel as strongly as you do about this given we have almost zero usage experience with this new sugar. We shouldn't rush to sugar things we don't understand - language features are the MOST DIFFICULT thing in the Swift ecosystem to back down from, but are comparatively easy to add later if they are missing.

This is a typical issue with new language features - we all project our expectations onto them and draw conclusions based on our experience in other systems or (sometimes) when porting our code to early betas. However, without a reasonable range of experience using it (including in brand new code bases!) we can fall into traps of carrying old patterns into the new world even when there is a better way to solve the problem.

I care far more about Swift being a great language in 2 years than I do about micro-optimizing the next 3 months.

-Chris

8 Likes

I'm not sure how your example would've helped here. A small change to the optional unwrap syntax (with the same issue around using ? as brought up in the recent thread) wouldn't help align the different uses, unless you're contending let x = 1 is also pattern matching in some way. Of course, we also have while let, which is functionally the same but reads differently.

Perhaps I just don't see the language side of things here. I just want things that look similar, or which I already use, to do similar things. To my mind that means case (pattern matching) + let (declaration) = matched declaration. Similarly, async (async work) + let (declaration) = async declaration. Maybe that's not how the language works, strictly, but I think it's what many users make up as they go along. And there's benefit to going along with that intuition, even if it offends the sensibilities of language designers.

We have 7 years of experience writing async Swift code to solve the same problems the concurrency feature proposes to solve with new language features. This isn't a new need. Probably behind only dependent tasks, waiting on parallel tasks is perhaps the most common async task users perform. So we already know this will be common.

Now, if you're saying more data is preferable, I agree. I don't think whatever Apple announces next week should determine the shape of the concurrency feature into the future. It should be a preview that isn't guaranteed to be source (or even ABI, if possible) stable until Swift 6. But that seems unlikely. Swift's development cycles just don't lend themselves to gathering much of this data.

I agree in theory, but I don't think Swift's history supports this position. If I have to choose, I choose sugar I can use today over theoretical concerns which may or may not be realized at some point in the future. Especially if one of the answers to those concerns is the same sugar we're talking about already.

Fair enough. I care a bit more about Swift I can use today than proposals I can read in 2 years.

2 Likes

Thanks for backing me up on this, Chris.

This community tends to deride any naming discussion as “bikeshedding,” but that term properly refers to minutiae — and names are not always minutiae. Names do matter. Sometime they matter a lot. And this strikes me as a situation where picking great names is at least important as adding great sugar.

Note that, from the user’s POV, the syntactic difference between the more-sugared form in the proposal and less-sugared property wrapper form is not large:

async let foo = expensiveComputation()

@async let foo = { expensiveComputation() }

There’s no great cost/savings to either the fingers or the eyes here. What meaning the alternative syntaxes convey is in question.

Consider, for somebody seeing the construct for the first time, how different the process of initial discovery, web searching, heuristic formation, and ultimate comprehension looks with, say…

@ChildTask let foo = { expensiveComputation() }

…or ScopedTask, or something.

I’d argue for more attention to terminology and implied metaphor across the concurrency proposals, then (agreeing with Chris) see what special sugar is truly necessary afterwards.

Since it’s a bit lost in all this back-and-forth: I love, love, love the basic idea of this proposal. Structured concurrency is a great idea, I’ve been sold on it since I first read about the ideas behind Trio, and I like the idea of reducing friction around it and making it the easiest-to-reach-for mechanism for introducting concurrency in Swift code. All this haggling over details is well worth it.

7 Likes

A non-review manager post to push back against async throws let.

As others have pointed out already, try serves an important purpose, similar to that of await. It marks where control flow can be abruptly interrupted. This is important in cases where you might be left in an inconsistent state, with an operation half-finished, unless you write code to account for that possibility.

On functions, throws also has a clear purpose: to require a try at the call site. It sets up the API for a throwing an error, even if none are actually thrown in the function as it is currently implemented. Function declarations are defining an interface, declaring that a function can throw without needing to know if/how it actually does within the function body.

The throws in async throws let serves neither of these purposes. Any use of a throwing async let must have an await try within the body of the same function. The throws itself adds no useful information in addition to the try that is already required. The Swift principle that applies here is "omit needless words".

Justifications of throws so far include symmetry – but symmetry is not a goal. It can be a means, to helping build muscle memory, or to explaining how things work so improving teachability, but not an end in itself. That relates to another justification: pedagogy. "How do we teach this" is an important question when a feature is complicated and confusing, needing a really clear explanation for a newcomer to be able to grasp how it works. But here there is no such need. The learner will quickly discover they need try the variable a few lines later, and the explanation for why (the function at the declaration throws – maybe pointed to by a note) is clear. Beginners will very quickly learn this once, but then have to write this otherwise pointless keyword thousands of times subsequently. That's not a good trade

The next reason offered is that it "indicates the let will need to be awaited later". That need is already enforced by the compiler at the place where it matters: the place that can throw, interrupting control flow.

This redundancy is justified by "clarity". You can never be too clear, right? It doesn't actually serve a material purpose at the variable declaration site, and you'll discover what you need a few lines down when you use the variable. But still, it's much clearer to be explicit at the variable declaration site as well.

Hmm, that sounds familiar. Oh right, it's the argument against local variable type inference!

// it doesn't matter on this line that x is a String,
// that will be checked later in this function at the use site, 
// but it's clearer to be explicit at the declaration
let x: String = fetchSomething()

// it doesn't matter on this line that awaiting x will throw, 
// that will be checked later in this function at the use site, 
// but it's clearer to be explicit at the declaration
async throws let x = fetchSomething()

In fact this comparison is unfair to the case against type inference, which is a lot stronger than the case for async throws let. At least with type inference you might pass an unexpected type into a function that takes a generic argument or an Any. This is why we warn when you interpolate an optional. But there is no similar case for throwing async variables, which must have a try to await them 100% of the time.

Requiring throws at variable declaration would be unambiguously counter to the feel and direction of Swift.

10 Likes

Yes, that is what I'm saying. let x = 1 is pattern matching / destructuring, just with a trivial pattern "x". This is why let (x, y) = foo() works. for in loops do the same. My point is that if we got this right in the beginning, we wouldn't have if case let at all, it would just compose naturally that let pattern is for non-failable patterns and if let pattern is for failable ones.

Here's my point: consistency and composability is important because it reduces language complexity over time. If we had been consistent on this from the beginning, we would have one model, instead of two and niche syntax (if case let) to support. I'm trying to illustrate why "simple things that compose" is such a strong goal for me.

Yes, I don't disagree with that. Language design is hard for many reasons, one of which is that you need to decide what to align new concepts with in the existing language. There are often multiple valid choices, and while you can predict many repercussions, they often don't play out until you mix in other aspects of the language.

You sort of ignored what I said. I'm very aware that some of us have decades of experience coding, lots of experience with concurrency etc. I specifically said we have "almost zero usage experience with this new sugar", which is objectively true. I'm not debating the need to solve this problem, I'm trying to find the best possible solution to it.

I completely agree, particularly for new core language concepts that are being added. This isn't bike shedding, this is design. It is like deriding Jony Ive and team for spending months or years "bikeshedding" to get the click on an airpods case "just right". Clearly a triviality, but a triviality that customers /feel/. The same is true about some parts of language design.

-Chris

8 Likes

I disagree with this characterisation. The throws is necessary for pedagogical reasons because without it, the mental model that the effects are "transferred" to the variable is completely lost. Without it, async in async let is just an arbitrary keyword that can mean either "needs to be awaited" or "needs to be tried and awaited". That greatly muddies the waters around the keyword both here and elsewhere in the language. Symmetry isn’t the goal — a clear mental model is.

It may very well be that requiring async throws let is the wrong trade-off to make, because the cost is too great for repeated use, but if that’s the issue, a more pedagogical solution is to choose a completely new keyword like future that has no other meaning in the language.

2 Likes

It is not completely lost, any more than the type returned by a function is "lost" when using type inference. It is just not required to write it in code at the declaration site, because it doesn't add significant value to do so.

7 Likes

I interpreted your statement more generally, as we obviously don't have experience with this particular sugar. Otherwise your statement is rather contradictory. We shouldn't add this sugar because we don't have experience with this sugar? How are we supposed to get experience with it without adding it? If you want more experience with the new concurrency features in general before adding such sugar, that's a different point. And like I said, I don't think Swift's process is well suited to that sort of evolution.

1 Like

@Konrad @Doug

I’m having trouble fully understanding this rational from the proposal:

It seems like a simpler model would be to have explicit futures/task.handles but require them to be awaited along all paths within the scope and/or not escape the current scope.

This appears to be a more teachable model; saying that when using an async function you must await whenever you finally want to retrieve the value.

// async serial
let _foo = await foo() // suspend 
let _bar = await bar() // suspend 
print(_foo, _bar)
// async parallel
let _foo = foo()
let _bar = bar()
await print(_foo, _bar) // suspend

Additionally, tying the effects to a type allows libraries to add extensions where as async let doesn't appear to be extensible at all. (Maybe that's desired, but seems at odds with the rest of the language)

3 Likes

I disagree. For one thing, it doesn't serve the first purpose because it isn't supposed to take the place of try at all. So I'm not sure I follow your point there.

The second purpose - "to require a try at the call site" - I think clearly applies. async throws let acknowledges that the right hand side of the let expression can throw, just like throws does with a function, and the "call" site for that variable will require a try.

2 Likes

I was not arguing that the effect is lost. It clearly isn’t – I understand that. My argument is that the clear mental model that the effects are transferred is lost.

The analogy with return types doesn’t really work. We never write half of a return type – we either write the whole return type, or we leave it out completely. Writing async let when the variable needs to be both awaited and tried is leaving out half of the effects without any marking that they’ve been omitted. (Even the upcoming SE-0315 that actually lets you write half a return type and leave the rest to inference requires you to use _ to mark that something has been omitted.)

4 Likes
IIUC, `async let` is not so much about the async effect but the spawning of a new task.

This example was already given earlier, namely that we can write this in Swift 5.4:

func f() throws {}

let x = f

If the concern was about showing which effects are transferred, then this should be written throws let x = f from now on. Is that what we want? I think not?

This is why spawn let might be a better choice.


Oops, rereading your original post, I see you suggested something a little different:

func f() throws -> Int { 42 }

let x: Result<Int, Error> = Result { try f() }

print(try x.get())

would become

throws let x = f()

print(try x)

The unifying idea being that throws let, async let and async throws let all separate the execution of effectful functions from the accessing (suspension & error handling) of the result? Sorry if I'm fumbling it.

But that means it is indeed not about the effects but about that separation, so maybe there should be another distinct keyword instead of throws, something like result let which, combined with spawn let would completely remove confusion with the effects specifiers.

Btw, async let x = f() on a serial executor would be equivalent to let x: Int; /* ... */ x = await f(), right?

2 Likes

Or, to indicate that the assignment hasn't happened yet but will before leaving the current scope:

defer let x = f()

I don't understand why library-based extensibility is relevant for async let. It cannot be exposed in the type signature of any declarations, only within their definitions. It also doesn't make sense to expose them in a type, e.g., by allowing a nominal type's member be an async let, because that would not respect the lifetime restrictions that are required to make the implementation work.

An async let represents a task that is a child of the task that created it (forming a tree of tasks); not a child of a declaration. This tree effects how cancellation is automatically propagated through a task. So, it wouldn't make sense for a type member to be an async let, since the task that called init can have a shorter lifetime than the object.

If you wanted to implement a @async or @Future property wrapper whose lifetime is tied to the lifetime of an object, then you could use unstructured / detached tasks, and give up this task tree. But, even then, you would need an async deinit to define that property wrapper, so that you can await the completion of the task during clean-up (so there is no dangling task). An async deinit, in general, is problematic because the destruction of an object may not happen in an async context.

This is why I think the correspondence to property wrappers doesn't work. It would effectively be a property wrapper that can only be applied to locally-bound properties that appear within an async function / method. Plus, a type with an async deinit would add a heavy restriction to values of that type: they can only be created if the lifetime can be determined statically to end (in all cases) within an async context.

5 Likes

If we were to accept that async let requires a decorator to indicate that the expression might throw, we'd expect to require the following for non-async code:

func f() throws -> Int { 0 }

func g() {
  let f_ref = throws f <-- assigning a function ref to be invoked later
  ...
  let x = try f_ref()
}

But, the reality is that we expect the documentation about throwing to be on the function declaration, not on code which assigns references or invokes the function.

The same should hold true for async let. To the extent that we can compare fundamentally different situations, throws is still on the function declaration. The difference between synchronous code and async let is now that the invocation of the method and the assignment (or use) of the return value is now separated both in time and place. So, while it makes sense to indicate that an exception may be caught by requiring try, there seems to be no room for requiring throws.

5 Likes