Skip to content

ZIO (Scala)

A zero-dependency Scala library for asynchronous and concurrent programming whose effect type ZIO[R, E, A] is interpreted as a tree of instructions by a hand-written, trampolined fiber runtime — a monadic effect system, not an algebraic-effects/delimited-continuation runtime like Koka or OCaml effects.

FieldValue
LanguageScala 2.12 / 2.13 / 3 (JVM, Scala.js, Scala Native)
LicenseApache-2.0
RepositoryZIO GitHub repository (analyzed at v2.1.26, branch series/2.x)
DocumentationZIO documentation
Key AuthorsJohn A. De Goes and the ZIO Contributors (Ziverge)
ApproachReified effect data tree + fiber runtime interpreter (FiberRuntime); not algebraic effects

Overview

What It Solves

ZIO provides a type-safe, resource-safe, interruptible substitute for ad-hoc Future/thread/exception code in Scala. A ZIO[R, E, A] value is a description of a concurrent program; nothing runs until the value is submitted to a Runtime. The library bakes the three most common effects — environment (Reader), typed errors (Either), and a fiber-based async runtime (IO) — directly into one concrete type, avoiding monad-transformer stacking.

It is included in this algebraic-effects corpus as a contrast point: ZIO is frequently described as an "effect system," but it does not implement algebraic effects. There are no user-definable effect operations resumed by a handler via captured continuations. Instead, a fixed, closed instruction set (FlatMap, Sync, Async, FoldZIO, Stateful, UpdateRuntimeFlags, WhileLoop, YieldNow, plus the Exit leaves) is interpreted by a single runtime loop. See Comparison and Theory & Compilation for where this sits on the spectrum, and Cats Effect for the closest peer.

Design Philosophy

ZIO is deliberately concrete and non-academic: one effect type, no higher-kinded F[_] abstraction, no tagless-final, no category-theory vocabulary in the public API. The runtime is treated as a performance-critical microkernel — John De Goes describes the ZIO 2.0 runtime as the first "third-generation" effect engine for Scala, rewritten around a hybrid declarative/executable encoding that tries to run effects without a trampoline and only falls back to a reified stack at asynchronous boundaries (ZIO 2.0 Released).


Core Abstractions and Types

ZIO[-R, +E, +A]

The central type (core/shared/src/main/scala/zio/ZIO.scala) is sealed trait ZIO[-R, +E, +A]. A good intuition is R => Async[Either[Cause[E], A]].

ParameterMeaningVarianceWhen Any/Nothing
R (Environment)Contextual services required to runContravariantAny = no requirements
E (Error)Typed, recoverable failureCovariantNothing = cannot fail
A (Success)Value produced on successCovariantNothing = never returns (unless fails)

Common aliases (UIO, URIO, Task, IO, RIO) specialize R/E. Effect values are immutable and referentially transparent: building a ZIO performs no side effects.

Effects as a reified data tree (the real interpreter input)

The "effect as data structure" claim is concrete. In the companion object ZIO (around ZIO.scala:6175), each combinator is a case class node implementing ZIO:

scala
// core/shared/src/main/scala/zio/ZIO.scala
private[zio] type Erased = ZIO[Any, Any, Any]

private[zio] final case class FlatMap[R, E, A1, A2](
  trace: Trace, first: ZIO[R, E, A1], successK: A1 => ZIO[R, E, A2]
) extends Continuation with ZIO[R, E, A2]

private[zio] final case class Mapped[R, E, A1, A2](
  trace: Trace, first: ZIO[R, E, A1], successK: A1 => A2
) extends Continuation with ZIO[R, E, A2]

private[zio] final case class Sync[A](trace: Trace, eval: () => A) extends ZIO[Any, Nothing, A]

private[zio] final case class Async[R, E, A](
  trace: Trace,
  registerCallback: (ZIO[R, E, A] => Unit) => Either[URIO[R, Any], ZIO[R, E, A]],
  blockingOn: () => FiberId
) extends ZIO[R, E, A]

