all_lessons/functional programming/13 · Laziness & concurrencylesson 14 / 15

Laziness, streams, and concurrency

Lesson 12 gave us recursion as the FP loop and showed that persistent structures make immutable updates cheap — a value is a tree whose unchanged parts are shared. This lesson adds one new lever: control over when evaluation happens. With lazy val and by-name parameters we defer work until it is needed; with a LazyList whose tail is a deferred recursion we describe infinite sequences and force only the prefix we use; we cash out a promise from Lesson 00 — that the pillars from 03–04 (immutable data, pure functions) are exactly what make sharing values across threads safe; and we extend pull-based laziness into streaming pipelines (Source → Flow → Sink) whose backpressure processes an unbounded effectful sequence in bounded memory.

Book source
This maps to Widman's treatment of lazy evaluation and the bridge from pure functional thinking to concurrency. The book motivates laziness as separating the description of a computation from its execution, and connects immutability to data-race-free parallelism. The Scala examples, the squares/sieve traces, and the eager-Future-vs-lazy-IO framing here are original; the conceptual arc — laziness → streams → why FP and parallelism fit — follows the book.
Linear position
Prerequisite: Lesson 04 (purity & referential transparency) and Lesson 12 (recursion + persistent structures). You also lean on Lesson 03 (immutability) and Lesson 08/10 (functor/monad map/flatMap).
New capability: defer evaluation deliberately (lazy val, by-name params), build and consume infinite sequences (LazyList), separate describing a computation from running it, explain precisely why pure functions over immutable data parallelize without locks, and read a streaming pipeline (Source → Flow → Sink) and say what backpressure buys you.
The plan
Seven moves. (1) Eager vs lazy evaluation: by-name parameters and lazy val. (2) Laziness needs purity — deferring is only safe when nothing happens. (3) LazyList: an infinite list whose tail is a deferred recursion. (4) Lazy pipelines fuse, so they avoid building intermediate collections. (5) Concurrency almost for free: no shared mutable state ⇒ no data races. (6) Future[A] as a first taste of asynchrony — and why it is eager, which sets up Lesson 14's lazy IO. (7) Streaming pipelines (Source → Flow → Sink) and backpressure: processing an unbounded effectful sequence in bounded memory.

1 · Eager by default; lazy on request

Scala, like most languages, evaluates eagerly: an argument is computed before the function it is passed to runs. So in f(expensive()), expensive() runs even if f never looks at its argument. Two tools change when a value is computed.

A lazy val is computed at most once, on first access, then the result is cached. A by-name parameter, written x: => A, defers the argument: it is re-evaluated each time the body reads x, and never if the body never reads it. That second property gives us short-circuiting:

// Eager: BOTH arguments evaluated before `or` runs.
def orEager(a: Boolean, b: Boolean): Boolean = a || b

// By-name: `b` is a thunk; evaluated only if `a` is false.
def or(a: Boolean, b: => Boolean): Boolean =
  if (a) true else b          // if a is true, b is NEVER touched

def boom(): Boolean = { println("ran b!"); true }

or(true, boom())              // prints nothing, returns true
or(false, boom())             // prints "ran b!", returns true

// lazy val: computed once, then cached.
lazy val cfg = { println("loading"); loadConfig() }
// "loading" prints on the FIRST read of cfg, not before; later reads reuse it.

This is exactly how Scala's own && and || work. The point is not the operators — it is that we can now treat "the computation that produces a value" as a thing we hold and run later, rather than a value we already have.

2 · Laziness needs purity

Deferring, reordering, skipping, or caching an evaluation can only be done safely if evaluating it produces no observable effect beyond its return value — that is, if it is pure (Lesson 04). If b in or(a, b) writes a file or increments a counter, then whether we evaluate it changes the behavior of the whole program, and "skip it when a is true" silently drops an effect someone may have depended on.

This is the joint where the earlier pillars pay off. Immutable data (Lesson 03) means a deferred value cannot be changed out from under us between when we describe it and when we force it. Purity (Lesson 04) means when we force it is irrelevant to what we get. Together they make "compute this later, maybe, possibly on another thread" a safe transformation rather than a bug. Laziness is the first feature in this series that is only correct because the code is pure — keep that in mind for §5 and §6.

