I've seen this in C#, Hack, and now Kotlin:
await or an equivalent operation can only be performed in special "async" contexts. Return values from these are, to borrow Hack's terminology, "awaitable" in turn, so the special async-ness of some low-level async system function call bubbles to the top unless it transformed to a synchronous operation. This partitions the codebase into synchronous and asynchronous ecosystems. After working for a while with async-await in Hack, however, I'm beginning to question the need. Why does the calling scope need to know that it's calling an async function? Why can't async functions look like sync functions that just happen to throw control somewhere else on occasion?
I've found all of the distinctiveness of async code I've written to come from three consequences:
- Race conditions are possible when two parallel coroutines share common state
- Time information about the transient/unresolved state might be embedded in async objects, which can enforce certain ordering rules
- The underlying work of a coroutine can be mutated by other coroutines (e.g. cancellation)
I'll concede the first one is tempting. Annotating an ecosystem as
async screams "beware: race conditions might live here!" However, attention to race conditions can be completely localized to combining functions (e.g.
(Awaitable<Tu>, Awaitable<Tv>, ...) -> Awaitable<(Tu, Tv, ...)>) since without them, two coroutines cannot execute in parallel. Then, the problem becomes very specific: "make sure all terms of this combining function do not race." This is beneficial to clarity. So long as it's understood that combining functions are useful for async code (but obviously not limited to it; async code is a superset of sync code), and there are a finite number of canonical ones (language constructs as they often are), I feel that this better communicates the risks of race conditions by localizing their sources.
The other two are a matter of language design by how the lowest-level async objects are represented (Hack's
WaitHandles for instance). Any mutation of a high-level async object is necessarily confined to a set of operations against the underlying low-level async objects that come from system calls. Whether or not the calling scope is synchronous is irrelevant, since mutability and the effects of mutation are purely functions of that underlying state at a single point in time. Aggregating them into a nondescript async object does not make the behavior any clearer — if anything, to me, it obscures it with the illusion of determinism. This is all moot when the scheduler is opaque (as in Hack and, from what I gather, Kotlin as well) where the information and mutators are hidden anyways.
Otherwise, the result is all the same for the calling scope: it eventually gets a value or an Exception and does its synchronous thing. Am I missing a part of the design thinking behind this rule? Alternatively, are there examples where async function contracts are indistinguishable from synchronous ones?