private[zio] case class FoldZIO[R, E1, E2, A1, A2]( /* successK + failureK */ ) extends Continuation with ZIO[R, E2, A2]
private[zio] final case class Stateful[R, E, A](trace, onState: (Fiber.Runtime[E, A], Fiber.Status.Running) => ZIO[R, E, A]) extends ZIO[R, E, A]
private[zio] final case class WhileLoop[R, E, A](trace, check: () => Boolean, body: () => ZIO[R, E, A], process: A => Any) extends ZIO[R, E, Unit]
private[zio] final case class UpdateRuntimeFlags(trace, update: RuntimeFlags.Patch) extends Continuation with ZIO[Any, Nothing, Unit]
private[zio] final case class YieldNow(trace, forceAsync: Boolean) extends ZIO[Any, Nothing, Unit]

Crucially, the success/failure leaves are also ZIO nodes: sealed trait Exit[+E, +A] extends ZIO[Any, E, A] (ZIO.scala:6474), with Exit.Success[A](value) and Exit.Failure[E](cause: Cause[E]). So the runtime never special-cases "values" — they are instructions like everything else. The continuation frames pushed on the fiber's stack are exactly FlatMap, Mapped, FoldZIO, and UpdateRuntimeFlags, all subtypes of private[zio] sealed abstract class Continuation.

Cause[+E] and Exit[+E, +A]

Cause[+E] (Cause.scala:27) is a full failure tree, not a single exception: leaves are Fail (typed error E), Die (defect/Throwable), Interrupt (fiber cancellation), Empty, and composites Then (sequential) and Both (parallel), plus Stackless. This lets ZIO faithfully represent multiple simultaneous failures (e.g. an error during finalization while already failing) — something a single Throwable cannot. Exit[E, A] is the terminal value of a fiber: Success(value) or Failure(cause).


How Effects Are Declared

ZIO has no algebraic "effect declaration." What looks like declaring an effect is one of:

  1. Service traits + the R channel. A capability is an ordinary trait whose methods return ZIO, accessed from the environment via ZIO.service / ZIO.serviceWithZIO. The R type parameter accumulates required services as an intersection type (UserRepository & Logger).
  2. Lifting callbacks via ZIO.async. Any callback-style API becomes an Async node. The signatures live in the version-specific companion (core/shared/src/main/scala-3/zio/ZIOCompanionVersionSpecific.scala):
scala
// scala-3/zio/ZIOCompanionVersionSpecific.scala
def async[R, E, A](
  register: Unsafe ?=> (ZIO[R, E, A] => Unit) => Unit,
  blockingOn: => FiberId = FiberId.None
)(implicit trace: Trace): ZIO[R, E, A] =
  Async(trace, { k => register(using Unsafe)(k); null }, () => blockingOn)

def asyncInterrupt[R, E, A](
  register: Unsafe ?=> (ZIO[R, E, A] => Unit) => Either[URIO[R, Any], ZIO[R, E, A]],
  blockingOn: => FiberId = FiberId.None
)(implicit trace: Trace): ZIO[R, E, A] =
  ZIO.Async[R, E, A](trace, register(using Unsafe), () => blockingOn)

asyncInterrupt lets register return Left(cancelEffect) (run on interruption) or Right(result) (synchronous short-circuit). This is the foundation for integrating OS/event-loop callbacks; see Effects and Event Loops and Async I/O Comparison.


How Handlers/Interpreters Work

There are no handlers. There is one interpreter: FiberRuntime. Each fiber owns a FiberRuntime[E, A] instance, and the runLoop method is the entire instruction interpreter.

The fiber as a state machine

final class FiberRuntime[E, A](...) extends Fiber.Runtime.Internal[E, A] with FiberRunnable (core/shared/src/main/scala/zio/internal/FiberRuntime.scala:30) holds mutable per-fiber state:

scala
// internal/FiberRuntime.scala
private val running        = new AtomicBoolean(false)
private val inbox          = new ConcurrentLinkedQueue[FiberMessage]()   // the fiber's mailbox
private var _stack         = null.asInstanceOf[Array[Continuation]]       // reified continuation stack
private var _stackSize     = 0
private var _asyncContWith = null.asInstanceOf[AsyncContWith]             // pending async continuation
private var _runtimeFlags  = runtimeFlags0
@volatile private var _exitValue = null.asInstanceOf[Exit[E, A]]

_stack is an Array[Continuation] grown by pushStackFrame / popStackFrame — the explicitly reified call stack that replaces JVM recursion.

The trampolined runLoop

runLoop(effect, minStackIndex, startStackIndex, currentDepth, currentOps) (FiberRuntime.scala:1085) is a while (true) dispatch over the cur instruction. The dispatch is a single match:

