`async let` and scoped suspension points

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.

Since it seems directly relevant, the slightly different syntax for using an object with an asynchronous dispose in C# is await using (<expression>), as opposed to simply using (<expression>).

1 Like

Not sure if I should post here with an overly half baked comment - but this seemed like an interesting question to ponder ... -- and hopefully the comments of a less experienced perspective are not super negative to the conversation ...

Maybe the async let declaration syntax could be expanded to allow declaring logic that is intended to run when the declaration leaves scope?

Perhaps an initializer expression could take a keyword closure ...

<scope-effect> let x = <initializer expression> trailingKeywordInlineClosure: { <scope-effect args> in ... <closure body>  }

Something like this for async let:

// ok: no suspension at scope exit 
{
  async let x = doWork(0)
  await x
}
// error: async declaration x missing await on some path, async suspension effect must be acknowledged on scope exit path at try
{
  async let x = doWork(0)
  try failableWork()
  await x
}
// ok: scope exit suspension effect acknowledged on every path via onAsyncLetScopeExit: argument to async let declaration ....
async let x = doWork(0) onAsyncLetScopeExit: { ackSuspension in await ackSuspension(x)  }
try failableWork()
await x

In this sketch the onAsyncLetScopeExit: keyword arg to the initializer expression would be made legal by the async in front of the let ... and then that closure would be executed from within the guts of the logic associated with the effectful-let-declaration leaving scope ...

I have no idea what kind of restrictions would need to exist on that closure but I expect there'd need to be a bunch of them ... but being able to put logic into that point in the computation at all might provide a convenient place to put a breakpoint ... as being able to stop just before/just after the logic associated with an effectful variable's lifetime ending might prove a generally useful point in the computation to make observations of program state from ...

I’d say in the case not all async lets are awaited explicitly, just nest the block into another block and prefix the inner block with (try) await.

That doesn’t sound like such a blocking issue that requires the creation of a new “async defer” to solve. Presumably defers from async functions would be able to await on things (and similarly for throwing).

Hmm, this discussion has unlocked something for me (though apologies if it was already mentioned up-thread, or if this is what you were getting at @Karl :sweat_smile:): we already have defer as a spelling for “do this along all exit paths from the scope,” so would it not be a solution to allow defer to suspend and then tell users that if the value doesn’t get awaited along all paths naturally, they should wrap it in a defer:

async let x = …
defer { await x }

?

4 Likes

I had this same thought, but then I was wondering if it would be too strange since in theory, a defer block would be executed after all code paths, even ones where x had already been awaited. Or am I missing other circumstances where await could really mean ‘await if not already awaited’?

Presumably we'd have the same situation with a setup like this:

async let x = ...

if specialCondition() {
  specialSetup(await x)
}

standardPath(await x)
1 Like

While I wanted to answer this, I found this interesting bug:

It seems that it is currently impossible to access an async let twice.

When I try it this way

async let x = foo()
bar(await x)
baz(await x)

I get the error Immutable value 'x' may only be initialized once on the first line. If I change the code to this instead

async let x = foo()
bar(await x)
baz(x)

Then I get the error Expression is 'async' but is not marked with 'await' on the third line. This is on the latest Xcode beta btw.

Yeah, that's a known bug with async let as currently implemented.

Yeah, so the thing about this is that I think it's too much noise.

One of (if not the) main benefits of structured concurrency is the ability to use regular control flow - throwing errors, conditionals, early returns (e.g. guard), etc, so I think we should keep that as simple as possible, except if doing so is actually confusing or harmful.

When it comes to implicit awaits, I don't think they're all equally harmful. If the implicit await happens because the function is over and you're leaving its scope, it's probably fine and we don't need to force developers to spell it out.

Implicit awaits within a function are potentially more insidious. But maybe not, I'm not sure. I guess it's possible you might write code which wants to reason about external state and carefully manage its suspension points. Generally speaking, async functions should refrain from trying to reason about external state though, and anything they do need to reason about should be captured in a local variable.

