Cats-effect, an overview

After many applications written using Scala’s Futures, Akka Actors or Monix,… Cats-effect is now my favourite stack to write Scala programs. Why is that?

Well, it makes your code easier to write and reason about while providing good performances.

Intro

In functional programming an effect is a context in which your computation operates:

  • Option models the absence of value
  • Try models the possibility of failure
  • Future models the asynchronicity of a computation

These effects have nothing to do with side-effects. They’re just contexts in which your code operates.

On the other hand a side-effect happens when a computation interacts with the outside world like printing something on the screen, reading or writing to a file … or something as simple as getting the current time or updating a variable outside the current scope.

All these operations, when replayed, produce different results or leave the outside world in a different state.

The side-effects are the things that make a program hard to reason about because they are not referential transparent. An expression is said to be referential transparent when it can be replaced by its value without changing the program.

Let’s consider this basic example:

// referential transparent
def n = 3 println(s"$n + $n") // prints "3 + 3"
println("3 + 3") // replaces n by its value and prints the same thing

// not referential transparent
def n = {
   print("n")
   3
}
println(s"$n + $n") // prints "nn3 + 3"
// not the same as
println("3 + 3")

The same reasoning applies to Scala’s Future as just creating the Future is enough to kick-off the computation (which is also memoized)

// here f1 and f2 run concurrently
val f1 = Future { ... }
val f2 = Future { ... }
for {
   a <- f1
   b <- f2
} yield (a, b) 

// here f1 and f2 run sequentially 
for {
    a <- Future { ... }
    b <- Future { ... }
} yield (a, b)

This is why side-effects are hard to reason about. Yet we need them because this is how a program interacts with the world. This is what makes a program useful. If the above program would not be able to place an order it wouldn’t be so useful.

So can we have our cake and it eat too? Can we have side-effects and referential transparency?

Well, yes if you wrap them in a context. This context is called IO and it’s the effect to deal with side-effect. IO indicates that the computation interact with the outside world (Input/Output) and this is the core of the cats-effect library.

Principle

How is that possible? Well there is only one way to make a code that performs side-effects referentially transparent and it’s to not run it!

This is exactly what the IO effect does. It wraps a computation but doesn’t run it.

val n = IO {
  print("n")
  3
}

This code doesn’t print anything (unlike Future). It’s just a description of what needs to be done and nothing happens until you explicitly run this computation.

Now you can see why this is so powerful. By not running the code straight away we gain a lot in composition. We can assemble a whole program without running anything, and only run it, once we’re ready.

You run an IO by calling one of the “unsafe” method that it provides:

  • unsafeRunSync: runs the program synchronously
  • unsafeRunTimed: runs the program synchronously but abort after a specified timeout (useful for testing)
  • unsafeRunAsync: runs the program asynchronously and execute the specified callback when done
  • unsafeToFuture: runs the program and produces the result as a Scala Future (useful to integrate with other libraries/framework).

Conditionals

By not running the code straight away we get additional capabilities. E.g. it’s possible to decide to run a computation after declaring it:

val isWeekday = true
for {
   _ <- IO(println("Working")).whenA(isWeekday)
   _ <- IO(println("Offwork")).unlessA(isWeekday)
} yield ()

Asynchronous IO

So far we have only created synchronous IO. i.e. computations that can be run straight away on the current thread. However sometimes you need to interact with remote systems and in this case you need an asynchronous IO. An asynchronous IO is created by passing a callback that is invoked when the computation completes. The signature of IO.async may look scary

def async[A](k: (Either[Throwable, A] => Unit) => Unit): IO[A]

but it just takes a function that given a callback Either[Throwable, A] => Unit returns Unit

.

This is especially useful to convert between a Java Future to IO

def fromCompletableFuture[A](f: => CompetableFuture[A]): IO[A] =
  IO.async { callback =>
    f.whenComplete { (res: A, error: Throwable) =>
      if (error == null) callback(Right(res))
      else callback(Left(error))
    } 
  }

The same logic applies to convert a Scala Future to IO but there is already a IO.fromFuture method available. Note that this method takes a IO[Future[A]] as an argument to avoid passing an already completed future.

Brackets

IO being lazy (it doesn’t evaluate strait away) it’s possible to make sure all the resources used in the computation are released properly no matter the outcome of the computation. Think of it as a try / catch / finally. In cats-effect this is called Bracket

// acquire the resource
IO(new Socket("hostname", 12345)).bracket { socket =>
  // use the socket here
} { socket =>
  // release block
  socket.close()
}

The bracket makes sure that the release block is called whatever happens with the resources in the usage block.

There’re more variants on this. E.g. bracketCase allows to consider the outcome of the execution when releasing the resource