scala
// internal/FiberRuntime.scala (runLoop, abridged)
while (true) {
  cur = drainQueueWhileRunning(cur)        // process inbox (interrupts, stateful msgs)
  ops += 1
  if (ops > FiberRuntime.MaxOperationsBeforeYield && RuntimeFlags.cooperativeYielding(_runtimeFlags)) {
    inbox.add(FiberMessage.Resume(cur))    // cooperative yield: re-enqueue self
    return null
  }
  cur match {
    case success: Exit.Success[Any] => /* unwind stack, applying successK of each Continuation */
    case sync: Sync[Any]            => var value = sync.eval(); /* unwind stack */
    case flatmap: FlatMap[...]      => stackIndex = pushStackFrame(flatmap, stackIndex); cur = flatmap.first
    case fold: FoldZIO[...]         => stackIndex = pushStackFrame(fold, stackIndex);    cur = fold.first
    case map: Mapped[...]           => stackIndex = pushStackFrame(map, stackIndex);     cur = map.first
    case stateful: Stateful[...]    => cur = stateful.onState(self, Fiber.Status.Running(...))
    case async: Async[...]          => cur = initiateAsync(async.registerCallback)
                                       if (cur eq null) cur = drainQueueAfterAsync()
                                       if (cur eq null) { self._blockingOn = async.blockingOn; return null }  // SUSPEND
    case iterate: WhileLoop[...]    => /* loop body via nested runLoop, no per-iteration stack growth */
    case yieldNow: ZIO.YieldNow     => inbox.add(FiberMessage.resumeUnit); return null
    case failure: Exit.Failure[Any] => /* unwind stack to nearest FoldZIO.failureK */
    case update0: UpdateRuntimeFlagsWithin.DynamicNoBox[...] => /* patch flags, push revert frame */
    case updateRuntimeFlags: UpdateRuntimeFlags             => cur = patchRuntimeFlags(...)
    case effect => throw new MatchError(effect)
  }
}

Two trampoline mechanisms keep the JVM stack bounded:

  • Depth trampoline. At entry, if (currentDepth >= FiberRuntime.MaxDepthBeforeTrampoline) { inbox.add(FiberMessage.Resume(effect)); return null } (MaxDepthBeforeTrampoline = 300). Deep recursion bounces back through the inbox instead of growing the native stack.
  • Op-count cooperative yield. After MaxOperationsBeforeYield = 1024 * 10 instructions, the fiber re-enqueues itself (FiberMessage.Resume(cur)) and returns null, freeing the worker thread for other fibers. Forks also yield every MaxForksBeforeYield = 128 (shouldYieldBeforeFork).

Success/Sync results unwind _stack inline, applying flatMap.successK(value), map.successK(value), or foldZIO.successK(value); Failure unwinds looking for a FoldZIO.failureK. This is the reified equivalent of returning through nested flatMap closures.

Driving the loop: evaluateEffect

runLoop is wrapped by evaluateEffect(initialDepth, effect0) (FiberRuntime.scala:403), which restarts runLoop while effect ne null, converts non-fatal exceptions into Cause.die, runs child interruption on completion, and finally calls setExitValue (or re-enqueues FiberMessage.Resume(exit) if the inbox is non-empty). A null return from runLoop means "suspended; an async resumption will continue."


Performance Approach

Async suspension and resumption (the core async loop)

When runLoop hits an Async node it calls initiateAsync(async.registerCallback) (FiberRuntime.scala:696). This constructs a one-shot AsyncContWith.Callback and invokes the user's register:

scala
// internal/FiberRuntime.scala
private def initiateAsync(asyncRegister: (ZIO.Erased => Unit) => Either[ZIO.Erased, ZIO.Erased]): ZIO.Erased = {
  val callback = new AsyncContWith.Callback(self)
  val value    = asyncRegister(callback)               // user registers their callback
  value match {
    case Left(onInterrupt)              => if (isInterruptible()) self._asyncContWith = AsyncContWith(callback, onInterrupt)
    case Right(value) if value ne null  => if (callback.compareAndSet(false, true)) return value   // synchronous result
    case _                              => if (isInterruptible()) self._asyncContWith = AsyncContWith(callback)
  }
  null   // genuinely asynchronous: suspend
}

If no synchronous value is available, runLoop returns null after recording self._blockingOn. The fiber is now suspended: its worker thread is released and the fiber lives only as _stack + _asyncContWith + the inbox.

