2.0.0
The future of asynchronous and concurrent programming is finally here. We are excited to announce the release of ZIO 2.0!
Since we started on the journey to ZIO 2.0 we have focused on four key themes:
- Performance - Frameworks such as ZIO are the base layer that higher level solutions are built on so they must be extremely efficient so you can build high performance applications on top of them.
- Developer Experience - Just as important, you must be able to quickly and joyfully write clear, bug free code with ZIO.
- Operations - ZIO must scale with you to support the needs of the most complex industry scale application with support for logging, metrics, and execution tracing.
- Streams - Streaming is a fundamental paradigm for describing data flow pipelines and ZIO must provide support for it in a way that is both highly performant and deeply principled.
We believe we have achieved all of these goals to a greater extent than we could have hoped for when we started this journey.
These release notes will provide a high level overview of what we have done with regard to each of these points but when you are upgrading to ZIO 2.0 we would encourage you to check out the detailed migration guide available here. The migration guide also contains information about the automated migration tool you can use to do most of the heavy lifting for you.
Performance
ZIO 2.0 ships with the first ever "third generation" runtime that breaks new ground, avoiding use of the JVM stack whenever possible. This delivers dramatically higher performance than other runtimes for synchronous code, as shown in the following benchmark between ZIO 1.0, Cats Effect 3, and ZIO 2.0 that is typically used to compare runtimes.
[info] Benchmark (depth) Mode Cnt Score Error Units
[info] ZIO 1.x 20 thrpt 20 647.268 ± 12.892 ops/s
[info] Cats Effect 3.x 20 thrpt 20 947.935 ± 124.824 ops/s
[info] ZIO 2 20 thrpt 20 1903.838 ± 21.224 ops/s
This runtime is highly optimized for Project Loom because in a post-Loom world there is never a need for asynchronous operations.
We will be continuing to optimize the performance of the new runtime for asynchronous operations and publish additional information regarding the new runtime. Until then, you can check out this blog post by John De Goes to learn more about the motivations and development process for the new runtime.
ZIO 2.0 also comes with a variety of other optimizations to improve performance of your application and prevent bottlenecks.
For example, appropriately managing blocking code is a typical "gotcha" for developers.
Runtimes such as ZIO typically perform all work on a small number of threads, typically equal to the number of operating system threads, to minimize contention and context switching. However, this means that if blocking work is inadvertently run on the core threadpool it can have a very negative impact on application performance, potentially resulting in deadlock.
ZIO 1.0 was the first runtime to provide tools to manage this through its Blocking
service, which was later copied by other runtimes. However, this still required users to manually identify blocking work, which could be an error prone process, especially when legacy APIs did not clearly document blocking code.
ZIO 2.0 takes another giant leap forward here with the introduction of autoblocking. Now the ZIO runtime will automatically identify blocking work for you and safely shift it to a dedicated blocking thread pool. You can still use ZIO.blocking
to provide a "hint" to the runtime that a certain section of code is blocking, but this is now merely an optimization. Early users have reported dramatic simplifications to their code by replacing all previous manual usages of blocking with this functionality.
Developer Experience
The speed of writing high quality code and onboarding developers can often be just as or more important than the speed of the code itself.
ZIO 1.0 broke new ground in developer productivity and teachability by taking a highly principled approach while eschewing unnecessary abstractions and jargon.
However, as we innovated in many areas in ZIO 1.0 such as the introduction of the environment type and support for dependency injection there were inevitably learning opportunities. We incorporated all that feedback and poured it back into ZIO 2.0 to make ZIO easier to use than ever before.
The area this is most visible is in the way you build your application’s required dependencies. This process is dramatically simplified in ZIO 2.0 with the deletion of the Has
data type, automatic construction of layers, and a new more straightforward design pattern for defining layers.
trait MyService {
def doSomething: ZIO[Any, MyDomainError, MyValue]
}
final case class MyServiceImplementation(dependency1: Dependency1, dependency2: Dependency2) extends MyService {
def doSomething: ZIO[Any, MyDomainError, MyValue] =
???
}
val layer: ZLayer[Dependency1 & Dependency2, Nothing, MyService] =
ZLayer {
for {
dependency1 <- ZIO.service[Dependency]
dependency2 <- ZIO.service[Dependency2]
... <- // do any necessary setup and finalization here
} yield MyServiceImplementation(dependency1, dependency2)
In this way, layers leverage everything you already know about constructor based dependency injection while giving you the ability to perform ZIO workflows to setup and finalize your services in a principled way.
Even better, once you define these layers you can use automatic layer construction to assemble them together with extremely helpful compiler messages to help you as you go.
object MyApp extends ZIOAppDefault {
val myAppLogic: ZIO[MyService, Nothing, Unit] =
???
val run: ZIO[Any, Nothing, Unit] =
myAppLogic.provide(layer)
// compiler message guides you to provide layers for required dependencies
}
Another area where ZIO 2.0 is dramatically simplifying the user experience is around resources. ZIO 1.0 pioneered safe, powerful resource management with the ZManaged
data type, which was the first to describe a resource as a value with powerful composition operators such as parallel acquisition of resources.
However, as great as ZManaged
was, it was "one more thing to learn" and could create issues when users had to mix ZIO
data types with ZManaged
data types.
ZIO 2.0 makes this a thing of the past by deleting ZManaged
and introducing the concept of a Scope
, which is just something that we can add finalizers to and eventually close to run all those finalizers. A resource in ZIO 2.0 is now just represented as a scoped ZIO:
val resource: ZIO[R with Scope, E, A] = ???
This is a workflow that requires a Scope
, indicating that part of this workflow requires finalization and needs a Scope
to add that finalizer to. To remove the requirement for a Scope
, the equivalent of ZManaged#use
in ZIO 1.0, we simply use the ZIO.scoped
operator:
ZIO.scoped {
resource.flatMap(doSomethingWithIt)
}
In this way ZIO workflows that require finalization compose seamlessly with other ZIO workflows, because they all are just ZIO workflows. During the development process of ZIO 2.0 almost every ZIO ecosystem library has migrated to using scopes exclusively, typically resulting in dramatic simplifications to code.
We have also made significant efforts to simplify naming conventions, focusing on consistency of naming and accessibility.
For example, the constructor for creating a ZIO
from a block of code that could throw exceptions was called effect
in ZIO 1.0, which was really a holdover from a more Haskell / category theoretic perspective where throwing exceptions was a "side effect" versus a pure function (never mind that there are a variety of other "side effects" that do not consist of throwing exceptions).
In contrast, in ZIO 2.0 this constructor is simply called attempt
. We "attempt" to do something which might fail, safely capturing the possibility of failure.
Similarly the constructor for creating a resource in ZIO 1.0 was bracket
, which was another holdover from Haskell. While it has a certain metaphorical appeal (we are "bracketing" the use of a resource with acquire and release actions) it does not really tell us what is going on. In contrast in ZIO 2.0 this is called acquireRelease
, saying precisely what it is.
Operations
While ZIO 2.0 makes it easier than ever to write correct code, it is inevitable in large applications that issues will arise, and you will need to be able to quickly diagnose and fix those issues.
ZIO 1.0 took significant steps to address these needs, including introducing asynchronous execution traces and providing support for logging and metrics through libraries such as ZIO Logging and ZIO Metrics.
However, there were some problems. While execution traces allowed you to see the trace for an asynchronous program in a way you never could before, traces could be hard to read and contained a lot of unnecessary information. Libraries such as ZIO Logging required you to add and integrate another dependency for what should be table stakes.
ZIO 2.0 is taking this to another level.
Execution traces now look just like JVM stack traces, letting you translate everything you know about reading stack traces into reading execution traces.
Logging is now build directly into ZIO so logging a line is as simple as:
val myAppLogic: ZIO[Any, Nothing, Unit] =
ZIO.logInfo("Hello logging!")
Of course you can use operators like logLevel
, logSpan
, and logAnnotate
just like you would with any other logging solution.
ZIO Logging is now focused on providing connectors to various existing logging frameworks, which can be installed with a single line of code.
import zio.logging.backend.SLF4J
object MyApp extends ZIOAppDefault {
val bootstrap = SLF4J.slf4j(logLevel, logFormat, rootLoggerName)
val run =
myAppLogic
We have taken the same approach to metrics.
Defining a metric is as simple as:
val counter = Metric.counter("myCounter")
val myAppcounter.increment
ZIO ZMX provides connectors to Metrics backends such as Prometheus, StatsD, and New Relic that you install with a single line of code just like you do for loggers.
ZIO also includes integrated fiber dumps you can get by simply calling dump
on any fiber so when something is not working as expected you can see what a fiber is doing and what it is waiting on.
Streams
Streaming has been fundamental to ZIO since ZIO 1.0 and we are building in that commitment in ZIO 2.0 with a new, more principled encoding of streams.
As a user most operators work exactly the same way and if you are just using existing stream operators you shouldn't notice much difference other than more straightforward names as discussed above and the replacement of ZTransducer
with ZPipeline
.
However, if you are doing more advanced work with streams you will find that your life just got easier because under the hood streams, sinks, and pipelines are all the same data type!
trait ZChannel[-Env, -InErr, -InElem, -InDone, +OutErr, +OutElem, +OutDone]]
A Channel
is a description of a data transformation pipeline that requires an environment of type Env
and accepts as input zero or more values of type InElem
along with one terminal value of type InErr
or InDone
. It produces zero or more values of type OutElem
along with one terminal value of type OutErr
or OutDone
.
In this way, you can think of a Channel
as the functional version of a low level channel such as an asynchronous file channel or a generalization of a publisher and a subscriber in the Reactive Streams protocol.
The great thing is that we can use channels to describe streams, sinks, and pipelines.
final case class ZStream[-R, +E, +A](channel: ZChannel[R, Any, Any, Any, E, Chunk[A], Any])
A stream is a producer that does not require any input but produces chunks of elements, terminating with either an error or an end of stream signal. It represents the "beginning" of a data transformation pipeline
final case class ZSink[-R, +E, -In, +L, +Z](channel: ZChannel[R, ZNothing, Chunk[In], Any, E, Chunk[L], Z])
A sink accepts as inputs the chunks of elements produced by a stream and either produces a summary value or fails with an error, along with potentially emitting a chunk of leftovers not consumed by the sink. It represents the "end" of a data transformation pipeline.
final case class ZPipeline[-Env, +Err, -In, +Out](channel: ZChannel[Env, ZNothing, Chunk[In], Any, Err, Chunk[Out], Any])
A pipeline consumes chunks of stream elements and emits new chunks of stream elements. It represents the "middle" of a data processing pipeline.
Streams, sinks, and pipelines are each their own data types so they have all the same powerful operators as they did in ZIO 1.0. But fundamentally they are all the same thing, which makes it much easier to compose them and makes it much easier for you to build your own solutions when you need to do something more advanced that integrated seamlessly with all the existing streaming data types.
Conclusion
We want to thank everyone who has used ZIO 1.0 or participated in the development process for ZIO 2.0 and given us feedback along the way. As is hopefully apparent here, many of the changes in ZIO 2.0 are based on your feedback.
When something isn't working, we strive to take that as feedback that "something is wrong with us" versus "something is wrong with you" and that doesn't stop today. The release of ZIO 2.0 is an important milestone that will help others start building even more amazing things on top of it.
But the development of ZIO is also an ongoing process and we will be working over the coming weeks and months to make ZIO even better based on your feedback. So please share your experiences upgrading to and working with ZIO 2.0 and we will continue to make it even better together.
Thank you all for your continued support and we hope you enjoy using ZIO 2.0 as much as we have enjoyed creating it.