`async let` and scoped suspension points

I can't speak for the entire core team, but I personally have very little interest in an "await on every control-flow edge" approach to this problem. I feel like that would often drown programmers in keywords that they won't recognize the purpose of.

20 Likes

Here's a idea:

  1. No need to spell await at the end of a function scope. The function is async so there's an await in the caller at the call boundary. This await in the caller can cover our implicit await at the end of the function scope, as long as the implicit await is done after any defer block in the function.

  2. Don't await (even implicitly) when exiting most scopes (if, switch, try, catch ...). Those awaits are pushed at the end of the function scope. There's little to be gained by implicitly awaiting at every scope, and it's better to reduce the number of suspension points.

  3. An explicit await is required for loop scopes. You can use await break or await continue when there is an async let that needs to be awaited. The compiler will have a fixit for you if you forget.

    • You can still exit a loop with a simple return with no await. As long as it gets merged with the the function scope implicit await it's fine.

This way we reduce the number of suspension points and all the suspension points remain explicitly spelled with await. Maybe it makes things slightly less structured, but I think it's for the better.

Example:

func test() async {
  async let x = work(0)
  if condition {
    async let y = work(1)
  }
  for i in 2..<10 {
    async let z = work(i)
    if condition {
      return // implicitly awaits x, y, z
    }
    if condition {
      await break // awaits z
    }
    await continue // required here, awaits z
  }
  // implicitly awaits x, y
}
7 Likes

What about a design that changes the way scoping works in async let? I think we should solve this by doing the same thing we do for Task Locals. Introduce a scope where these variables are valid and if folks need more variable then nest the scopes. Or we could use something like if sync let {} to do multiple variables.

The real solution is going to be a new feature like dotnet’s using

Thanks,
Chéyo

I don’t think implicit await is particularly a problem at the end of functions. The problem seems to be at the end of the scope, e.g., if block.

That sounds like a fragile system. If you refactor things into functions (which people regularly do), then you’d change all the await locations iside the function.

The problem is not the quantity of the suspension point anyhow, just that they are implicit where it should be called out.

What if we just do for await ... in ... {} for those loop, covering both the iteration and end-of-scope suspensions.

I’m most certain that the point of async let is to not introduce new scope. Adding scope would just reduce it to a withTaskGroup.


I still like putting await at the beginning of the scopes, but maybe someone can convince me otherwise.

2 Likes

Yeah that's the risk there... and I agree it could be quite noisy, so I guess we indeed went another full circle and arrived back at implicit awaits again :thinking:

It could indeed be the right tradeoff after all to just implicitly await, but did want to explore the "await never awaited ones" idea at least with folks in the thread a bit.

Do you mean that the idea to require awaiting every async let at every path? I do think it could be a good common ground to settle since it seems to be extendable to many other ideas.

I think the big problem with forcing an await along every path is thrown errors — forcing the program to catch, await, and re-throw seems like it would be very pedantic. It also means that we wouldn't be cancelling the sub-task, just awaiting it normally.

There are also concerns about things like variables that are only awaited in loops.

2 Likes

Excellent! This solves the issue with annotating the exiting of scopes by throwing.

This makes a lot of sense to me. If I was to do this manually, without compiler support, I would probably have done it that way too, as there is a slight chance it will be done at end of scope so I do not have to wait at all.

Implicit suspension in loops are what I think could be most confusing and unexpected. I think it's great to make those explicit. And again the throwing exits are taken care of (like return) :+1:

1 Like

I'm not saying we have to go all the way to implicit awaits, just that I personally don't think putting an await on every control flow edge out of the block is an acceptable solution. An await on the block itself might still be okay with me, if that's what the community wants. And of course people are welcome to disagree with me in any case.

1 Like

I would like the thread to consider the "namespace of things that will need to be awaited at the end of the scope" seriously as an idea. There are a number of features in Swift, either currently or under consideration, that involve running code at the end of a scope. defer is the most obvious example, but also a C#-like using (if it doesn't have to introduce a sub-scope, which IIRC it does in C#), the concept of a local inout or borrow binding from the ownership manifesto, and so on. If we want that code to be able to do async things — or, for to matter, to throw — we will have very similar problems to what we have here with async let. So if it's possible, it would be very nice to have a language solution that feels like it can extend to cover all those things comprehensively.

Again, either marking the containing block or expecting people to understand the effects attached to an earlier statement or declaration seem potentially acceptable to me, but I think we should be thinking of this as an example of a broader set of directions for the language.

14 Likes

Could you elaborate on this? If the variable itself is non-throwing, the equivalent "fix" should just be await x. at the end of scope. If it is the throwing one, it should still be _ = try? await x. You'd need that on every path still, but I don't see where catch/re-throw come into play.

