Welcome back! In the last chapter, Error Handling, we learned how to make our stream pipelines resilient to unexpected problems. We now know how to get data into a Stream (File I/O), process it with Stream Transformations using Effects (IO/ZIO), finish the process with Stream Sinks and Running, and handle Error Handling.
So far, our streams have mostly processed data sequentially. One item comes in, gets processed, and then the next item comes in. This works great for many tasks, like reading and converting a single file.
But what if your application needs to do multiple things at the same time? What if you have a server that needs to handle many client connections concurrently? What if you have a 'producer' stream generating data and a 'consumer' stream processing it, and you want them running simultaneously? What if you need to run a background task (like monitoring user input) while your main process is running?
If these tasks involve waiting (like waiting for data from a network connection, waiting for a file to be read, or just waiting for a timed delay), doing them purely sequentially means one task blocks the others. This can make your application slow and unresponsive.
This is where Concurrency comes in.
Concurrency is about structuring your program so that it can work on multiple tasks seemingly at the same time. This doesn't necessarily mean they are all running on separate CPU cores (that's parallelism, a related but distinct concept), but rather that the program can make progress on one task while waiting for another task to complete something slow like I/O.
Think back to our conveyor belt analogy. Up until now, we've mostly considered a single, perhaps complex, conveyor belt. Concurrency allows us to manage multiple conveyor belts running in parallel.
- You might have data coming from two different sources that you want to process and combine onto a single belt.
- You might have a situation where each item arriving represents the start of a new conveyor belt that needs processing (like a new client connection arriving at a server). You want to process many of these incoming belts concurrently, up to a certain limit.
- You might have a small side task that needs its own dedicated mini-belt running in the background, independent of your main processing belt.
Stream libraries like FS2 and ZIO Streams, built on top of effect libraries like Cats Effect and ZIO, provide powerful and safe ways to manage concurrency. They use lightweight concurrent units called fibers (Cats Effect/FS2) or ZIO fibers (ZIO) instead of traditional operating system threads, making it efficient to manage thousands or millions of concurrent tasks.
Let's look at some key operations and concepts for achieving concurrency with streams and effects.
The simplest way to introduce concurrency to streams is often by combining the output of two (or more) independent streams onto a single output stream. The merge operation does this. It runs both source streams concurrently and emits items from whichever stream has data ready.
Analogy: You have two separate conveyor belts bringing items. You use a special junction to combine them onto a single main belt. Items from both original belts appear on the main belt in the order they arrive at the junction.
Consider the QueueExample.scala and zioVersion/QueueExample.scala examples. They have a producer stream and a consumer stream. The producer puts numbers into a queue, and the consumer takes numbers from the queue. For this to work, both streams must run at the same time.
// Snippet from src/main/scala/QueueExample.scala (FS2)
// producer(queue) is a Stream[IO, Nothing] (it performs effects but emits no data)
// consumer(queue, ref) is a Stream[IO, Nothing] (same)
// merge producer and consumer streams
mergedStream <- producer(queue).merge(consumer(queue, ref))// Snippet from src/main/scala/zioVersion/QueueExample.scala (ZIO Streams)
// producer(queue) is a UStream[Nothing]
// consumer(queue, ref) is a UStream[Nothing]
// merge producer and consumer streams
_ <- (producer(queue) merge consumer(queue, ref)) // Using parentheses due to operator precedence
.interruptAfter(1.second) // Run for 1 second, then stop
.runDrain // Run for effects, discard outputIn both examples, producer(...) merge consumer(...) creates a single stream blueprint. When this merged stream is executed (runDrain), the runtime starts both the producer stream and the consumer stream concurrently. The producer starts emitting numbers (as effects into the queue), and the consumer starts pulling from the queue, processing numbers as they become available.
The type Stream[IO, Nothing] or UStream[Nothing] indicates that these streams are run purely for their effects (like println and queue operations), not for the data they emit (there isn't any). merge can also be used with streams that do emit data, combining the emitted elements onto the single output stream.
How merge Works Under the Hood (Simplified):
When a merged stream A.merge(B) is run, the runtime effectively starts running stream A and stream B on separate fibers. The merge operator then acts as a funnel, pulling results from whichever fiber has data ready and emitting it downstream. If one stream finishes, merge continues pulling only from the other until it also finishes.
sequenceDiagram
participant R as Runtime
participant A as Stream A Fiber
participant B as Stream B Fiber
participant M as Merge Operator
participant S as Sink
Note over R,S: Stream (A merge B) -> S defined
R->>M: Start Running
M->>A: Pull item
M->>B: Pull item
A-->>M: Emit Item A1 (e.g., Producer puts in Queue)
M-->>S: Emit Item A1 (Not actually emitted by Nothing stream, effect happens)
S->>M: Pull next item
B-->>M: Emit Item B1 (e.g., Consumer takes from Queue)
M-->>S: Emit Item B1 (Not actually emitted, effect happens)
S->>M: Pull next item
A-->>M: Emit Item A2
M-->>S: Emit Item A2
S->>M: Pull next item
Note over A,B: Streams A and B run concurrently, emitting results when ready.
Note over S: Sink pulls whenever it's ready.
Note over A: Stream A finishes
A-->>M: Signal End of Stream
Note over M: Merge continues pulling from B
B-->>M: Emit Item B2
M-->>S: Emit Item B2
S->>M: Pull next item
Note over B: Stream B finishes
B-->>M: Signal End of Stream
M-->>S: Signal End of Stream
S->>R: Signal Completion
This shows merge allowing A and B to make progress independently, and the sink receiving items from either as they become available. For streams like the ones in QueueExample that emit Nothing, the "emission" is just the completion of the effectful step (queue.offer or ref.update), and merge coordinates these effect completions.
A common pattern, especially in network programming, is having a stream where each element is itself a stream. For example, a server's accept method might emit a new stream of bytes for each incoming connection. You want to process many of these connection streams concurrently.
- FS2: Use the
parJoin(n)operation. It takes a stream of streams (Stream[F, Stream[F, O]]) and runs up tonof the inner streams concurrently, merging all their emitted items (O) onto a single output stream (Stream[F, O]). - ZIO Streams: A similar pattern can be achieved using
flatMapPar(n). If your outer stream emits valuesAand applying a functionA => ZStream[R, E, B]to each results in inner streams,flatMapPar(n)runs up tonof these inner streams concurrently and flattens their results (B) into a single output stream (ZStream[R, E, B]).
Analogy: You're running a large dispatch center. Incoming calls (items on the outer stream) are requests to start a new operation, which is itself a process involving a sequence of steps (an inner stream). parJoin(n) / flatMapPar(n) is like assigning up to n available workers to handle these incoming operations simultaneously, with all workers putting their finished items onto one final output belt.
Let's look at the EchoServer.scala example. The core of the server is receiving connections. Network[F].server(...) gives you a Stream[F, Socket[F]]. Each Socket[F] represents an incoming client connection. We want to handle each connection, and handleClient(clientSocket) is a function that creates a Stream[F, Unit] representing the processing pipeline for that single client (reading messages, echoing them, handling commands).
So, mapping handleClient over the server's stream gives you a Stream[F, Stream[F, Unit]] – a stream of streams, where each inner stream handles one client connection.
// Snippet from src/main/scala/EchoServer.scala (FS2)
Network[F] // Stream[F, Socket[F]] - server accepts connections
.server(port = Some(port"5555"))
.map(handleClient[F]) // Stream[F, Stream[F, Unit]] - map each socket to its handling stream
.parJoin(2) // Stream[F, Unit] - run up to 2 client handling streams concurrently, merge their outputs
.interruptWhen(serverExitSignal) // Stop the server stream when signal is setHere:
Network[F].server(...)is the source, emitting aSocket[F]for each incoming connection.Stream[F, Socket[F]]..map(handleClient[F])transforms eachSocket[F]into its client-handling streamStream[F, Unit]. The result is a stream where the elements are streams:Stream[F, Stream[F, Unit]]..parJoin(2)is the key concurrency operator. It takes this stream of streams and starts running up to 2 of thehandleClientstreams concurrently. All theUnitoutputs (representing completion of effects within a client stream segment) are merged onto a single output streamStream[F, Unit]. This allows the server to handle two clients simultaneously. If a third client connects, it waits until one of the first two finishes before its handling stream starts running.
This is the standard pattern for building concurrent servers or processing independent tasks arriving in a stream.
How parJoin(n) Works Under the Hood (Simplified):
When streamOfStreams.parJoin(n) is run, the runtime pulls from the outer stream (streamOfStreams). When the outer stream emits an inner stream, parJoin checks how many inner streams are currently running. If less than n are running, it starts the new inner stream on a new fiber and adds it to a pool of active inner streams. If n are already running, it pauses pulling from the outer stream until one of the running inner streams finishes. parJoin also concurrently pulls from all currently running inner streams and merges their results onto its output stream.
sequenceDiagram
participant R as Runtime
participant OS as Outer Stream Fiber
participant PJ as parJoin(n) Operator
participant IS1 as Inner Stream 1 Fiber
participant IS2 as Inner Stream 2 Fiber
participant S as Sink
Note over R,S: Stream OS -> PJ -> S defined
Note over PJ: Inner streams will be run on new fibers
R->>OS: Start Running
OS->>PJ: Emit Inner Stream 1
Note over PJ: Slot available (1 < n), start IS1 fiber
PJ->>IS1: Start Running
OS->>PJ: Emit Inner Stream 2
Note over PJ: Slot available (2 <= n), start IS2 fiber
PJ->>IS2: Start Running
Note over OS: Outer stream pauses, waits for PJ to pull more.
Note over IS1: IS1 is running concurrently with IS2.
IS1-->>PJ: Emit Item 1 from IS1
PJ-->>S: Emit Item 1 from IS1
S->>PJ: Pull next item
IS2-->>PJ: Emit Item 1 from IS2
PJ-->>S: Emit Item 1 from IS2
S->>PJ: Pull next item
IS1-->>PJ: Emit Item 2 from IS1
PJ-->>S: Emit Item 2 from IS1
S->>PJ: Pull next item
Note over IS1: IS1 finishes
IS1-->>PJ: Signal End of Stream
Note over PJ: Slot available (1 < n), Pull from OS
PJ->>OS: Pull next Inner Stream
OS-->>PJ: Emit Inner Stream 3
Note over PJ: Slot available (2 <= n), start IS3 fiber
PJ->>IS3: Start Running
IS2-->>PJ: Emit Item 2 from IS2
PJ-->>S: Emit Item 2 from IS2
S->>PJ: Pull next item
Note over IS2: IS2 finishes
IS2-->>PJ: Signal End of Stream
Note over PJ: Slot available (1 < n), Pull from OS
PJ->>OS: Pull next Inner Stream (if any)
Note over OS: Outer stream finishes
OS-->>PJ: Signal End of Stream
Note over PJ: PJ waits for remaining inner streams (IS3) to finish.
IS3-->>PJ: Emit Item 1 from IS3
PJ-->>S: Emit Item 1 from IS3
S->>PJ: Pull next item
Note over IS3: IS3 finishes
IS3-->>PJ: Signal End of Stream
Note over PJ: All inner streams finished.
PJ-->>S: Signal End of Stream
S->>R: Signal Completion
This diagram shows how parJoin manages the concurrency limit (n) and merges results from multiple inner streams running on separate fibers.
Sometimes you have an individual effect (IO or ZIO) that you want to execute asynchronously, without it blocking the current flow. This is common for setting up background processes that run independently.
- Cats Effect: Use the
.startmethod on anIO[A]. It returns anIO[Fiber[IO, Throwable, A]]. This newIOaction, when run, starts the original effect in a new fiber and immediately returns the fiber object. The original effect (IO[A]) continues running concurrently. - ZIO: Use the
.forkmethod on aZIO[R, E, A]. It returns aZIO[R, Nothing, Fiber.Runtime[E, A]]. This newZIOaction, when run, starts the original effect in a new ZIO fiber and immediately returns the fiber object. The original effect (ZIO[R, E, A]) continues running concurrently.
Analogy: You have a specific task (an effect recipe) that doesn't fit into the main conveyor belt pipeline, but needs to be done simultaneously. You hand the recipe to a helper (a new fiber) and tell them to go do it. You immediately return to your main work without waiting for the helper to finish. The value you get back from start/fork is a reference to the helper, allowing you to potentially check on them or tell them to stop later.
In the EchoServer.scala example, we want to run the main server loop, but also have a separate process running at the same time that listens to keyboard input in the console (repl). If the user types "KILLSERVER", this background process needs to signal the main server loop to stop.
// Snippet from src/main/scala/EchoServer.scala (FS2)
private def repl[F[_]: Sync: Console](
serverExitSignal: SignallingRef[F, Boolean]
): F[Unit] = ??? // This defines an effect that runs the keyboard loop
override def run: IO[Unit] =
for
_ <- IO.println("Hello, this is a simple echo server")
serverExitSignal <- SignallingRef.apply[IO, Boolean](false) // Create a Ref to signal exit
// We spin up the repl, which signals the server to exit
_ <- repl(serverExitSignal).start // Starts the keyboard repl in a background fiber
_ <- echoServer[IO](serverExitSignal).compile.drain // Start the main server stream and run it
_ <- IO.println("Server exiting") // This runs after the main server stream finishes (or is interrupted)
yield ()Here:
repl(serverExitSignal)is anIO[Unit]effect blueprint for the keyboard loop..startis called on thisIO[Unit]. It creates a newIOblueprint:IO[Fiber[IO, Throwable, Unit]]. When this newIO(produced by.start) is run, it kicks off the original keyboard loopIOin a separate fiber.- The
_ <- repl(serverExitSignal).startline in theforcomprehension runs the result of.start. It starts the keyboard fiber and immediately continues to the next line (echoServer(...).compile.drain) without waiting for the keyboard loop to finish. - The main
echoServerstream pipeline is then run viacompile.drain. It runs on the main fiber of the application. - Now you have two things happening concurrently: the keyboard
replfiber is running, and the mainechoServerstream fiber is running. - The
replfiber can update theserverExitSignal(a concurrency primitive we'll see in the next chapter) when the user types "KILLSERVER". - The
echoServerstream is set up to stop (interruptWhen) when thisserverExitSignalis set totrue. - When the
echoServerstream stops (because it was interrupted), thecompile.draineffect finishes, and therunmethod continues to the finalIO.println("Server exiting").
This pattern of using start or fork is useful for starting effects that don't naturally fit as elements within a stream pipeline, but need to run alongside a stream or other effects.
How start/fork Works Under the Hood (Simplified):
When myEffect.start or myEffect.fork is executed by the runtime, it doesn't run myEffect directly. Instead, it wraps myEffect in a new lightweight task unit (a fiber) and tells the runtime scheduler to start executing that fiber. The start/fork operation itself then quickly completes, returning a reference to the newly created fiber. The original fiber that called start/fork can then immediately proceed to its next instruction.
sequenceDiagram
participant MF as Main Fiber
participant RT as Runtime
participant NF as New Fiber
participant E as My Effect
Note over MF,E: Effect blueprint E exists.
MF->>RT: Execute myEffect.start / myEffect.fork blueprint
RT->>NF: Create New Fiber, give it Effect E
RT->>NF: Schedule New Fiber for execution
RT-->>MF: Return Fiber reference (e.g., Fiber[...])
Note over MF: Main Fiber continues its work immediately.
Note over NF: New Fiber starts executing Effect E asynchronously.
loop Execution of E
NF->>E: Step of Effect E
E-->NF: Result/Instruction
end
Note over NF: New Fiber finishes running Effect E.
NF-->>RT: Signal completion/result
This shows the crucial non-blocking nature: the main fiber doesn't wait for myEffect to finish; it gets a fiber reference and keeps going while the new fiber executes myEffect in the background.
Here's a quick recap of the concurrency tools discussed:
| Operation | Purpose | On What? | What it Returns | Analogy | Example Use Case | Libraries |
|---|---|---|---|---|---|---|
merge |
Run two streams concurrently, combine output | Stream[F, O], ZStream[R, E, O] |
Same Stream type | Combining two conveyor belts | Producer/Consumer, combining data | FS2, ZIO Streams |
parJoin(n) |
Run up to n inner streams concurrently, combine output |
Stream[F, Stream[F, O]] |
Stream[F, O] |
Dispatching multiple operations simultaneously | Concurrent Server, parallel tasks | FS2 |
flatMapPar(n) |
Like flatMap but runs inner streams concurrently |
ZStream[R, E, I] |
ZStream[R, E, O] |
Parallel processing of items that yield streams | Parallel data processing | ZIO Streams |
start |
Run an IO effect in a new fiber, return fiber |
IO[A] |
IO[Fiber[IO, Throwable, A]] |
Handing task to a background helper | Running background listeners/tasks | Cats Effect |
fork |
Run a ZIO effect in a new fiber, return fiber |
ZIO[R, E, A] |
ZIO[R, Nothing, Fiber.Runtime[E, A]] |
Handing task to a background helper | Running background listeners/tasks | ZIO |
Concurrency is a powerful capability, essential for building responsive and efficient applications, especially those that handle many simultaneous inputs or involve waiting for external resources.
You can see these concurrency patterns used in the example code:
QueueExample.scala/zioVersion/QueueExample.scala: Usesmergeto run theproducerandconsumerstreams concurrently.EchoServer.scala: UsesparJoin(2)on the stream of client handling streams (Stream[IO, Stream[IO, Unit]]) to process up to 2 clients concurrently. Uses.starton the keyboardrepleffect to run it in the background while the server runs.
These examples demonstrate how to apply basic concurrency patterns to make different parts of your application run at the same time, allowing for greater responsiveness and throughput.
Concurrency is the ability to manage multiple tasks or processes seemingly happening at the same time. In the context of streams and effect systems, this is typically achieved using lightweight fibers. We learned about key operations: merge for combining the outputs of concurrent streams, parJoin/flatMapPar for concurrently processing many streams emitted by another stream, and start/fork for running individual effects in the background. These tools are essential for building applications that can efficiently handle simultaneous operations, like multiple network connections or concurrent production and consumption of data.
Running things concurrently often introduces new challenges, especially when these concurrent tasks need to share or communicate state. How can our concurrent producer and consumer safely interact via a queue? How can the background repl fiber safely signal the main echoServer stream to stop? These are problems solved by Concurrency Primitives, which we will explore in the next chapter.
Concurrency Primitives (Queue, Ref, SignallingRef)