An "Async" function
The central concept of Gears programs are functions taking an Async
context,
commonly referred to in this book as an async function.
import gears.async.*
def asyncFn(n: Int)(using Async): Int =
// ^^^^^ an async context
AsyncOperations.sleep(n * 100 /* milliseconds */)/*(using Async)*/
n + 1
We can look at the Async
context from two points of view, both as a capability
and a context:
- As a Capability,
Async
represents the ability to suspend the current computation, waiting for some event to arrive (such as timeouts, external I/Os or even other concurrent computations). While the current computation is suspended, we allow the runtime to use the CPU for other tasks, effectively utilizing it better (compared to just blocking the computation until the wanted event arrives). - As a Context,
Async
holds the neccessary runtime information for the scheduler to know which computation should be suspended and how to resume them later. It also contains a concurrency scope, which we will see later in the structured concurrency section.
However, different from other languages with a concept of Async functions, gears
's async functions are
just normal functions with an implicit Async
parameter!
This means we can also explicitly take the parameter as opposed to using Async
:
def alsoAsyncFn(async: Async): Int =
asyncFn(10)(using async)
and do anything with the Async context as if it was a variable1!
Passing on Async
contexts
Let's look at a more fully-fledged example, src/scala/counting.scala
:
//> using scala 3.4.0
//> using dep "ch.epfl.lamp::gears::0.2.0"
//> using nativeVersion "0.5.1"
import gears.async.*
import gears.async.default.given
/** Counts to [[n]], sleeping for 100milliseconds in between. */
def countTo(n: Int)(using Async): Unit =
(1 to n).foreach: i =>
AsyncOperations.sleep(100 /* milliseconds */ ) /*(using Async)*/
println(s"counted $i")
@main def Counting() =
Async.blocking: /* (async: Async) ?=> */
countTo(10)
println("Finished counting!")
(if you see //>
directive on examples, it means the example can be run self-contained!)
Let's look at a few interesting points in this example:
-
Async.blocking
:Async.blocking
gives you a "root"Async
context, given implementations of the supporting layer (neatly provided byimport gears.async.default.given
!).This defines a "scope", not too different from
scala.util.Using
orscala.util.boundary
. As usual, the context provided within the scope should not escape it. Likescala.util.boundary
'sboundary.Label
, theAsync
context will be passed implicitly to other functions takingAsync
context. Note thatAsync.blocking
is the only provided mean of creating anAsync
context out of "nothing", so it is easy to keep track of when adoptinggears
into your codebase! -
countTo
returnsUnit
instead ofFuture[Unit]
or an equivalent. This indicates that *callingcountTo
will "block" the caller untilcountTo
has returned with a value. Noawait
needed, andcountTo
behaves just like a normal function!Of course, with the
Async
context passed,countTo
is allowed to perform operations that suspends, such assleep
(which suspends theAsync
context for the given amount of milliseconds).This illustrates an important concept in Gears: in most common cases, we write functions that accepts a suspendable context and calling them will block until they return! While it is completely normal to spawn concurrent/parallel computations and join/race them, as we will see in the next chapter, "natural" APIs written with Gears should have the same control flow as non-
Async
functions. -
Within
countTo
, note that we callsleep
under a function passed intoSeq.foreach
, effectively capturingAsync
within the function. This is completely fine:foreach
runs the given function in the sameAsync
context (not outside nor in a sub-context), and does not capture the function. Our capability and scoping rules is maintained, andforeach
isAsync
-capable by default!While this illustrates the above fact, we could've just written the function in a familiar fashion with
for
comprehension:/** Counts to [[n]], sleeping for 100milliseconds in between. */ def countTo(n: Int)(using Async): Unit = for i <- 1 to n do AsyncOperations.sleep(100.millis) println(s"counted $i")
That's all for now with the Async
context. Next chapter, we will properly introduce concurrency to our model.
Aside: Pervasive Async
problem?
At this point, if you've done asynchronous programming with JavaScript, Rust or C# before, you might wonder if Gears is offering a solution that comes with the What color is your function? problem.
It is true that the Async
context divides the function space into ones requiring it and ones that don't,
and generally you need an Async
context to call an async function2.
However:
-
Writing async-polymorphic higher order functions is trivial in Gears: should you not be caring about
Async
contexts when taking in function arguments (() => T
andAsync ?=> T
both works), simply take() => T
andAsync
-aware blocks will inherit the context from the caller!One obvious example is
Seq.foreach
from above. In fact, all current Scala collection API should still work with no changes. Of course, if applied in repeat the function will be run sequentially rather than concurrently, but that is the expected behavior ofSeq.foreach
. -
The precense of an
Async
context helps explicitly declaring that a certain function requires runtime support for suspension, as well as the precense of a concurrent scope (for cancellation purposes). Ultimately, that means the compiler does not have to be pessimistic about compiling all functions in a suspendable way (a la Go), hurting both performance and interopability (especially with C on Scala Native).For the user, is also a clear indication that calling the function will suspend to wait for (in most cases) external events (IO, filesystem, sleep, ...), and should prepare accordingly. Likewise, libraries using Gears should not be performing such tasks if they don't take an
Async
context.
With that in mind, it is useful (as with all languages with async functions) to treat Async
like a capability,
only pass them in functions handling async operations, and try to isolate business logic into functions that don't.
While in principle this is possible, capability and scoping rules apply to the Async context: functions taking
Async
capabilities should not capture it in a way that stays longer than the function's body execution.
In the future, capture checking should be able to find such violations and report them during the compilation process.
Technically Async.blocking
can be used to call any async function without an async context,
you should be aware of its pitfalls. That said, if you are migrating from a synchronous codebase,
they are functional bridges between migrated and in-progress parts of the codebase.