Resources

Resource builds on top of Bracket. It allows to acquire resources (and making sure they are released properly) before running your computation. Moreover Resource is composable so that you can acquire all your resources at once.

def acquire(s: String) =
  IO(println(s"Acquire $s")) *> IO.pure(s)

def release(s: String) = IO(println(s"Releasing $s"))

val resources = for {
   a <- Resource.make(acquire("A"))(release("A")
   b <- Resource.make(acquire("B"))(release("B")
} yield (a ,b) 

resources.use { case (a, b) =>
    // use a and b
} // release code is automatically invoked when computation finishes

Cancellation

Remember the asynchronous IO that we created by passing a continuation (or callback). The signature was Callback => Unit (where Callback is Either[Throwable, A] => Unit). Now if instead of having a Callback => Unit we have a Callback => IO[Unit] we have a computation that we can run (IO[Unit]) to cancel the async task. Back to our Java CompletableFuture we now have:

def fromCompletableFuture[A](f: => CompletableFuture[A]): IO[A] =
  IO.cancellable { callback =>
    f.whenComplete(res: A, error: Throwable) =>
      if (error == null) callback(Right(res))
      else callback(Left(error))
    IO(f.cancel(true))
  }

Now if we cancel our IO[A] what’s going to happen is that the cancellation token (IO(f.cancel())) is going to run, trying to cancel the underlying future.

Note the cancellation is a concurrent action and can only be enforced at asynchronous boundaries. You must also consider what could happen if you cancel (e.g. close or release a resource) while the computation is running (e.g. the resource is being used).

This point is very well explained in the cats-effect documentation.

The cats effect type-classes

So far we’ve only focused on IO which is at the heart of cats-effect, and as we’ve seen it’s a really powerful beast. That’s ok to use in a simple application effect but it’s not recommended for writing more complex application or libraries and services.

Instead it’s always preferable to use the least powerful type-class needed for the job. That makes it clear what abstraction is needed and as a bonus it also make testing easier.

cats-effect type classes
  • Bracket safely acquire and release resources
  • Sync suspend a synchronous computation (stack-safe)
  • Async for asynchronous computations running outside of the main program
  • Concurrent concurrently start or cancel computations
  • Effect lazy evaluation of asynchronous computation
  • ConcurrentEffect cancellation and concurrent executions of asynchronous computation
  • LiftIO converts from IO to F

Effect provide a “safe” run methods as opposed to the unsafeRun methods available with other classes.

unsafeRun do run the computation and return the result (or the result wrap in an already running context, e.g. Future) …

Safe run methods on the other hand do not execute any code but show an intent to run the code. They return a SyncIO instance which can be converted to F with a LiftIO instance.null

Concurrency

Fibers

Everything that supports Concurrent can be started. When you start an asynchronous computation you get back a Fiber. A Fiber holds the result of a computation and can be think of as a “green” or “logical” thread.

They are light-weight (unlike JVM Threads) so it’s possible to start many of them and like threads they can be started and joined.

Async boundaries

An important point to understand is that cancellation can only happen when the execution crosses an asynchronous boundary. But what is an asynchronous boundary exactly? Well, it’s just when you give the processor a chance to do a context shift.

And for that cats-effect provides a ContextShift with 2 methods:

  • shift creates a logical fork (places an async boundary)
  • evalOn runs the computation on another ExecutionContext

Unlike Future which created a context shift for every operation (map, flatMap, …) and resulted in pretty poor performances, cats-effect gives you back control when to place such boundaries, achieving far better performances at the same time.

Concurrency helpers

Cats-effect provides a bunch of useful data structures to deal with concurrency.

Ref

The first one is called Ref and is similar to an AtomicRef. It always holds a value (can’t be empty but can hold an Option ;-)).

Deferred

Deferred is like a Promise. It is created empty and can be completed only once. When a consumer calls get it blocks (no, it doesn’t block a thread but its computation is suspended) until a producer calls complete and provides a value. complete can only be called once.

MVar

MVar is like a Queue of size 1 where consumer block when empty and producers block when full.

Semaphore

Manages a number of permit. Users block on acquire when no permits are available.

And more…

Cats-effect also provide a Clock service which is just a wrapper to get the current time. It can be mocked easily to facilitate testing. Quite basic but always useful.

And for anything more complex (Queues and streams) there is the awesome fs2 library.

Conclusion

For me cats-effect is a game changer. It provides simple and powerful abstraction that takes most of the complexity away to reason about a program while offering very decent performances out-of-the-box.

If you’re still using Scala Future and the likes, give it a try, you won’t be coming back (plus the documentation is really good).