A quick look at scala-async

Intro

Scala-async is a library for asynchronous programming. It provides a simple API to handle asynchronous method calls using the imperative way, which in some cases is more convenient.

It was first added in C# and F# and then ported to other languages like Scala, Python, Javascript etc. Sometimes you will find this library or the concept behind it to be referred as Async/Await because of the 2 methods it relies on.

The Scala implementation uses macros in order to transform imperative and synchronous-like code into asynchronous and non-blocking. We'll find out what happens under the hood in the following sections.

Usage

To use scala-async library add this dependency to your build.sbt file:

libraryDependencies += "org.scala-lang.modules" %% "scala-async" % "0.9.6"

For Maven projects add the dependency to pom.xml:

<dependency>
    <groupId>org.scala-lang.modules</groupId>
    <artifactId>scala-async_2.12</artifactId>
    <version>0.9.6</version>
</dependency>

Note: Make sure to match the Scala version used in your project with the artifactId prefix.

import scala.concurrent.ExecutionContext.Implicits.global
import scala.async.Async._

val future1: Future[Int] = ...
val future2: Future[Int] = ...

val asyncComputation = async {
    await(future1) + await(future2)
}

The syntax is very simple. The asynchronous code is enclosed in an async block and it uses await for suspending the computation and reading the value.

The signature of async is similar to Future:

def async[T](body: => T)(implicit execContext: ExecutionContext): Future[T]

And for await:

 def await[T](awaitable: Future[T]): T

Note: await calls cannot be done outside the async block.

Under the hood

Before talking about the internals and what happens under the hood it's worth mentioning that if you are a beginner you might want to skip this section. At this point it's more than enough to know that this library is asynchronous and non-blocking. So, don't be afraid to use it.

For those who are interested what happens under the hood, scala-async utilises macros for converting blocking-like code into non-blocking.

The process of transforming the async block using macro expansion consists of two steps:

  1. A Normal Form (ANF)
  2. State machine
ANF

Roughly this is a preparation step before generating the state machine. It involves:

  • Replacing if and match expressions with the statement version. The result of each branch is written into a var.
  • Replacing async with Future.apply calls.
State machine

After the ANF transform the code will be split into chunks, await calls being the splitting points.

The following things happen during the state machine creation:

  • A state machine class is created with some states, a paremeterless apply method for starting the computation and also apply which takes a Try[Any] parameter which is called on completion of each future.
  • Each chunk is moved into a separate branch of state pattern matching. Once executed each chunk/branch makes the transition to the next state.
  • If any of the computation fails, the computation stops and a failed Future is returned.

The generated code by macro expansion of the example above can be found here.

When to use it

The biggest questions about this library are why and when to use it and what are the benefits over for-comprehensions or the traditional Future API.

Before answering the question let's have a look at a very simple example. Suppose we have two futures (for the sake of simplicity I've created two simple future computations; it doesn't change the essence anyway):

val future1: Future[Int] = Future.successful(40)
val future2: Future[Int] = Future.successful(2)

Bellow we have three different ways to deal with future computations.

This is what the computation using scala-async looks like:

val asyncComputation = async {
  await(future1) + await(future2)
}

The for-comprehension version:

val forComprehensionComputation =
  for {
    res1 <- future1
    res2 <- future2
  } yield res1 + res2

And the Future API:

val futureAPIComputation =
  future1.flatMap { res1 =>
    future2.map { res2 =>
      res1 + res2
    }
  }

The answer of the question above is ... of course - it depends... It is the same as when to use for-comprehension or the traditional monadic API. Sometimes it's easier to reason about some piece of code when using for-comprehension and sometimes - the monadic API.

If the business logic is complex and may result in lots of nested flatMap/map calls and it cannot be broken down in a such way so the for-comprehension is readable and easy to understand then most probably async/await would be a better option here. Also, it may be a good start for people coming from the imperative languages who are about to learn some asynchronous programming.

Limitations

Unfortunately there are some limitations on await. Just to name a few of them, where await cannot be used:

  • inside try/catch blocks
  • inside closures
  • in boolean short circuit (&& and || not allowed, use the strict version | and &)
  • as guards in pattern matching