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!
Async
contexts form a tree, rooted byAsync.blocking
, each node associated by abody
computation. AllAsync
contexts only returns afterbody
completes.- A child
Async
context 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 currentAsync
context.
- When
body
completes, all linkedFuture
s are cancelled, and theAsync
context 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): T
Just as the name suggest,
Async.blocking
gives you anAsync
context from thin air, but in exchange, whatever suspending computation happening inbody
causesblocking
to block. Hence,blocking
only returns whenbody
gives back aT
, whichblocking
happily forwards back to the caller.Looking at it from the perspective of the call stack,
blocking
looks like this... Pretty simple, isn't it? Withblocking
, 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): T
Async.group
takes anAsync
scope, and starts a child scope under it. This scope inherits the suspend and scheduling implementation of the parentAsync
, but gives theinner_body
a scope of its own... It is actually almost the exact same asAsync.blocking
from the perspective of the caller (with anAsync
context!)Async.group
also "blocks" (suspending if possible, of course) untilinner_body
completes.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): T
Future.apply
starts a concurrent computation that is bound to the givenAsync.Spawn
scope. What does that mean? Looking at the call stack... We can see thatFuture.apply
immediately returns (tobody
) a reference to theFuture
. In the meantime, a newAsync.Spawn
scope (namedFuture.apply
here) is created, runningfuture_body
. It is linked to the originalAsync.Spawn
passed intoFuture.apply
.We can now see that
Async
scopes, with both its associatedbody
and linkedFuture.apply
'sAsync
scopes, 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
.await
ing 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 Future
s 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 .await
ed 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 CancellationException
s.