`async let` and scoped suspension points

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