3 · LazyList: a list whose tail is a deferred recursion

Recall the cons-list from Lesson 12: a value is either Nil or head :: tail. A LazyList is the same shape, except the tail is by-name — it is not computed until you ask for it. Because the tail is a deferred recursion that produces another LazyList, the structure can be infinite: it is a finite description of an unbounded sequence.

val naturals: LazyList[Int] = LazyList.from(1)   // 1, 2, 3, 4, ... forever

// Nothing has been computed yet. Build a pipeline, then force a prefix:
naturals.map(n => n * n).take(5).toList          // List(1, 4, 9, 16, 25)

// A hand-rolled infinite stream: the tail is a deferred recursion.
def from(n: Int): LazyList[Int] = n #:: from(n + 1)   // #:: is lazy cons
// `from(n + 1)` is NOT called until something forces the tail.

// Fibonacci as a self-referential lazy stream:
val fibs: LazyList[BigInt] =
  BigInt(0) #:: BigInt(1) #:: fibs.zip(fibs.tail).map { case (a, b) => a + b }
fibs.take(8).toList            // List(0, 1, 1, 2, 3, 5, 8, 13)

// Prime sieve, infinite, generate-and-filter:
def sieve(s: LazyList[Int]): LazyList[Int] =
  s.head #:: sieve(s.tail.filter(_ % s.head != 0))
val primes = sieve(LazyList.from(2))
primes.take(5).toList          // List(2, 3, 5, 7, 11)

The key idea, stated plainly: a lazy list separates the description of a computation from its execution. LazyList.from(1) describes all the naturals but runs nothing; .map and .filter extend the description; only a terminal operation like .take(n).toList forces evaluation — and it forces exactly what it needs and no more.

4 · Pipelines fuse: no intermediate collections

Lazy map/filter chains process the sequence element by element, pulling one value all the way through the pipeline before requesting the next. They never materialize a full intermediate collection. Contrast with an eager List, where each stage builds a complete new list in memory:

StageEager ListLazy LazyList
map(f)allocates a full new list of all resultsdescribes "apply f on demand"; allocates nothing yet
filter(p)allocates another full listdescribes "keep those passing p on demand"
take(5)slices the already-built full listpulls exactly 5 elements through the whole chain
MemoryO(n) per stageO(1) live data + the result
Infinite input?impossible — the first map never returnsfine — only the demanded prefix runs

So (1 to 1000000).to(LazyList).map(_ * 2).filter(_ % 3 == 0).take(4).toList does only enough work to produce four elements, never allocating a million-element intermediate. This is the same map/filter you learned as functor operations (Lesson 08) — laziness changes only when they run, not what they mean.

Worked example — trace what actually evaluates
Define the naturals and double them, then take five and force the result:
val naturals = LazyList.from(1)   // describes 1,2,3,...  (computes NOTHING)
val evens    = naturals.map(_ * 2) // describes 2,4,6,...  (still NOTHING)
evens.take(5).toList               // forces here -> List(2,4,6,8,10)
What ran? take(5).toList demands element 1: it pulls 1 from naturals, applies _ * 22. Demands element 2: pulls 2, → 4. …through element 5: pulls 5, → 10. Then take is satisfied and stops. Naturals 6, 7, 8, … and the multiplications on them are never evaluated — even though naturals and evens both describe infinitely many. Exactly five doublings happened. "Description vs execution" is now concrete: the program says infinite, the run does five.

5 · Concurrency almost for free

Here is the deep payoff, and the reason this lesson sits next to laziness. A data race is two threads accessing the same memory location, at least one writing, without coordination — the classic source of nondeterministic concurrency bugs, normally fenced off with locks. A data race requires shared mutable state. Remove either word and it cannot occur:

immutable data (Lesson 03)
A value never changes after construction. Sharing it across threads is always safe — there is no "write" for any reader to race against. Threads can read the same list freely; no lock protects something that cannot change.
pure functions (Lesson 04)
A pure function reads only its arguments and writes nothing outside its result. Two threads running the same pure function on (possibly the same) immutable inputs cannot interfere — there is no shared cell either of them mutates.