So you really want to write code like this, with all your intermediate state being stored local to the function, rather than part of some struct or class which the function is a member of (and actors don't really help with that, because of reentrancy):

func processImageData() async throws -> Image {
  let dataResource  = try await loadWebResource("dataprofile.txt")
  let imageResource = try await loadWebResource("imagedata.dat")
  let imageTmp      = try await decodeImage(dataResource, imageResource)
  let imageResult   = try await dewarpAndCleanupImage(imageTmp)
  return imageResult
}

When you look at async/await code in other languages, it looks exactly like this.

So I think there's an argument that we're maybe exaggerating how important it is to explicitly call out each and every suspension point. For code that really embraces this pattern and only tries to reason about local variables, they won't really care if there are more suspension points that aren't immediately visible.

2 Likes

It seems like actors are a pretty direct counterexample to this. Their entire purpose is to provide a first-class construct for managing and reasoning about some external state, and reentrancy means that awareness of all suspension points is pretty important—implicit suspension points could lead to broken invariants across suspensions.

I wonder if it would be a feasible rule to say implicit suspension is not allowed within actor-isolated methods, but okay for 'normal' async functions which aren't isolated to an actor.

5 Likes

True, but this is why reentrancy is such a problem. Implicit or explicit, any suspension completely invalidates anything you know about the actor state. Actors give you little islands of reasoning, but they are really small islands, and you shouldn't try to make a home there. Big functions with lots of suspension points mixed in-between reads/writes to actor state will be a recipe for bugs. I'd wager most actors will end up being small wrappers around an Array or Dictionary, with a bunch of methods that:

  1. Copy any relevant actor state in to locals, or set some kind of flag to say a transaction is in process and try to prevent anybody interfering with what you're about to do (e.g. inserting a task handle in to a dictionary).
  2. Start a transaction using that copied state (e.g. the download and process image example above, taken from the async/await proposal), being careful that the transaction itself doesn't directly access the actor state.
  3. Once the transaction is finished, merge its results back in to the actor state.

That's my take on it, anyway.

I quite like this idea. It would discourage people from trying to reason about actor state between suspension points by making them write a bunch of awkward boilerplate which they could avoid by writing their code a different way. It's not user-friendly, but it's ultimately for their own good.

TLDR: I think implicit suspension points are fine if our tools surface them and provide information about where they came from.

Over time, I've started to think about try and await more as granting permission to called functions rather than labeling source code, and I think this distinction is valuable when considering the async let problem. The main benefit of these markers, as I see it, has to do with maintenance: the absence of these markers means that changes in the called function won't change the behavior of the calling function without the compiler telling you about it. For me, granting permission is great, but labelling things I already understand is what feels onerous. I'd wager that even as a learning exercise "write this word here to show you understand" is not a positive experience for the user.
The novel part about async let is that the callee has the ability to potentially change behavior away from the call-site. Forcing users to label the places where this change could happen feels onerous to me, especially since they've already granted the callee permission to change the caller's behavior with async let. From this perspective, the fact that the suspension point is implicit is good... the bigger problem is that it is hidden and thus may be surprising.
This is not the only potentially surprising thing that may happen in Swift. For instance, it may be surprising when calling count on a collection is not O(1). Luckily, Swift already solves this with tooling: API docs mention this and they can be surfaced during autocomplete or in the help pane, and API that does this should call out this behavior. Without the tooling, this would not be a great situation (i.e. if folks had to go find the source file where count was declared and check that it was O(1)).
Perhaps then, tooling is the right solution to our async let problem. If Xcode (and sourcekit-lsp) highlighted potential implicit suspension points (maybe with an affordance which on hover pointed to the async lets which cause them), this would not be an issue.

Corrolary: In "On the proliferation of try..." affordances like try do and await do were discussed as an improvement for try-or-await-heavy code. The granting permission mental model allows for this since you are granting permission to a scope to throw, suspend, or both. Without tooling, this would come at the cost of decreased visibility about where behavior might change based on a callees behavior. With tooling, we would get the best of both worlds: the ability to grant permissions at a less granular level and visual indications about which API might cause changes in behavior (and where). In fact, such a thing might already be useful for more complex expressions (for instance a function call where some of the arguments might throw).

4 Likes