Many excellent libraries: http4s, fs2, doobie - are implemented only on the basis of type classes from Cats Effect. And libraries like ZIO and Monix, in turn, provide instances of these type classes for their effect types. Despite some problems that will be fixed in version 3.0, Cats Effect helps many open source contributors to organically support the entire functional ecosystem of the Scala language. Developers who use Cats Effect are faced with a difficult choice: which implementation of effects to use for their applications.
Today there are three alternatives:
- Cats IO, reference implementation;
- Monix, the Task data type and its reactivity in code;
- ZIO, the ZIO data type and its cross-threading scope.
In this post I will try to prove to you that to create your application using Cats Effect, ZIO is a good choice with design solutions and capabilities that are quite different from the reference implementation in Cats IO.
1. Better MTL / Tagless-Final Architecture
MTL (Monad Transformers Library) is a programming style in which functions are polymorphic by their type of effect and express their requirements through a “type class constraint”. In Scala, this is often called the tagless-final style (although it’s not the same thing), especially when the type class has no laws.
It is well known that it is not possible to define a global instance for such classic MTL type classes as Writer and State, as well as for effect types such as Cats IO. The problem is that instances of these type classes for these types of effects require access to a mutable state, which cannot be created globally, because creating a mutable state is also an effect.
For best performance, however, it is important to avoid "monad transformers" and provide the Write and State implementation directly, on top of the main effect type.
To achieve this, Scala programmers use a trick: create with effects (but cleanly) instances at the top level of their programs and then provide them further in the program as local implications:
Ref.make[AppState](initialAppState).flatMap(ref => implicit val monadState = new MonadState[Task, AppState] { def get: Task[AppState] = ref.get def set(s: AppState): Task[Unit] = ref.set(s).unit } myProgram )
Despite the fact that such a trick is useful, it is still a “crutch”. In an ideal world, all instances of type classes could be coherent (one instance per type), and not be created locally, generating effects, so that they would then magically wrap themselves in implicit values for use by subsequent methods.
A great feature of MTL / tagless-final is that you can directly define most instances on top of the ZIO data type using the ZIO environment.
Here is one way to create a global MonadState definition for a ZIO data type:
trait State[S] { def state: Ref[S] } implicit def ZIOMonadState[S, R <: State[S], E]: MonadState[ZIO[R, E, ?], S] = new MonadState[ZIO[R, E, ?], S] { def get: ZIO[R, E, S] = ZIO.accessM(_.state.get) def set(s: S): ZIO[R, E, Unit] = ZIO.accessM(_.state.set(s).unit) }
An instance is now defined globally for any environment that supports at least
State[S]
.
Similarly for
FunctorListen
, otherwise known as
MonadWriter
:
trait Writer[W] { def writer: Ref[W] } implicit def ZIOFunctorListen[W: Semigroup, R <: Writer[W], E]: FunctorListen[ZIO[R, E, ?], W] = new FunctorListen[ZIO[R, E, ?], W] { def listen[A](fa: ZIO[R, E, A]): ZIO[R, E, (A, W)] = ZIO.accessM(_.state.get.flatMap(w => fa.map(a => a -> w))) def tell(w: W): ZIO[R, E, W] = ZIO.accessM(_.state.update(_ |+| w).unit) }
And of course, we can do the same for
MonadError
:
implicit def ZIOMonadError[R, E]: MonadError[ZIO[R, E, ?], E] = new MonadError[ZIO[R, E, ?], E]{ def handleErrorWith[A](fa: ZIO[R, E, A])(f: E => ZIO[R, E, A]): ZIO[R, E, A] = fa catchAll f def raiseError[A](e: E): ZIO[R, E, A] = ZIO.fail(e) }
This technique is easily applicable to other type classes, including tagless-final type classes, instances of which may require generating effects (changes, configurations), testing functions that generate effects (combining environment effects with tagless-final), or anything else easily accessible from the environment .
No more slow monadic transformations! Let’s say “no” to creating effects when initializing instances of the type class, to local implications. No more crutches needed. Direct immersion in pure functional programming.
2. Saving resources for mere mortals
One of the first features of ZIO was interraption — the ability of the ZIO runtime to instantly interrupt any executable effect and guaranteed to free up all resources. A crude implementation of this feature hit Cats IO.
Haskell called such functionality async exception, which allows you to create and efficiently use latency, efficient parallel and competitive operations, and globally optimal calculations. Such interruptions not only bring great benefits, but also pose complex tasks in the field of supporting secure access to resources.
Programmers are used to tracking errors in programs through simple analysis. This can also be done with ZIO, which uses a type system to help detect errors. But interruption is something else. An effect created from many other effects can be interrupted at any border.
Consider the following effect:
for { handle <- openFile(file) data <- readFile(handle) _ <- closeFile(handle) } yield data
Most developers will not be surprised at this scenario:
closeFile
will not be executed if
readFile
crashes. Fortunately, the effects system has a
ensuring
(
guarantee
in Cats Effect) that allows you to add a final handler to the finalizer effect, similar to finally.
So, the main problem of the code above can be easily solved:
for { handle <- openFile(file) data <- readFile(handle).ensuring(closeFile(handle)) } yield ()
Now the effect has become “fall resistant,” in the sense that if the
readFile
breaks, the file will still be closed. And if
readFile
succeeds, the file will also be closed. In all cases, the file will be closed.
But still not quite in all. Interruption means that the effect can be interrupted everywhere, even between
openFile
and
readFile
. If this happens, the open file will not be closed and a resource leak will occur.
The pattern of getting and releasing a resource is so widespread that ZIO introduced a bracket operator, which also appeared in Cats Effect 1.0. The Bracket statement protects against interruptions: if the resource is received successfully, then release will occur even if the effect using the resource is interrupted. Further, neither the receipt nor the release of the resource can be interrupted, thus providing a guarantee of resource security.
Using bracket, the example above would look like this:
openFile(file).bracket(closeFile(_))(readFile(_))
Unfortunately, bracket encapsulates only one (rather general) resource consumption pattern. There are many others, especially with competitive data structures, access to which must be accessible for interrupts, otherwise leaks are possible.
In general, all interrupt work comes down to two main things:
- prevent interruptions in some areas that may be interrupted;
- allow interruption in areas that may freeze.
ZIO has the ability to implement both. For example, we can develop our version of bracket using low-level ZIO abstractions:
ZIO.uninterruptible { for { a <- acquire exit <- ZIO.interruptible(use(a)) .run.flatMap(exit => release(a, exit) .const(exit)) b <- ZIO.done(exit) } yield b }
In this code,
use(a)
is the only part that can be interrupted. The surrounding code guarantees
release
execution in any case.
At any time, you can check if there is an opportunity for interruptions. For this, only two primitive operations are needed (all the rest are derived from them).
This compositional full-featured interrupt model allows you to implement not only a simple implementation of bracket, but also the implementation of other scenarios in resource management, in which a balance is found between the advantages and disadvantages of interrupts.
Cats IO provides only one operation for controlling interrupts: the uncancelable combinator. It makes the whole block of code uninterrupted. Although this operation is rarely used, it can lead to a resource leak or locks.
At the same time, it turns out that you can define a primitive inside Cats IO, which allows you to achieve more control over interrupts. Fabio Labella's very complicated implementation turned out to be extremely slow.
ZIO allows you to write code with interruptions, operating at a high level with declarative compound operators, and does not force you to choose between severe complexity combined with low performance and blocking leaks.
Moreover, the recently added Software Transactional Memory in ZIO allows the user to declaratively write data structures and code that are automatically asynchronous, competitive and allow interruptions.
3. Guaranteed Finalizers
The try / finally block in many programming languages provides the guarantees that are needed to write synchronous code without leaking resources.
In particular, this block guarantees the following: if a try block starts execution, then the finally block will execute when try stops.
This warranty applies to:
- there are nested "try / finally" blocks;
- there are errors in the "try block";
- there are errors in the nested finally block.
The ZIO “ensuring” operation can be used just like try / finally:
val effect2 = effect.ensuring(cleanup)
ZIO provides the following guarantees for "effect.ensuring (finalizer)": if "effect" began to be executed, then "finalizer" will start execution when "effect" stops.
Like try / finally, these guarantees remain in the following cases:
- There are nested “ensuring” compositions;
- there are errors in the "effect";
- there are errors in the nested "finalizer".
Moreover, the guarantee is maintained even if the effect is interrupted (guarantees on the “bracket” are similar, in fact, “bracket” is implemented on “ensuring”).
The Cats IO data type provides another, weaker guarantee. For “effect.guarantee (finalizer)”, it is weakened as follows: if “effect” began to be executed, “finalizer” will start execution when “effect” stops, if the problem effect is not inserted into the “effect”.
A weaker guarantee is also found in the implementation of the “bracket” in Cats IO.
To get a resource leak, just use the effect used inside the "guarantee" or "bracket.use" effect, compose it with something like this:
// `interruptedFiber` - val bigTrouble = interruptedFiber.join
When bigTrouble is inserted in this way into another effect, the effect becomes uninterrupted - no “finalizers” set via “guarantee” or cleaning of resources through “bracket” will be executed. All this leads to a drain of resources, even when there is a “finalizer” in the block.
For example, the “finalizer” in the following code will never start execution:
(IO.unit >> bigTrouble).guarantee(IO(println("Won't be executed!!!«)))
When evaluating the code without taking into account the global context, it is impossible to determine whether an effect, such as “bigTrouble”, will be inserted anywhere in the “use” effect of the “bracket” operation or inside the finalizer block.
Therefore, you won’t be able to find out if the Cats IO program will work with resource leaks or missing “finalizer” blocks without evaluating the entire program. The whole program can only be evaluated manually, and this process is always accompanied by errors that cannot be verified by the compiler. In addition, this process must be repeated every time any important changes in the code occur.
ZIO has a custom implementation of “guarantee” from Cats Effect, “guaranteeCase” and “bracket”. Implementations use native ZIO semantics (not Cats IO semantics), which allows us to evaluate possible problems with resource leaks here and now, knowing that in all situations “finalizers” will be launched and resources will be freed.
4. Stable switching
Cats Effect has the “evalOn” method from “ContextShift”, which allows you to move the execution of some code to another execution context.
This is useful for a number of reasons:
- many client libraries force you to do some work in their thread pool;
- UI libraries require some updates to occur in the UI thread;
- some effects require isolation on thread pools adapted to their specific features.
The “EvalOn” operation performs the effect where it should be run, and then returns to the original execution context. For example:
cs.evalOn(kafkaContext)(kafkaEffect)
Note: Cats IO has a similar “shift” construct, which allows you to switch to a different context without going back, but in practice, this behavior is rarely required, so “evalOn” is preferred.
The ZIO implementation of “evalOn” (made on the ZIO primitive “lock”) provides the guarantees necessary to uniquely understand where the effect works — the effect will always be executed in a specific context.
Cats IO has a different, weaker guarantee - the effect will be executed in a certain context until the first asynchronous operation or internal switching.
Considering a small piece of code, it is impossible to know for sure whether an asynchronous effect (or nested switching) will be built into the effect that will switch, because asynchrony is not displayed in types.
Therefore, as in the case of resource security, to understand where the Cats IO effect will be launched, it is necessary to study the entire program. In practice, and from my experience, Cats IO users are surprised when, when using “evalOn” in one context, it is subsequently discovered that most of the effect was accidentally performed in another.
ZIO allows you to determine where effects should be triggered, and trust that this is how it will happen in all cases, no matter how the effects are built into other effects.
5. Security of error messages
Any effect that supports concurrency, concurrency, or secure access to resources will run into a problem with the linear error model: in general, not all errors can be saved.
This is true for both `Throwable`, a fixed error type built into Cats IO, and the polymorphic error type supported by ZIO.
Examples of situations with multiple one-time errors:
- Finalizer throws an exception;
- two (falling) effects are combined in parallel execution;
- two (falling) effects in a state of racing;
- the interrupted effect drops before leaving the section protected from interruptions.
Since not all errors are saved, ZIO provides a “Cause [E]” data structure based on a free semiring (an abstraction from abstract algebra, its knowledge is not supposed here), which allows connecting serial and parallel errors for any type of error. During all operations (including cleaning for a fallen or interrupted effect), ZIO aggregates errors into the “Cause [E]” data structure. This data structure is available at any time. As a result, ZIO always stores all errors: they are always available, they can be logged, studied, transformed, as required by business requirements.
Cats IO chose a model with loss of error information. While ZIO will connect the two errors through Cause [E], Cats IO will “lose” one of the error messages, for example, by calling “e.printStackTrace ()” on the error that occurs.
For example, an error in the “finalizer” in this code will be lost.
IO.raiseError(new Error("Error 1")).guarantee(IO.raiseError(new Error("Error 2«)))
This approach to error tracking means that you cannot locally locate and process the entire spectrum of errors that occur due to the combination of effects. ZIO allows you to use any type of error, including “Throwable” (or more specific subtypes like “IOExceptio” or another custom exception hierarchy), ensuring that no errors are lost during program execution.
6. Asynchrony without deadlocks
Both ZIO and Cats IO provide a constructor that allows you to take code with a callback and wrap it in effect
This feature is provided through the Async pipe class in Cats Effect:
val effect: Task[Data] = Async[Task].async(k => getDataWithCallbacks( onSuccess = v => k(Right(v)), onFailure = e => k(Left(e)) ))
This creates an asynchronous effect, which, when executed, will wait until the value appears, and then continue, and all this will be obvious to the user of the effect. Therefore, functional programming is so attractive for developing asynchronous code.
Notice that as soon as the callback code turns into an effect, the callback function (here it is called `k`) is called. This callback function exits with a success / error value. When this callback function is called, the execution of the effect (previously paused) resumes.
ZIO guarantees that the effect will resume execution on the runtime thread pool if the effect was not assigned to any particular special context, or to another context to which the effect was attached.
Cats IO resumes the effect on the callback thread. The difference between these options is quite deep: the thread causing the callback does not expect the callback code to be executed forever, but only allows a slight delay before the control returns. On the other hand, Cats IO does not give such a guarantee at all: the calling thread, the starting callback, may freeze, waiting for an indefinite time when the control of execution returns.
Early versions of the competitive data structures in Cats Effect (“Deferred”, “Semaphore”) resumed effects that did not return control to the calling thread. As a result, problems were discovered in them related to deadlocks and a broken execution scheduler. Although all these problems have been found, they are only fixed for competitive data structures in Cats Effect.
User code that uses a similar approach as in Cats IO will get into such troubles, because such tasks are non-deterministic, errors can only occur very rarely, in runtime, making debugging and problem detection a difficult process.
ZIO provides deadlock protection and a normal task scheduler out of the box, and also makes the user explicitly choose the behavior of Cats IO (for example, using "unsafeRun" on "Promise", which ended in a resumed asynchronous effect).
Although none of the solutions is suitable for absolutely all cases, and ZIO and Cats IO provide enough flexibility to solve all situations (in different ways), choosing ZIO means using “Async” without any worries and forces you to put the problem code in “unsafeRun”, which is known to cause deadlock
7. Compatible with Future
Using "Future" from the Scala standard library is a reality for a large number of code bases. ZIO comes with a “fromFuture” method, which provides a ready-made execution context:
ZIO.fromFuture(implicit ec => // Create some Future using `ec`: ??? )
When this method is used to wrap Future in an effect, ZIO can set where Future will be executed, and other methods, such as evalOn, will correctly transfer Future to the desired execution context. Cats IO accepts "Future", which was created with an external "ExecutionContext". This means that Cats IO cannot move the execution of Future according to the requirements of the evalOn or shift methods. Moreover, this burdens the user with determining the execution context for Future, which means narrow selection and a separate environment.
Since the provided ExecutionContext can be ignored, ZIO can be represented as the sum of the Cats IO features, guaranteeing a smoother and more accurate interaction with Future in the general case, but there are still exceptions.
8. Blocking IO
As was shown in the article “ Thread Pool. Best practices with ZIO ”, for server applications, at least two separate pools are required for maximum efficiency:
- fixed pool for CPU / asynchronous effects;
- dynamic, with the possibility of increasing the number of blocking threads.
The decision to run all effects on a fixed thread pool will one day lead to deadlock, while triggering all effects on a dynamic pool can lead to performance loss.
On the JVM, ZIO provides two operations that support blocking effects:
- "Blocking (effect" operator, which switches the execution of a certain effect in the pool of blocking streams that have good presets that can be changed if desired);
- «effectBlocking(effect)» , , .
, , , «blocking». , - , , «effectBlocking» , ZIO ( ).
Cats IO , . , «blocking», «evalOn», , , .
( ZIO) (, ), .
9.
, Scala, :
- «ReaderT»/ «Kleisli», ;
- «EitherT», ( «OptionT», «EitherT» «Unit» ).
, (, http4s «Kleisli» «OptionT»). («effect totation»), ZIO «reader» «typed error» ZIO. «reader» «typed error» , ZIO , . , «Task[A]», «reader» «typed errors».
ZIO () - . , ZIO , .
Cats IO . , , «reader» «typed errors» «state», «writer» , .
ZIO 8 Cats IO . , Scala .
10.
ZIO , , . , Scala, .
ZIO 2000 , «typed errors» , — 375 . Scala , . , , .
:
- ;
- ;
- , ;
- .
. , - , .
- . , . ZIO . Cats IO , , ZIO ( , ).
11.
ZIO , , - .
- , : «ZIO. succeed» «Applicative[F].pure», «zip» «Apply[F].product», «ZIO.foreach» «Traverse[F].traverse».
- (Cats, Cats Effect, Scalaz ).
- , ( «Runtime», Cats Effect - Cats Effect). — Cats IO.
- .
- . : "zip"/"zipPar", "ZIO.foreach"/"ZIO.foreachPar", "ZIO.succeed"/"ZIO.succeedLazy«.
- , «». ZIO IDE.
- Scala ZIO : «ZIO.fromFuture», «ZIO.fromOption», «ZIO.fromEither», «ZIO.fromTry».
- «».
, Scala, , ZIO , , , ZIO, . Cats IO , Cats.
, , , ( , , ).
12.
ZIO — - , .
:
- , «Ref», «Promise», «Queue», «Semaphore» «Stream» //;
- STM, , , ;
- «Schedule», ;
- «Clock», «Random», «Console» «System» , ;
- , .
- Cats IO . Cats IO , ( ) .
Conclusion
Cats Effect Scala-, , .
, Cats Effect, , Cats Effect : Cats IO, Monix, Zio.
, . , , , : ZIO Cats Effect .
Scala — . , Scala. ScalaConf , 18 , John A De Goes .