So a pure map over an immutable List can be split across N threads — each handles a chunk, results are recombined — with no locks and no race, because nothing shared is ever written. (The "recombine" step is exactly an associative monoid combine from Lesson 09, which is why associativity is what enables parallel folds.) This is not a library trick; it is a direct consequence of the two pillars. Imperative code needs locks because it shares mutable state; functional code does not share mutable state, so it does not need locks. That is the real reason FP and parallelism fit together.

6 · Future[A]: a first taste of asynchrony

A Future[A] is a value that will hold an A once an asynchronous computation finishes. Crucially, it is a functor and monad (Lessons 08, 10): you map to transform the eventual result and flatMap to sequence dependent async steps — so you can compose asynchronous work in a for-comprehension without ever touching threads or locks directly:

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

def fetchUser(id: Int): Future[User]      = ...
def fetchOrders(u: User): Future[Seq[Order]] = ...

val result: Future[Int] =
  for {
    user   <- fetchUser(42)        // flatMap
    orders <- fetchOrders(user)    // flatMap, depends on user
  } yield orders.size              // map

// No locks, no thread juggling — just functor/monad composition.

One catch, and it is the bridge to the final lesson: a Future is eager. The moment you write fetchUser(42), the request is already running — the effect has fired. So a Future is not a pure description you can pass around and run later; it is an effect already in flight, which means it is not referentially transparent (Lesson 04). That is precisely the gap Lesson 14 closes with the IO monad, which turns even side effects into lazy values you build purely and run only at the edge.

7 · Streaming pipelines and backpressure

A LazyList (§3–4) is pull-based, but in one narrow setting: a single consumer, in one thread, pulling pure data. Real systems stream something harder — an unbounded sequence of effects, with a producer and a consumer running independently and at different speeds: reading a 100 GB file, draining a socket, consuming a Kafka topic. Two failures lurk. If the producer outruns the consumer and you buffer the gap, memory grows without bound (an out-of-memory crash on a large input). If instead you block the producer on every element, you waste the parallelism that made it asynchronous. Neither "load it all into a List" nor "fire a Future per element" solves this: the first does not fit in memory, the second has no rate control.

The functional answer keeps the describe-then-run discipline you have seen three times now (by-name thunks, LazyList, and — next lesson — IO) and gives it a pipeline shape: Source → Flow → Sink.

Source
Produces elements, possibly infinite, possibly effectful — the lines of a file, the messages on a queue. The streaming analog of LazyList.from.
Flow
A transformation stage: map, filter, grouped — the very functor operations from Lesson 08, now stages in a pipeline rather than methods on a collection.
Sink
Consumes the stream terminally — fold to a count, write to a file, push to a socket. Like LazyList's .toList, it is what forces the pipeline to run.

You connect a Source through some Flows to a Sink to describe a pipeline; like every other lazy value in this lesson, nothing runs until you run the whole graph. The new idea is backpressure: the Sink signals how much it is ready to consume, that demand propagates upstream through the Flows to the Source, and the Source produces only as fast as the slowest downstream stage can absorb. This is exactly the pull-based laziness of §3–4 — the consumer pulls, work happens on demand — generalized to independently running stages. The payoff is decisive: the pipeline runs in bounded memory regardless of input size, with no manual buffering and no locks, because the rate is coordinated by demand rather than by a shared mutable queue.

LazyList (§3–4)Backpressured stream
Elementspure dataeffectful (file/socket/queue) or pure
Stagesone consumer, in-processindependent stages, possibly across threads/machines
Rate controln/a (single puller)demand propagates upstream — the producer is throttled
Memory on huge inputO(1) live dataO(1) — bounded by the slowest stage, not the input size
Worked example — word-count a file of any size in constant memory
// pseudocode in the Source/Flow/Sink shape
val pipeline =
  Source.fromFile("huge.txt")          // emits lines, lazily, on demand
    .mapConcat(line => line.split(" ")) // Flow: each line -> its words  (map/filter, Lesson 08)
    .map(_.toLowerCase)                 // Flow: normalize
    .runFold(0)((count, _) => count + 1)// Sink: fold to a total — forces the run