Resumption is the load-bearing detail the original draft glossed over. The Callback is an AtomicBoolean (for at-most-once completion) and a ZIO.Erased => Unit:

scala
// internal/FiberRuntime.scala (AsyncContWith.Callback)
final class Callback(fiber: FiberRuntime[?, ?]) extends AtomicBoolean(false) with (ZIO.Erased => Unit) {
  def completeZIO(effect: ZIO.Erased): Boolean =
    if (compareAndSet(false, true)) { fiber.tell(FiberMessage.Resume(effect)); true } else false
  def completeCause(cause: Cause[Nothing]): Boolean =
    if (compareAndSet(false, true)) { fiber.tell(FiberMessage.Resume(Exit.Failure(cause))); true } else false
}

So when an OS event loop / Future / Promise invokes the registered callback with a result, that result becomes FiberMessage.Resume(effect) posted into the fiber's inbox via tell. tell (FiberRuntime.scala:1521) does:

scala
private[zio] def tell(message: FiberMessage): Unit = {
  inbox.add(message)
  if (running.compareAndSet(false, true)) drainQueueLaterOnExecutor(false)  // wake the fiber on an executor
}

drainQueueLaterOnExecutor submits the fiber (which is Runnable via FiberRunnable) to its Executor. When the worker runs it, drainQueueOnCurrentThreadevaluateMessageWhileSuspended matches the FiberMessage.Resume(nextEffect) and calls evaluateEffect, re-entering runLoop with the resumed continuation. This is the whole "register callback → enqueue FiberMessage.Resume → resume the fiber" cycle; nothing about it involves captured native continuations.

FiberMessage — the fiber mailbox protocol

scala
// internal/FiberMessage.scala
private[zio] sealed trait FiberMessage
private[zio] object FiberMessage {
  final case class InterruptSignal(cause: Cause[Nothing])        extends FiberMessage
  final case class Stateful(onFiber: FiberRuntime[_, _] => Unit) extends FiberMessage
  final case class Resume(effect: ZIO[_, _, _])                  extends FiberMessage
}

Three messages drive everything: Resume (async wake-up / trampoline bounce), InterruptSignal (cancellation), and Stateful (cross-fiber state queries like addChild, run on the target fiber to avoid races). running (an AtomicBoolean) guarantees at most one thread drains a given fiber's inbox at a time, so the fiber's mutable state needs no locks.

Work-stealing executor: ZScheduler

The default JVM/Native executor is ZScheduler (core/jvm-native/src/main/scala/zio/internal/ZScheduler.scala), explicitly "Inspired by 'Making the Tokio Scheduler 10X Faster' by Carl Lerche" (see Tokio). It is a fixed pool of poolSize = Runtime.getRuntime.availableProcessors daemon Worker threads. Each Worker has:

  • a bounded localQueue: RingBufferPow2[Runnable](256),
  • a nextRunnable single-slot fast path (LIFO hand-off, like Tokio),
  • a shared globalQueue: PartitionedLinkedQueue[Runnable].

submit enqueues onto the current worker's local queue (or the global queue if called from outside a worker), then maybeUnparkWorker. submitAndYield (used by submitAndYieldOrThrow during cooperative yields) tries to keep the just-yielded runnable on the current thread via nextRunnable to avoid park/unpark churn. When a worker's queues are empty, it enters a searching state and steals from peers:

scala
// internal/ZScheduler.scala (Worker.run, steal path, abridged)
val runnables  = worker.localQueue.pollUpTo(size - size / 2)   // steal half a victim's queue
...
if (runnable eq null) runnable = globalQueue.poll(random)

isCurrentThreadInExecutor (overridden only here) lets the runtime detect "am I already on a scheduler worker?" so that, e.g., a synchronous interrupt can run the loop inline (interruptAsdrainQueueOnCurrentThread) rather than bouncing through the executor.

Blocking pool and auto-blocking

ZIO.blocking(zio) shifts execution to FiberRef.currentBlockingExecutor, which on JVM is Blocking.blockingExecutor (core/jvm-native/src/main/scala/zio/internal/Blocking.scala): an unbounded ThreadPoolExecutor (corePoolSize = 0, maxPoolSize = Int.MaxValue, SynchronousQueue, 60s keep-alive, threads named zio-default-blocking). attemptBlocking = ZIO.blocking(ZIO.attempt(...)). This keeps thread-blocking I/O off the small ZScheduler worker pool.

