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:
- 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
- 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:
- Monix had
Task
- FS2 had their own
Task
- 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! “