Monix vs Cats-Effect

I’m the author of Monix and a major contributor to Cats-Effect.

This post describes the relationship between Monix and Cats-Effect, their history and when each should be used.

But to start with the most pressing concern …

Monix’s Task vs cats.effect.IO

cats.effect.IO and monix.eval.Task are very similar, plus I ended up working on both, so design decisions I made for Task ended up in IO as well. That said IO is designed to be a simple, reliable, pure reference implementation, whereas Task is more advanced if you care about certain things.

For example Task’s run-loop is designed to provide certain fairness guarantees, depending on configuration. By default its run-loop does processing in batches, introducing thread forks once over a threshold. If you’re using Task or IO as some sort of green threads, and you should because they are really good for that, then Task provides scheduling fairness out of the box, whereas your logic via IO will have to include manual IO.shift calls.

// Cats-effect
def fib(n: Int, a: Long, b: Long): IO[Long] =
  IO.suspend {
    if (n <= 0) IO.pure(a) else {
      val next = fib(n - 1, b, a + b)
      // Insert async boundary every 128 cycles
      if (n % 128 == 0) IO.shift *> next else next      
    }
  }

// Monix simply does it ;-)
def fib(n: Int, a: Long, b: Long): Task[Long] =
  Task.suspend {
    if (n <= 0) Task.pure(a) 
    else fib(n - 1, b, a + b)
  }

This week I’ve released version 0.10 of cats-effect with the cancelable IO. Before this it was only Monix’s Task that was cancelable and actually usable in concurrent scenarios (e.g. race conditions), so I imported the same underlying design that the other maintainers agreed to. That said the IO implementation is more conservative still. In both implementations the cancelability of a task has to be opt-in, however with Task you can opt into auto-cancelable flatMap loops, whereas with IO you need manual calls to IO.cancelBoundary.

// Cats-effect
def fib(n: Int, a: Long, b: Long): IO[Long] =
  IO.suspend {
    if (n <= 0) IO.pure(a) else {
      val next = fib(n - 1, b, a + b)
      // Check cancelation status every 128 cycles
      if (n % 128 == 0) IO.cancelBoundary *> next else next
    }
  }

// Monix
def fib(n: Int, a: Long, b: Long): Task[Long] =
  Task.suspend {
    if (n <= 0) Task.pure(a) 
    else fib(n - 1, b, a + b)
  }

// Cancelability is still opt-in, but can be decided at 
// the call-site for this one:
fib(1000, 0, 1).cancelable

So IO’s design is to be very explicit about forking or cancelability, whereas Task affords some smartness in it, doing the right thing out of the box, but with easy configuration options.

Task is also designed to interact better with the impure side and you’ll have an easier time to get it adopted in hybrid projects. For example Task#runAsync calls return CancelableFuture results. And Task.fromFuture can recognize CancelableFuture too, which means that this equivalence really holds as you lose no information:

Task.deferFuture(task.runAsync) <-> task

With IO you have unsafeToFuture, but the above equivalence does not hold for IO. It’s not the same thing, the same level of integration, because IO doesn’t care about what happens after the “edge of the FP program”.

Task also has a memoize that allows you to (lazily) cache executing tasks, but that in certain cases could break RT (since allocation of a mutable ref is a side effect), so you have to be a big boy when using it, but it’s awesome if you’ve got interactions with the other side. So to turn any Task into a lazily evaluated value (the thread-safe, asynchronous equivalent of Scala’s lazy val) all you need is:

task.memoize

See issue #120 for why this won’t happen for IO.

Task also requires a Scheduler in its runAsync, which gets injected everywhere internally, so you don’t need an ExecutionContext for operations like sleep or shift or whatnot. We fixed this ExecutionContext dependency for IO recently by creating an indirection in Timer, but it’s still there. This matters in certain scenarios - for example with Task you get a Task.deferFutureAction which gives you an ExecutionContext for wrapping Future enabled APIs, so you no longer have any need to carry around that ExecutionContext, which isn’t possible with IO.

Also Monix has TaskLocal, which is like a ThreadLocal, but for Task. You can’t implement such a thing for IO without modifying its run-loop, because you need to ensure that values get transported over async boundaries.

This will not be the end of it. Improvements in run-loop execution that we (the Monix developers) come up with, will make it into Task first and might not make it into IO if it is against its principles.

What should I use?

Task is designed for smart control in concurrent scenarios, for fairness and for interoperability with the impure side of the project, design goals that go against IO’s design. If you’re a user of monix.eval.Task, then know that Task will always be the more advanced one.

The IO in Cats-effect is designed for simplicity and the API follows the WYSIWYG principle.

Both have virtues. But that is why the cats-effect project also provides type classes, such that projects that can provide polymorphic abstractions can work with both. At this point projects like Doobie, FS2, Http4s or Monix’s own Iterant allow you to use both Task and IO.

So keep using Task if you already do, or look into it, because it’s awesome ;-) And if you decide to use cats.effect.IO instead, that’s fine too.

History of Monix