I'm okay with deciding that people need to internalize implicit end-of-scope suspension points for async declarations like async let and async defer, but if we are to use "more explicit" syntax for an end-of-scope suspension point, I like @filip-sakel 's suggestion because it puts the explicit syntax on the async let declaration itself. The thing I don't like about writing await somewhere other than on the declaration is that it's not clear where the await came from. Since defer is very clear in it's meaning of "this happens when the scope exits", could we use something like that here if it isn't read on every path? E.g. async defer(await) let blah = foo()?

5 Likes

I don't mean from the async let itself. Unhandled throws are paths out of the scope, so e.g.

async let x = a()
try b() // <- path out of scope
try c() // <- path out of scope
d(await x)
2 Likes

I agree that catching, awaiting and rethrowing here is probably the biggest problem, but I explained above why the implicit awaits aren't really a significant problem - that code snippet you wrote would have to be enclosed in an async function, which all callers will have to await on. The only way developers might get confused is if they catch the error in the caller and reason that, since the try lines occur before the explicit await, that the function didn’t suspend — even though they had to call it using an await.

We should instead teach developers that, if they have to await a function call, they should always assume that it suspends.

Also - how does defer allow implicit suspension points? Surely any awaits inside a defer block are every bit as explicit as an await in a conditional scope or for loop.

+1 on using

using async let = ... // don’t require explicit scope just like c# 8+

Otherwise every async let should be awaited in all branches but I am assuming most folks won’t want to do that so maybe only expose using async let and then open up using for other use cases.

Maybe I should propose yet another model... one that does not care about scopes:

  1. Each async let found in the function, whether in a loop or elsewhere, can have zero or one task running at a time. They're all implicitly awaited at the end of the function.

  2. When the async let is inside of a loop, and not awaited on all paths, you need to declare it as await async let. That's because the second loop iteration will need to await the task from the first iteration before it can launch its new task.

I think this is an interesting model because you don't need to change anything but the async let declaration itself, yet all the suspension points are clearly marked.

Example:

func test() async {
  async let x = work(0)
  for i in 1..<10 {
    async let y = work(i)
    await async z = work(i)
    if await y == 0 {
      await print(z)
    }
  }
  // end of function: implicitly awaiting x, y, z
}

Here, z is not awaited on all paths, so it needs to become await async let to allow itself to await the termination of the previous iteration's z.

2 Likes

To be clear, I was not suggesting that we change this syntax to using async let. If we add using, it should be for a C#-like purpose. I was just saying that if we do add a C#-like using feature in the future, it might be useful to allow the dispose analogue to be async, and that creates similar problems.

dispose in dotnet can also be async (slightly different syntax). To me making sure unused Tasks are cancelled implicitly when exiting scope is a form of implicit dispose. I don’t think async let should have this behavior implicitly.

Without ranking the (many!) alternative here, my two cents on the design principles to follow in making a choice:

Every await indicates the potential presence of unexpected mutation, and is thus a moment of potential danger. The danger isn’t immediately obvious, but it seems to me it’s serious.

Consider this toy class:

class BunchOfItems {
    public private(set) var items: [Item?] = []
    public private(set) var specialItemCount = 0

    func add(_ item: Item?) {
        items.append(item)
        if isSpecial(item) {
            specialItemCount += 1
        }
    }

    func clear() {
        items.removeAll()
        specialItemCount = 0
    }
}

The add and clear functions both correctly preserve the invariant that specialItemCount equals the number of elements in items that are special. There is a brief moment in each function when the two are out of sync, but they are back in sync by the time each function completes. All good.

Now suppose that we determine that isSpecial() is a performance bottleneck, so we use async let to let it execute concurrently:

    func add(_ item: Element?) async {
        items.append(item)
        async let itemIsSpecial = isSpecial(item)
        if await itemIsSpecial {  // [1]
            specialItemCount += 1
        }
    }

This innocent-looking changing means that add suspends at [1] — while items and specialItemCount are potentially out of sync. Uh-oh.

IIUC (please correct me if I’m wrong!) this means that a call to add and a subsequent call to clear could potentially produce the following order of execution:

        items.append(item)
        async let itemIsSpecial = isSpecial(item)
        <<<suspend>>>
        items.removeAll()
        specialItemCount = 0
        if itemIsSpecial {
            specialItemCount += 1
        }


resulting in an empty BunchOfItems whose specialItemCount is 1.

(It’s a little unclear to me just how clever we have to get with the call to add and then clear in order to make this happen, but I do believe it can happen.)

The only protection we have against this is our own wits plus the word await warning us that there is a suspension point, and we thus must ensure our invariants — any relationships between different pieces of state for which we’re responsible — are all in good order before we await. We must also be prepared for unexpected mutations after the await.

This to my mind is the primary purpose of making await explicit. Any syntax we choose should make crystal clear the precise line of code before and after which we need to worry about this dual problem of exposure of intermediate state + unexpected mutation.

Edit to add TL;DR: We need something explicit at every suspension point that says “danger here,” because await can be dangerous. Whatever syntax that is, it should make clear exactly where the suspension point sits in the flow of code.

5 Likes

A defer block is not currently an async context and cannot await anything. It also cannot throw.