Groups, Scoping and Structured Concurrency
If you've been following the book thus far, you've seen a lot of mentions of
"an Async scope" and "sub-scoping". What exactly are those "scopes", and why
do they matter?
That's exactly what this section is about!
tl;dr: Gears Structured Concurrency
We can sum up the content of this section as below. For more details, read on!
Asynccontexts form a tree, rooted byAsync.blocking, each node associated by abodycomputation. AllAsynccontexts only returns afterbodycompletes.- A child
Asynccontext can be created by eitherAsync.group, creating a direct child context "blocking" its caller, grantingAsync.Spawn.Future.apply, branching a concurrent computation linked to the currentAsynccontext.
- When
bodycompletes, all linkedFutures are cancelled, and theAsynccontext returns after the cancellation is completely handled.
With these three properties, we guarantee that calling using Async functions will properly suspend if needed,
and cleans up properly on return.
In Gears, Async scopes form a tree
Let's look at the only three1 introduction points for an Async context in gears.async, and examine their signatures:
-
Async.blocking's signature isobject Async: def blocking[T](body: Async.Spawn ?=> T)(using AsyncSupport, Scheduler): TJust as the name suggest,
Async.blockinggives you anAsynccontext from thin air, but in exchange, whatever suspending computation happening inbodycausesblockingto block. Hence,blockingonly returns whenbodygives back aT, whichblockinghappily forwards back to the caller.Looking at it from the perspective of the call stack,
blockinglooks like this...
Pretty simple, isn't it? With blocking, you run suspending code in a scope that cleans up itself, just like returning from a function! -
Async.group's signature isobject Async: def group[T](inner_body: Async.Spawn ?=> T)(using Async): TAsync.grouptakes anAsyncscope, and starts a child scope under it. This scope inherits the suspend and scheduling implementation of the parentAsync, but gives theinner_bodya scope of its own...
It is actually almost the exact same as Async.blockingfrom the perspective of the caller (with anAsynccontext!)Async.groupalso "blocks" (suspending if possible, of course) untilinner_bodycompletes.So far so good, and our scope-stack is still a linear stack! Let's introduce concurrency...
-
Finally,
Future.apply's signature isobject Future: def apply[T](future_body: Async.Spawn ?=> T)(using Async.Spawn): TFuture.applystarts a concurrent computation that is bound to the givenAsync.Spawnscope. What does that mean? Looking at the call stack...
We can see that Future.applyimmediately returns (tobody) a reference to theFuture. In the meantime, a newAsync.Spawnscope (namedFuture.applyhere) is created, runningfuture_body. It is linked to the originalAsync.Spawnpassed intoFuture.apply.We can now see that
Asyncscopes, with both its associatedbodyand linkedFuture.apply'sAsyncscopes, now forms a tree!
To illustrate what exactly happens with linked scopes as the program runs, let's walk through the cases of interactions between the scopes!
Technically Task.start
also introduces an Async context, but it is exactly equivalent to Future.apply(Task.run()).
Interactions between scopes
.awaiting a Future
When you .await a future, the Async scope used to .await on that Future will suspend the computation until the
Future returns a value (i.e. its future_body is complete).

Note that the Async context passed into .await does not have to be the same Async.Spawn context that the Future
is linked to!
However, for reasons we'll see shortly, active Futures will always be linked to one of the ancestors of body's Async context
(or the same one).
Another thing to note is that, upon completion, the Future unlinks itself from the Async.Spawn scope.
This is not very important, as the Future already has a return value! But it is a good detail to notice when it comes to cleaning up.
Cleaning up: after body completes
What happens when body of an Async context completes, while some linked Future is still running?

In short: When body completes, all linked child scopes are .cancel()-ed, and Async will suspend until no more
child scopes are linked before returning.
This is especially important!
It means that, futures that are never .awaited will get cancelled. Make sure to .await futures you want to complete!

Cleaning up: when cancel is signalled
During a Future's computation, if it is .cancel-ed mid-way, the body keeps running (as there is no way to suddenly
interrupt it without data loss!)
However, if .await is called with the Future's Async context (i.e. a request to suspend), it will immediately
throw with a CancellationException.
The body may catch and react to this exception, e.g. to clean up resources.
The Future context will only attempt to unlink after body has returned, whether normally or by unwinding an exception.

Delaying cancellation with uninterruptible
The above .await behavior (throwing CancellationException) can be delayed within a Future by running the code under an
uninterruptible scope.
This is useful for asynchronous clean-up:
Future:
val resource = acquireResource()
try
useResource(resource)
finally
Async.uninterruptble:
resource.cleanup()/*(using Async)*/
without uninterruptble, suspensions done by resource.cleanup will again throw CancellationExceptions.