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,
Asyncrepresents 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,
Asyncholds 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.blockinggives you a "root"Asynccontext, given implementations of the supporting layer (neatly provided byimport gears.async.default.given!).This defines a "scope", not too different from
scala.util.Usingorscala.util.boundary. As usual, the context provided within the scope should not escape it. Likescala.util.boundary'sboundary.Label, theAsynccontext will be passed implicitly to other functions takingAsynccontext. Note thatAsync.blockingis the only provided mean of creating anAsynccontext out of "nothing", so it is easy to keep track of when adoptinggearsinto your codebase! -
countToreturnsUnitinstead ofFuture[Unit]or an equivalent. This indicates that *callingcountTowill "block" the caller untilcountTohas returned with a value. Noawaitneeded, andcountTobehaves just like a normal function!Of course, with the
Asynccontext passed,countTois allowed to perform operations that suspends, such assleep(which suspends theAsynccontext 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-
Asyncfunctions. -
Within
countTo, note that we callsleepunder a function passed intoSeq.foreach, effectively capturingAsyncwithin the function. This is completely fine:foreachruns the given function in the sameAsynccontext (not outside nor in a sub-context), and does not capture the function. Our capability and scoping rules is maintained, andforeachisAsync-capable by default!While this illustrates the above fact, we could've just written the function in a familiar fashion with
forcomprehension:/** 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
Asynccontexts when taking in function arguments (() => TandAsync ?=> Tboth works), simply take() => TandAsync-aware blocks will inherit the context from the caller!One obvious example is
Seq.foreachfrom 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
Asynccontext 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
Asynccontext.
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.