ZScheduler also supports auto-blocking: when constructed with autoBlocking = true, a Supervisor thread watches per-worker opCount; a worker stuck on the same Trace location for too long is markAsBlocking()-ed — its queued work is shoved to the global queue and a replacement worker is spawned. Critically, the default executor uses Executor.makeDefault(autoBlocking = false) (core/jvm/src/main/scala/zio/RuntimePlatformSpecific.scala:26); auto-blocking was disabled by default in ZIO 2.1 because of performance regressions and must be opted into via the Runtime.enableAutoBlockingExecutor layer. This corrects a common misconception that ZIO auto-detects blocking out of the box.

Memory footprint

Each fiber carries FiberRefs, runtime flags, a Cause-capable error channel, a children set, and observers — richer (and heavier) than a Cats Effect IOFiber. The ZIO 2.0/2.1 rewrite narrowed the gap: the fork/join optimization that shipped in 2.1.0 reports the ZScheduler runtime as ~6.5x faster than the pre-optimization series/2.x baseline (and ~15x with FiberRoots disabled) per its JMH benchmarks (Fork-Join Performance PR), partly by avoiding the trampoline on synchronous fast paths.


Composability Model

Monadic composition

flatMap builds a FlatMap node; for-comprehensions desugar to nested FlatMap/Mapped trees that runLoop walks via the reified _stack.

fork does not detach a fiber globally. ZIO#forkforkWithScopeOverride(null)ZIO.unsafe.forkmakeChildFiber, which adds the child to the parent's FiberScope (parentScope.add(...)). When the parent finishes, evaluateEffect calls interruptAllChildren(), awaiting every child — so a fiber cannot outlive its parent unless explicitly forkDaemon'd (attached to FiberScope.global) or forkIn(scope)/forkScoped into a longer-lived Scope.

Scope (core/shared/src/main/scala/zio/Scope.scala) is the resource-safety primitive: addFinalizer registers cleanup, close(exit) runs finalizers in reverse, and acquireRelease ties a resource's lifetime to the enclosing scope. forkScoped interrupts the child when its scope closes (child.addFinalizer(interrupt(fiber))).

Interruption model

Interruption is fully reified through FiberMessage.InterruptSignal(cause) and the runtime flags, not Java thread interrupts (mostly). Key mechanics:

  • Interruptibility is a RuntimeFlag. ZIO.interruptible / ZIO.uninterruptible emit UpdateRuntimeFlagsWithin(trace, RuntimeFlags.enableInterruption/disableInterruption, …) (ZIO.scala:3981, :5017). The loop pushes a revert UpdateRuntimeFlags frame so the flag is restored on unwind.
  • Delivery. interruptAsFork/tellInterrupt post InterruptSignal. drainQueueWhileRunning (called every loop iteration) turns it into cur = Exit.Failure(cause) only if isInterruptible(); otherwise the signal is recorded and deferred until the fiber re-enters an interruptible region.
  • Waking a suspended fiber. processNewInterruptSignal (FiberRuntime.scala:958) grabs the pending _asyncContWith; if there is an onInterrupt cleanup it schedules it, then completes the callback with the interrupt Cause, resuming the suspended fiber so it can finalize.
  • Cooperative. Because interruption is checked at loop boundaries and async points, a tight uninterruptible synchronous region runs to completion — interruption is safe, not preemptive thread-killing.

Schedule[Env, In, Out]

Schedule (core/shared/src/main/scala/zio/Schedule.scala) is a composable recurrence/retry policy with an abstract type State and a step(now, in, state): ZIO[Env, Nothing, (State, Out, Decision)] driver. It powers ZIO#repeat/#retry, supports combinators (&&, ||, andThen, jittered), and built-ins (recurs, spaced, exponential, fixed). It is an ordinary ZIO-returning value — interpreted by the same runtime, not a runtime feature.

Concurrent primitives

PrimitivePurposeFile
RefAtomic mutable referenceRef.scala
PromiseSingle-value async rendezvousPromise.scala
QueueBounded/unbounded async queueQueue.scala
SemaphoreConcurrency limitingSemaphore.scala
HubPub/sub broadcastingHub.scala
STMSoftware transactional memorystm/

Promise.await and Queue.take are themselves built on ZIO.asyncInterrupt, registering callbacks that are completed (via tell(Resume(...))) when a value arrives — the same suspension/resumption machinery described above.