Because the fold (Sink) pulls one line's worth at a time and that demand throttles the file read (Source), a 1 KB file and a 100 GB file run in the same fixed memory. No stage ever holds more than a few elements in flight. Compare the naive Source.fromFile(...).toList: it tries to materialize 100 GB of lines and dies. Backpressure is what makes "process an unbounded effectful sequence" safe — it is the streaming counterpart of the bounded-memory fusion you saw on LazyList in §4.

The two production libraries are Akka/Pekko Streams — whose API is literally Source/Flow/Sink with built-in backpressure — and FS2 (Functional Streams for Scala). FS2 goes one step further: its stream is effect-typed, written Stream[IO, A] — a description of a stream of IO effects. That builds directly on the IO monad of Lesson 14, so the fully-pure version of streaming — effects and all, run only at the edge — is something you will only be able to read after the next lesson. Hold the Source→Flow→Sink picture; Lesson 14 supplies the IO that makes FS2's version of it referentially transparent.

Common mistakes / failure modes

A lazy val that hides an effect
lazy val x = { writeAudit(); 42 } — now when the audit write happens depends on the unpredictable moment of first access, which may be never, or on a different thread. Laziness over impure code makes timing a bug. Keep deferred computations pure (§2).
Holding the head of a huge LazyList
If a val references the start of a long/infinite stream while you traverse it, every forced element stays reachable and cannot be garbage-collected — a memory leak. Consume it without keeping a reference to the head, or use an iterator (§3).
Assuming Future is lazy
Future { sideEffect() } runs immediately on creation, not when you call onComplete. Wrapping an effect in a Future does not defer it — it just moves it to another thread, now. Use IO (Lesson 14) for true deferral (§6).
Expecting infinite to "just work" on List
(1 to N).toList.map(...).take(5) with a huge N materializes the whole thing first. Reach for LazyList / view when you want take-only-what-you-need (§4).
Buffering instead of backpressuring
Pulling a fast Source into an unbounded queue for a slow consumer trades a clean OOM for a slow one. A backpressured pipeline throttles the producer to the consumer's pace so memory stays bounded — don't hand-roll the buffer (§7).

Checkpoint exercise

Try it
(a) Write an infinite LazyList of perfect squares and take the first four. One way: LazyList.from(1).map(n => n * n).take(4).toListList(1, 4, 9, 16); verify by tracing that only four multiplications run (§4 worked example). (b) Explain, in two sentences, why the same map over an immutable List can be run on N threads with no lock — name the two pillars (§5: the data is immutable so there is no write to race against; the function is pure so the threads share no mutable cell). Then state why doing the same with a Future per element still does not need locks, yet is not the same as a lazy description (§6).

Where this points next

We can now defer computation, describe infinite sequences and force only what we need, and we understand why purity over immutable data makes concurrency safe. But Future exposed a crack: it is eager, so it is an effect already running, not a pure value. Lesson 14 closes the loop the whole series has been building toward. The IO monad makes even side effects into lazy values — descriptions of effects you compose purely with map/flatMap and run only once, at the very edge of the program. That is the imperative shell from Lesson 00 made concrete, and with type classes (trait + given/implicit) it assembles the entire functional-core / imperative-shell architecture from the parts of all fourteen prior lessons.

Takeaway
Laziness is control over when evaluation happens: lazy val computes once and caches, a by-name parameter x: => A defers and can short-circuit, and a LazyList — a cons-cell whose tail is a deferred recursion (Lesson 12) — describes possibly-infinite sequences that a terminal take/toList forces exactly as far as needed, fusing pipelines so no intermediate collection is built. All of this is only safe because the deferred work is pure (Lesson 04) over immutable data (Lesson 03) — the same two pillars that, by eliminating shared mutable state, eliminate data races, so pure functions over immutable data parallelize across threads without locks. Future[A] is the first taste of composing asynchrony as a functor/monad in a for-comprehension, but it is eager — an effect already in flight — which is exactly the gap Lesson 14's lazy IO closes by making effects themselves into pure values run only at the edge.

Interview prompts