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.