Dependency injection: ZLayer

ZLayer[RIn, E, ROut] describes how to build services. ++ composes horizontally, >>> vertically, >+> with passthrough; ZIO#provide / ZLayer.make assemble the dependency graph and verify it at compile time via macros (internal/macros/). DI is a library feature interpreted as ordinary ZIO, not a runtime capability.


Strengths

  • Honest, fast runtime. A single hand-tuned interpreter (FiberRuntime.runLoop) with explicit trampolining, a Tokio-style work-stealing scheduler, and a dedicated blocking pool — predictable and well-optimized.
  • Typed errors via Cause. The E channel plus the Cause failure tree faithfully represents typed failures, defects, interruptions, and parallel failures.
  • Real structured concurrency. Parent/child fiber links + Scope make fiber and resource leaks structurally hard.
  • Reified interruption. Interruptibility as a runtime flag gives precise, region-scoped, resource-safe cancellation.
  • Batteries included. STM, Schedule, streaming (ZIO Streams), ZLayer DI, and ZIO Test ship together.
  • No HKT prerequisite. One concrete type; approachable error messages.

Weaknesses

  • Not algebraic effects. A fixed, closed instruction set with one interpreter; you cannot define a new effect operation handled by a user-supplied handler via captured continuations (contrast Koka, OCaml effects, Eff).
  • No abstraction over the effect. Concrete ZIO, no tagless-final F[_]; code is committed to ZIO.
  • Heavier fibers than Cats Effect IOFiber (richer per-fiber state), though much narrowed since 2.0.
  • Closed effect set. New "channels" (beyond R, E) cannot be added; everything routes through environment/error/value.
  • Learning curve. Three type parameters, ZLayer wiring, and the interruption model take time.
  • Ecosystem split. Some Scala libraries target only Cats Effect or only ZIO; ZIO interop exists but adds friction.

Key Design Decisions and Trade-offs

DecisionRationaleTrade-off
Reified effect tree + single runLoop interpreterFull control over trampolining, fusion, tracing; no GADT/free-monad overheadClosed instruction set; no user-defined algebraic operations
Concrete ZIO[R,E,A] (not F[_])Simpler API, better errors, no HKT machineryNo abstraction over effect implementation; no tagless-final
Cause failure treeModels typed errors + defects + interrupts + parallel failuresMore complex than a single Throwable
Async via Async node + tell(Resume(...)) mailboxClean callback integration; lock-free per-fiber state via running CASResumption is a queue hop + executor submit, not a direct call
ZScheduler work-stealing pool (Tokio-style)High throughput, good locality, minimal park/unparkComplex scheduler; blocking work must be shifted off it manually
Auto-blocking off by default (since 2.1)Avoids the perf regressions of trace-based detectionUsers must remember ZIO.blocking / enableAutoBlockingExecutor
Structured concurrency (fiber scopes + Scope)Leak-resistant, resource-safe by constructionforkDaemon/forkScoped needed to escape parent lifetime
Interruptibility as a RuntimeFlagPrecise, region-scoped, resource-safe cancellationCooperative only; uninterruptible regions delay interruption

ZIO 2.x runtime facts (verified)

  • ZIO 2.0 shipped a complete runtime rewrite (the FiberRuntime interpreter analyzed here), pitched as the first "third-generation" Scala effect engine: a hybrid declarative/executable encoding that avoids the trampoline on synchronous fast paths and reifies the stack only at async boundaries (ZIO 2.0 Released).
  • ZScheduler is the default executor on JVM/Native, a work-stealing scheduler explicitly inspired by Tokio (per-worker local ring buffer + shared global queue + work-stealing), introduced as the default in the 2.x line (Advances In The ZIO 2.0 Scheduler).
  • ZIO 2.1.0 brought major fork/join improvements (its JMH ForkJoinBenchmark measured the ZScheduler runtime ~6.5x faster than the pre-optimization series/2.x baseline, ~15x with FiberRoots disabled — see Fork-Join Performance PR) and disabled auto-blocking detection by default (Release 2.1.0).
  • Latest release at time of writing is v2.1.26 (the revision this document was grounded against), still on Scala 2.12/2.13/3 (Releases).

Sources

Related corpus docs: Index, Comparison, Evolution, Theory & Compilation, Parallelism, Papers, Cats Effect, Koka, OCaml effects, Eff, Effects and Event Loops, Async I/O Comparison.