Monix started at the beginning of 2014 as a collection of concurrency tools, augmenting Scala’s standard library, but then it evolved into providing a full fledged ReactiveX implementation that was back-pressured with a protocol based on Scala’s Future and with opinions™ in order to keep an idiomatic Scala style, very unlike RxJava / RxScala. Back then, RxJava was not back-pressured and Akka Streams did not exist, so this was solving a very real problem for me.

In 2014 this project was named Monifu.

In 2016 something happened — I pushed the project in Typelevel’s incubator and its path was set by this comment by Miles Sabin:

@alexandru would you be willing to explore evolving Monifu in a direction which makes a more statically enforced separation between effectful and non-effectful parts of the API? @tpolecat would that address your concerns, and would you be willing to help?

More generally, do we think that activity around this would usefully feed into the design of a Cats alternative to Task/IO?

Being very enthusiastic about having my project accepted as a member of a young and cool community, I accepted.

Then on Dec 30, due to an existential crisis that happens to me around New Year’s Eve, I also renamed the project to Monix. Not late after that, the first (slow, shitty and broken) version of Task was unleashed on the unsuspecting public.

Well, from March until May, when I attended flatMap(Oslo), apparently I got my shit together and had a pretty awesome Task implementation on my hands. And the presentation went great.

Since then Monix has evolved other capabilities, now in version 3.0.0 also providing Iterant, an abstraction for purely functional pull-based streaming. Documentation and details to follow at the final 3.0.0.

But being a modular library, the interesting thing to me is that thus far some people only use it for Task, while others only use it for Observable, treating Task like RxJava’s Single, aka an Observable of one event. Monix is one of the very few libraries that happens to be at the intersection of two worlds, exposing its users to the benefits of both:

  1. The world of Java and .NET developers that have bitten from the forbidden fruit of FP due to ReactiveX libraries, but never really making the jump
  2. The world of Cats developers that need abstractions for describing effects, that need the IO monad

And I’m damn proud of it.

History of Cats-Effect

Sometimes in Feb 2017 the situation looked like this:

  1. Monix had Task
  2. FS2 had their own Task
  3. Scalaz 7 had its own Task

My Task implementation was cancelable and was very competitive, continuously improved for best performance, as you may know. FS2’s Task and Scalaz’s Task were behind. The problem, especially for Cats’ ecosystem was that we had two working Task implementations on our hands, but libraries like Http4s and Doobie, had to pick one.

I happened to attend NEScala 2017, where I’ve heard of libraries standardizing on Cats at the expense of Scalaz. Up until this point Monix (series 2.x) had support for both Cats and Scalaz via optional sub-projects, by providing orphaned instances. This was very far from ideal however, pushing all of this complexity to the users, when an approach such as shims is far superior for both Cats and Scalaz users.

At the same NEScala event, at its “Unconference”, I participated in a discussion with other members of Typelevel, to decide what to do next. I remember having a heated argument with Daniel about cancelability. As you may or may not know, he wasn’t agreeing to the idea of cancelable tasks, due to the bad experience of Scalaz 7.

Anyway, it was decided that something had to be done. So given my experience and short involvement in the discussions for reactive-streams.org, which Monix’s Observable was already implementing, I felt the need for a similar interoperability protocol between Task-like data types. I also thought of this for selfish reasons. I did not want my Task implementation to wither away by de jure standardization, because I believed in my approach and wanted it to succeed, therefore if there was going to be a proposal, I wanted to be a part of it.

Therefore I submitted a proposal for a project named Schrodinger (later renamed to effects4s), an interop project that was supposed to be the “reactive streams” protocol, but for task data types and a neutral ground between Cats and Scalaz.

Well, it did not go well. The Scalaz issue received no interest and the Typelevel issue was eventually eclipsed by cats-effect by Daniel Spiewak.

It did not go well because my proposal was deeply flawed. I did not want a “reference IO” in it and the type classes themselves where modeled for getting data out, or in other words the emphasis was on converting between data types and not on enabling constrained parametric polymorphism. I was wrong about my type class design and Daniel Spiewak was right.

Leaving pride aside, I warmed up to it and started contributing. And I’ve been the author of 41 PRs, because I may not be experienced in type class design, but I can sure optimize an IO’s run-loop and optimize I did, repeatedly.

Trouble on the Horizon

You see, I’m a very competitive individual, so for example when John De Goes used benchmarks showing dramatic improvements over Cats-Effect in his presentation (at ScalaIO and Scale by the Bay), well I couldn’t help myself but to follow up with no less than one, two, three batches of optimizations.

Most of the changes I did were already in use by Monix’s Task. Via these optimizations I effectively replaced the internals of cats.effect.IO. Daniel’s first implementation was more elegant, but mine was more efficient.

I also had breakthroughs in Monix of course, with one, two batches. Less impact though, due to Monix’s Task already reaching its potential for many use cases.

I then realized that my monix.tail.Iterant is effectively blocked by the type class hierarchy not exposing type classes for IO/Task data types that can be canceled.

So being on a roll, I followed with PRs for the cancelable IO proposal, followed by Timer, type class changes (preceded by a failed attempt), and race, culminating in the v0.10 release.

All of this ties into Monix of course, Task along with the new Iterant data type making full use of Timer and cancelability. So now you’ve got yourself a credible, pull-based competitor to FS2 as well 😉

This is Sparta!