SE-0317: Async Let

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

[details=ā€œ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.
[/details]

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

Good point. This might present a potential high-level race. Although in this particular example it wonā€™t because child tasks are guaranteed to complete before their enclosing scope exits. But someone else could call mutateState from a different context to cause one.

Iā€™m not sure if having an await under existing rules does too much to solve this though, because the await could still be only used on the outermost expression, making it easy to miss the suspension in the middle of the expression.

If child tasks eventually are able to inherit their parentā€™s executor, this could perhaps also be mitigated.

1 Like

Yeah, this is going to be one of the most fraught parts of the new model. Weā€™ll all have to get used to the fact that every time thereā€™s an await, visible mutable state may change without notice. The sequential appearance of async/await code makes concurrency look dangerously easy! That is one of its downsides.

Thinking aloud here, we need to make sure (in most cases) that an object/actorā€™s invariants hold before every single await, not just at the end of every public function. I imagine weā€™ll develop a toolbox of common practices to help with this, e.g.:

  • Isolating complex state manipulations that temporarily clobber invariants to non-async functions.
  • Holding more intermediate state in local variables, then writing it all back to ivars in one non-awaiting swoop at the end.

Whatever the ultimate terminology and syntax, I see the async let proposal as being helpful on this front, e.g. with implementing that second pattern.

Does this really help? Partial tasks can still execute interleaved even in the same executor / execution context, so you can still encounter surprising mutations after an await. Cross-context communication is strictly sendable, and thus canā€™t cause surprise mutations.

Overall, I think async let is going to be really helpful for concurrency and has a great underlying model. This is one case where I think a small tweak could help clarify things.

See this example:

func slowNetworkCall() async -> String { ... }

actor Actor {

   var state: Bool = false
   var color: String { state ? "red" : "blue" }
   var object: String { state ? "balloon" : "frisbee" }
   func mutateState() { state.toggle() }

   func test1() {
     // This could have inconsistent `color` and `object` 
     // But - there's an explicit await, so we know to watch out
     let result = await "\(color) \(slowNetworkCall()) \(object)"
     print(result)
   }
   func test2() {
     // This could also have inconsistent `color` and `object`
     // But there's no explicit await, so we might not spot it
     async let result = "\(color) \(slowNetworkCall()) \(object)"
     print(await result)
   }
}

Say you changed the definition of result in test1() to make it async let (as in test2()). You're currently allowed to drop the await. I think it would be preferable to only allow this to be dropped if there is a single suspension point in the expression on the right side of the equals - possibly also only if this at the start. You would then have to write:

async let result = await "\(color) \(slowNetworkCall()) \(object)"

which would give you the same amount of warning about possibly reentrancy as you get for the non async let case.

1 Like

I have a question to the authors: The proposal states that "A async let that was declared but never awaited on explicitly as the scope in which it was declared exits, will be awaited on implicitly." What is meant by scope in this context?

I ran a test using the current Xcode beta:

actor A {
    var value = 0
    
    func increaseValue() {
        print("Increasing value")
        value += 1
    }
    
    func testScope(probablyFalse: Bool) async {
        do {
            value = 0
            async let a: String = {
                sleep(1)
                await increaseValue()
                return ""
            }()
            
            if probablyFalse {
                await a
            }
            print(value)
        }
        print(value)
        print("End of test()")
    }
}

Calling testScope(probablyFalse: false) prints the following:

0
0
End of test()
Increasing value

Given the above quote, I would have expected the following output (because the do scope ends before the second print(value)):

0
Increasing value
1
End of test()

However, I actually prefer the behavior shown by the current beta to only await implicitly at the end of a function and not to implicitly await at the end of dos (or ifs). The reason is that there is no await between the two print(value) statements. Thus, the programmer would not expect a potential suspension point between the two statements.

Can you please enlighten me?

1 Like

Yeah you're right. I was thinking that if it inherited the actor's executor and was auto-isolated to that actor, it would be able to synchronously access the state atomically.

1 Like