Concurrency Patterns: The Reusable Shapes for Safe Multithreaded Design
Concurrency patterns explained: producer-consumer, thread pool, future, read-write lock, thread-safe singleton, and a semaphore-bounded pool for safe multithreaded design.
Walk past a busy restaurant kitchen at eight on a Friday and it looks like chaos — but stand there a minute and you’ll see it’s the opposite. Orders don’t get shouted at whichever cook is nearest. They land on a rail. Cooks don’t get hired the instant a table sits down and fired when it leaves; there’s a fixed brigade that just keeps pulling the next ticket. And a finished dish doesn’t chase its waiter around the floor — it waits on the pass with a number on it. Nobody designed that from scratch tonight. It’s the shape every busy kitchen converges on, because it’s the shape that works.
Multithreaded code converges on the same handful of shapes, and that’s what this article is about. You already know the raw materials — threads, locks, atomics. What you need next are the reusable patterns built from them: a buffer that hands work between fast and slow parties, a fixed crew that reuses its threads, a handle that stands in for a result you’ll have later. Learn the shapes and you stop hand-rolling synchronization (and hand-rolling the bugs that come with it). By the end you and I will have built each one, run it, and named the exact trap that fails the interview for each.
This is the level above the primitives. If threads, race conditions,
synchronized, volatile, and atomics aren’t already comfortable, read
multithreading, explained first — it
builds every tool this article now assembles into patterns. Here we don’t
re-teach a lock; we teach the shapes you make out of locks.
Let’s stay in the kitchen a minute
Picture the flow end to end. Waiters take orders and clip tickets to a rail. Cooks — a fixed brigade, four of them, no more — pull the oldest ticket, cook it, and clip the plate to the pass. Each part is deliberately deaf to the others’ timing.
Three separate cleverness’s are hiding in that picture, and each is a pattern you’ll write in code:
- The rail is a buffer. Waiters never wait for a cook to be free; they clip the ticket and go. Cooks never stand idle wondering if an order is coming; they read the rail. The rail decouples their speeds. That’s producer–consumer.
- The brigade is fixed. You don’t spawn a cook per order — you’d have three hundred cooks by nine and nobody could move. A fixed crew pulls tickets forever. That’s a thread pool.
- The pass is a promise. The waiter drops the order and gets, in effect, a claim check. The dish appears there later; the waiter collects it when it’s ready. That’s a future.
Every concurrency pattern in this article is one of these instincts made precise — plus three more (a read–write lock, immutability, a permit-gated pool) for the moments the kitchen metaphor stops stretching.
Where you already meet these
You lean on these patterns every day through libraries that hid them for you:
- Every web server. Tomcat, Netty, and friends don’t make a thread per request — they hand requests to a bounded thread pool. That’s why an overloaded server queues instead of forking itself to death.
- Your database access. HikariCP, the connection pool under most apps, is a semaphore-bounded resource pool: a fixed number of connections, and threads wait their turn for one.
- Async application code. Every
CompletableFutureyou’ve chained is the future pattern — hand back a result you’ll fill in later. - Read-mostly caches and configs. A
ConcurrentHashMapor a copy-on-write snapshot lets a hundred readers through at once and serializes only the writers — the read–write instinct. - Async logging. Log4j2’s async appender is a textbook producer–consumer: your threads drop log events on a ring buffer, one background thread writes them out.
The point of naming them is leverage. Once you see "a fast side and a slow side" and think bounded queue, or "a scarce resource" and think semaphore, you stop reinventing synchronization badly and start composing the shapes people have already proven correct.
The one idea under all of them
Here’s the mental model to carry through the whole article. The primitives — threads, locks, volatile, atomics — are the ingredients. The patterns are the recipes. You almost never want to cook with raw primitives (that’s how you get a half-cooked lock and a race you’ll debug at 2 a.m.); you want the recipe that’s already been tasted a million times. The rest of this article is that cookbook — seven recipes, each with the dish it makes and the way it burns.
Pattern 1 — producer–consumer: a queue between fast and slow
The setup: one set of threads produces work (requests, log lines, image jobs) and another set consumes it, and the two sides don’t run at the same speed. If you let them touch a shared list directly, you’re back to hand-rolling wait/notify and losing signals. The pattern is to put a BlockingQueue between them and let it do the waiting.
The queue’s two operations are the whole trick: put blocks the producer when the queue is full, and take blocks the consumer when it’s empty. Nobody spins, nobody polls, no signal gets dropped.
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(64); // BOUNDED
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 1000; i++) {
queue.put(i); // blocks if the queue is full → backpressure
}
queue.put(POISON); // a "poison pill": no more items coming
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
long sum = 0;
while (true) {
int x = queue.take(); // blocks if the queue is empty
if (x == POISON) break; // consumer stops when it sees the pill
sum += x; // sum ends at 500500
}The rule in plain words: let the queue own the blocking. The producer’s only job is to put; the consumer’s only job is to take; the handoff and all the waiting live in the queue, which is already correct. The poison pill is the standard way to say "we’re done" — a sentinel value the consumer recognises and stops on.
The bound is not optional — it’s the whole safety story. An unbounded queue
(new LinkedBlockingQueue<>() with no capacity) means put never blocks, so a
producer that outruns its consumers piles work into memory until the heap is
gone and the JVM dies with an OutOfMemoryError. A bounded queue converts
"produce too fast" into a harmless pause (backpressure) instead of a crash.
Always ask: what’s the capacity, and what happens when it’s hit?
Pattern 2 — thread pool: a fixed crew that reuses its threads
Creating a thread is not free — it’s a real OS resource with a chunk of stack memory, and a thousand of them will thrash the scheduler and exhaust memory long before they help. The thread pool fixes the crew size once and feeds it from a queue of tasks. Submit ten thousand tasks and you still have four threads; they just take turns.
An ExecutorService is the pattern in library form — you hand it tasks, it schedules them onto its workers.
ExecutorService pool = Executors.newFixedThreadPool(4);
List<Future<Integer>> futures = new ArrayList<>();
for (int i = 1; i <= 8; i++) {
int k = i; // capture a per-task copy
futures.add(pool.submit(() -> k * k)); // hand the task over, get a Future
}
int total = 0;
for (Future<Integer> f : futures) {
total += f.get(); // sum of squares 1..8 = 204
}
pool.shutdown();The rule: decouple "how much work" from "how many threads." The pool caps concurrency at its size, so load turns into a queue, not into a thread explosion.
Executors.newFixedThreadPool(n) hides an unbounded task queue — the same
OOM trap as Pattern 1, one layer down. Under overload the threads stay capped
but the backlog grows without limit. For anything load-bearing, build the pool
explicitly so the queue is bounded and overload is visible:
ExecutorService pool = new ThreadPoolExecutor(
4, 4, // core = max = 4 workers
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100), // bounded backlog
new ThreadPoolExecutor.CallerRunsPolicy()); // full? the submitter runs itCallerRunsPolicy is backpressure for pools: when the queue is full, the thread
that submitted the task runs it itself, which naturally slows the producers down.
Pattern 3 — future: a handle for a result you’ll have later
You saw Future appear the moment you called submit above — that’s the third pattern already. A future (or promise) is a typed claim check: you kick off work now and immediately get back an object that will eventually hold the result. You keep doing other things; you call get() only when you actually need the value, and it blocks just long enough for the answer to be ready — no longer.
ExecutorService pool = Executors.newSingleThreadExecutor();
Callable<Long> job = () -> {
long f = 1;
for (int i = 2; i <= 10; i++) f *= i; // 10! = 3628800
return f;
};
Future<Long> future = pool.submit(job); // returns immediately, work runs elsewhere
// ... do other useful work here while it computes ...
long result = future.get(); // blocks only until the value is ready
pool.shutdown();The rule: separate starting the work from needing the answer. Between submit and get, the calling thread is free. That gap is where parallelism lives.
Reach for CompletableFuture when you want to compose results without ever
blocking a thread — future.thenApply(...).thenCombine(other, ...) builds a
pipeline that runs when its inputs arrive. And always prefer the timed
get(timeout, unit): a plain get() on a task that hangs will hang your
caller forever, turning one stuck worker into a stuck request.
Pattern 4 — read–write lock: many readers, or one writer
A plain lock (synchronized, or a ReentrantLock) is a bouncer that lets exactly one thread in at a time — even two threads that only want to read. For data that’s read far more than it’s written, that’s a waste: two reads never corrupt each other. A ReadWriteLock splits the door in two. Any number of readers may hold the read lock at once; a writer must hold the write lock alone, with every reader shut out until it’s done.
public final class SharedConfig {
private final Map<String, String> map = new HashMap<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public String get(String key) {
lock.readLock().lock(); // readers share this lock
try {
return map.get(key);
} finally {
lock.readLock().unlock();
}
}
public void put(String key, String value) {
lock.writeLock().lock(); // a writer gets it alone
try {
map.put(key, value);
} finally {
lock.writeLock().unlock();
}
}
}The rule: let reads run in parallel; serialize only the writes. It’s a pure win exactly when reads dominate — and a wash (or worse, from the extra bookkeeping) when writes are frequent.
Two traps hide here. First, writer starvation: if reads never stop
arriving, a plain read–write lock can keep a writer waiting indefinitely — use
the fair constructor new ReentrantReadWriteLock(true) when writes must
make progress. Second, you cannot upgrade a read lock to a write lock
while holding it (that deadlocks); release the read lock first, or reach for
StampedLock’s optimistic read. If writes get heavy, stop fighting the lock
and publish an immutable snapshot instead — which is the next pattern.
Pattern 5 — immutability: the pattern that needs no lock
The cheapest way to win a race is to make one impossible. If an object can never change after construction, then sharing it across threads is automatically safe — there’s no write for a reader to catch half-done, so there is nothing to synchronize. No lock, no atomic, no thought.
public final class Money { // final class: no mutable subclass
private final long cents; // every field final
private final String currency;
public Money(long cents, String currency) {
this.cents = cents;
this.currency = currency;
}
public Money plus(Money other) { // "mutation" returns a NEW object
return new Money(this.cents + other.cents, this.currency);
}
public long cents() { return cents; }
}The rule: share nothing mutable. An immutable object is thread-safe by construction; a "change" just mints a fresh instance and leaves the old one untouched for whoever’s still reading it. Its cousin is thread confinement — never share the object at all, give each thread its own copy (that’s what ThreadLocal is for). Both make the concurrency problem vanish rather than manage it.
final guards the reference, not the object it points to. A final List<String>
whose contents you keep mutating is not immutable — a reader can still see a
half-updated list. True immutability means the fields are final and deeply
unmodifiable: copy incoming collections defensively and wrap them in
List.copyOf(...) before you store them.
Pattern 6 — the thread-safe singleton
You want exactly one instance of something (a config, a connection factory), created lazily, and you want every thread to get the same one without paying for a lock on every access. There are two correct patterns, and one infamous way to get it subtly wrong.
The clean one is the initialization-on-demand holder idiom — it leans on a guarantee the classloader already gives you for free: a class is initialized once, lazily, and thread-safely, the first time it’s touched.
public final class Config {
private Config() { /* expensive load */ }
private static final class Holder {
static final Config INSTANCE = new Config(); // JVM inits this once, safely
}
public static Config getInstance() {
return Holder.INSTANCE; // first call triggers Holder init; no locks after
}
}The other is double-checked locking (DCL) — historically how people hand-rolled it, and the source of one of concurrency’s most famous bugs.
public final class Config {
private static volatile Config instance; // ← the volatile is load-bearing
private Config() {}
public static Config getInstance() {
Config local = instance; // one volatile read on the fast path
if (local == null) {
synchronized (Config.class) {
local = instance;
if (local == null) {
instance = local = new Config();
}
}
}
return local;
}
}The rule: prefer the holder idiom; if you must write DCL, the field must be volatile.
Drop the volatile and DCL breaks in a way no small test will catch.
instance = new Config() is not one step — it allocates, runs the
constructor, and assigns the reference, and without volatile the JVM is free
to make the reference visible before the constructor finishes. A second
thread sees a non-null instance, skips the lock, and hands back a
half-constructed object. The holder idiom sidesteps the whole minefield —
which is exactly why it’s the one to reach for.
Pattern 7 — a semaphore-bounded resource pool
Some resources are genuinely scarce: database connections, licences, outbound sockets. You want a hard ceiling — at most N in use at once — and everyone else waits their turn. A Semaphore is a bowl of N permits: acquire one to enter, release it to leave, and if the bowl is empty you block until someone drops a permit back.
public final class ConnectionPool {
private final Semaphore permits;
private final BlockingQueue<String> idle;
public ConnectionPool(int size) {
permits = new Semaphore(size); // exactly `size` tickets
idle = new ArrayBlockingQueue<>(size);
for (int i = 0; i < size; i++) idle.add("conn-" + i);
}
public String borrow() throws InterruptedException {
permits.acquire(); // no permit? wait — never over-issue
return idle.take();
}
public void giveBack(String conn) {
idle.add(conn);
permits.release(); // hand the ticket to the next waiter
}
}The rule: a permit count is the ceiling. The semaphore, not luck, guarantees no more than N threads ever hold a connection at once — which is precisely how HikariCP keeps your database from being swamped.
A leaked permit is forever. If borrow succeeds but an exception skips the
matching giveBack, that permit is gone from the bowl — do enough of that and
the pool deadlocks with every thread waiting on acquire. Always release in a
finally. And for pure coordination rather than gating, know the two
siblings: a CountDownLatch is a one-shot finish line (wait until N tasks
report done), while a CyclicBarrier is a reusable rendezvous (N threads
wait for each other, then all proceed, and it resets).
Fixing a real race with the right shape
Every one of these patterns exists to kill a class of bug, and the bug the site keeps returning to is the read-modify-write race — the same lost update that bites the rate limiter, the LRU cache, and the counter in multithreading. count++ looks atomic but is three steps — read, add, write — and two threads can interleave so an increment simply vanishes.
The pattern-level fix isn’t a lock around count++ (correct, but slow) — it’s to pick a type whose read-modify-write is already one indivisible hardware step: an atomic.
AtomicLong counter = new AtomicLong();
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int t = 0; t < 4; t++) {
pool.submit(() -> {
for (int i = 0; i < 25000; i++) {
counter.incrementAndGet(); // one atomic step — no update can be lost
}
});
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);
// counter.get() == 100000 — every single time, not "usually"incrementAndGet fuses the three steps into one uninterruptible operation, so four threads doing 25,000 increments each land on exactly 100,000 — deterministically, run after run. That’s the difference between "shares mutable state and hopes" and "uses the shape that can’t lose."
The patterns vs. hand-rolling it
Every pattern is measured against the naive thing you’d write without it — a shared mutable field guarded (or not) by ad-hoc synchronization. The last column is the trap the pattern introduces, because every shape has one.
| Pattern | The naive alternative | What the pattern buys | The trap to watch |
|---|---|---|---|
| Producer–consumer | shared list + hand-rolled wait/notify | clean handoff + backpressure, no lost signal | unbounded queue → OOM |
| Thread pool | new Thread() per task | capped, reused threads; load becomes a queue | hidden unbounded queue; pool-induced deadlock |
| Future / promise | block on a shared result + a ready flag | a typed handle you fill later; composable | get() with no timeout → hangs forever |
| Read–write lock | one synchronized mutex for everything | readers run in parallel | writer starvation under constant reads |
| Immutability / confinement | shared mutable object + locks everywhere | zero locks — safe by construction | a final ref to a mutable field isn’t safe |
| Thread-safe singleton | lazy getInstance() with no sync | one instance, lazy, lock-free reads | DCL without volatile → half-built object |
| Semaphore-bounded pool | create resources on demand, unbounded | a hard ceiling on a scarce resource | forget to release() → permit leak → deadlock |
Read the table as one sentence per row: the naive version is simpler to type and wrong under load; the pattern is the shape that stays correct when the threads actually collide.
The complete implementation
Everything above, assembled into one runnable file — the producer–consumer sum, the pool, the future, the atomic counter, and the two reusable classes (SharedConfig, ConnectionPool):
package dev.fiveyear.concurrency;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public final class ConcurrencyPatterns {
private ConcurrencyPatterns() {}
private static final int POISON = Integer.MIN_VALUE;
/** Producer–consumer over a BOUNDED queue: one producer, one consumer, a sum. */
public static long producerConsumer(int n) throws InterruptedException {
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(64);
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= n; i++) {
queue.put(i); // blocks if full → backpressure
}
queue.put(POISON); // poison pill: no more items
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
long sum = 0;
while (true) {
int x = queue.take(); // blocks if empty
if (x == POISON) break;
sum += x;
}
producer.join();
return sum;
}
/** Thread pool: submit N independent tasks to a fixed crew, collect results. */
public static int sumOfSquares(int n) throws InterruptedException, ExecutionException {
ExecutorService pool = Executors.newFixedThreadPool(4);
try {
List<Future<Integer>> futures = new ArrayList<>();
for (int i = 1; i <= n; i++) {
int k = i; // capture per task
futures.add(pool.submit(() -> k * k));
}
int total = 0;
for (Future<Integer> f : futures) {
total += f.get(); // block only when needed
}
return total;
} finally {
pool.shutdown();
}
}
/** Future: start a slow computation now, collect the value later. */
public static long factorialAsync(int n)
throws InterruptedException, ExecutionException {
ExecutorService pool = Executors.newSingleThreadExecutor();
try {
Callable<Long> job = () -> {
long f = 1;
for (int i = 2; i <= n; i++) f *= i;
return f;
};
Future<Long> future = pool.submit(job); // returns immediately
return future.get(); // blocks until ready
} finally {
pool.shutdown();
}
}
/** The read-modify-write race, fixed: an atomic increment is indivisible. */
public static long countConcurrently(int threads, int perThread)
throws InterruptedException {
AtomicLong counter = new AtomicLong();
ExecutorService pool = Executors.newFixedThreadPool(threads);
for (int t = 0; t < threads; t++) {
pool.submit(() -> {
for (int i = 0; i < perThread; i++) {
counter.incrementAndGet(); // no lost updates
}
});
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);
return counter.get();
}
/** Read–write lock: many readers in parallel, writers exclusive. */
public static final class SharedConfig {
private final Map<String, String> map = new HashMap<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public String get(String key) {
lock.readLock().lock();
try {
return map.get(key);
} finally {
lock.readLock().unlock();
}
}
public void put(String key, String value) {
lock.writeLock().lock();
try {
map.put(key, value);
} finally {
lock.writeLock().unlock();
}
}
}
/** Semaphore-bounded pool: at most `size` connections are ever handed out. */
public static final class ConnectionPool {
private final Semaphore permits;
private final BlockingQueue<String> idle;
public ConnectionPool(int size) {
permits = new Semaphore(size);
idle = new ArrayBlockingQueue<>(size);
for (int i = 0; i < size; i++) idle.add("conn-" + i);
}
public String borrow() throws InterruptedException {
permits.acquire(); // wait for a permit — never over-issue
return idle.take();
}
public void giveBack(String conn) {
idle.add(conn);
permits.release(); // wake exactly one waiter
}
}
}And here it is producing exactly the outputs the comments claim:
ConcurrencyPatterns.producerConsumer(1000); // 500500
ConcurrencyPatterns.sumOfSquares(8); // 204
ConcurrencyPatterns.factorialAsync(10); // 3628800
ConcurrencyPatterns.countConcurrently(4, 25000); // 100000 (every run, not "usually")
var config = new ConcurrencyPatterns.SharedConfig();
config.put("version", "v1");
config.put("version", "v2");
config.get("version"); // "v2"
var pool = new ConcurrencyPatterns.ConnectionPool(2);
String c = pool.borrow(); // "conn-0"
pool.giveBack(c); // permit returned to the bowlThe atomic counter is the one to stare at: run it a thousand times and it’s 100000 a thousand times. Swap AtomicLong for a plain long and it drops below 100000 on almost every run — the lost-update race, live.
The interview corner
Concurrency questions rarely ask you to invent a primitive. They hand you a scenario — "a hundred requests hit this at once" — and watch whether you reach for the right shape and know where it breaks.
Ask these before you write a line:
- "Is the work CPU-bound or I/O-bound?" It decides the pool size entirely — a CPU-bound pool wants roughly one thread per core; an I/O-bound pool wants many more, because most threads are parked waiting.
- "What’s the read-to-write ratio on the shared state?" Read-heavy points at a read–write lock or an immutable snapshot; write-heavy makes both pointless, and a plain lock or a sharded structure wins.
- "Can the producer outrun the consumer, and if so, do we block, drop, or reject?" This is the bounded-vs-unbounded question, and it’s the single most important decision in the whole design.
The follow-up ladder — where a strong candidate keeps climbing:
- "The queue fills up — now what?" Name a policy on purpose: block the producer (backpressure, the safe default), drop the oldest/newest, or reject and shed load. A
ThreadPoolExecutorlets you pick —CallerRunsPolicythrottles by making the submitter do the work. - "Size the pool for me." For CPU-bound work, ≈ cores + 1. For I/O-bound, cores × (1 + wait/compute) — a task that waits 90% of the time wants roughly ten threads per core. Then cap it against downstream limits (a pool of 200 threads hammering a 10-connection database just moves the queue).
- "Two pooled tasks wait on each other’s result and it hangs." That’s pool-induced deadlock: a task blocked inside the pool on another task in the same bounded pool can starve it. Never submit-and-block on a dependent task in the same pool — give it a separate pool, or compose with
CompletableFutureso nothing blocks a worker. - "Reads dominate but the writer never gets in." Writer starvation — switch to a fair
ReentrantReadWriteLock(true), or stop locking entirely and publish an immutable snapshot that writers replace atomically (copy-on-write). - "Coordinate a start line and a finish line across N workers." A
CountDownLatchfor a one-shot finish ("wait until all N have loaded"), aCyclicBarrierfor a reusable rendezvous ("all N reach phase boundary, then go again"). Knowing which is reusable is the tell. (These coordination shapes pair naturally with the design patterns you’d use to structure the workers themselves.)
Three mistakes that fail the round:
- An unbounded queue you didn’t know was there.
Executors.newFixedThreadPoolandnewSingleThreadExecutorboth back onto an unbounded queue — under sustained overload the backlog eats the heap. Saying "fixed thread pool" without saying "bounded queue" is the tell that you’ve never run one hot. - Double-checked locking without
volatile. The classic. Without it a second thread can hand out a half-constructed object, and it passes every casual test. If you write DCL, thevolatileis not decoration — and the holder idiom avoids the question. - Holding a lock across a blocking call. Locking, then doing I/O or calling
future.get()or grabbing a second lock while holding the first — that’s how throughput collapses and how deadlocks are born. Hold locks for the shortest possible critical section, always release infinally, and always acquire multiple locks in a fixed global order.
Where to go from here
You now own the cookbook: a bounded queue between fast and slow, a fixed reused crew, a handle for a later result, readers-parallel-writers-exclusive, share-nothing-immutable, a lazy safe singleton, and a permit-gated ceiling — each with the exact trap that undoes it. Three natural next stops:
- Lock-free structures.
ConcurrentHashMap,LongAdder, and CAS-based queues push past locking entirely for hot paths — the next rung above the atomic you used to fix the race. - Structured concurrency and virtual threads.
CompletableFuturecomposition and the newer structured-concurrency and virtual-thread models make "start work, collect it later" cheap enough to spawn by the million — the future pattern, industrialised. - Share-nothing at scale. The actor model and message passing take immutability’s "share nothing" all the way up: no shared state at all, only messages between isolated workers.
And it all rests on the primitives underneath — so if any step here felt like it skipped a beat, multithreading, explained is the floor this whole cookbook stands on, and the thread-safe LRU cache is these patterns worked end to end on one real data structure.
Next time you walk past that kitchen at eight on a Friday, you’ll see it for what it is: a producer–consumer rail, a fixed brigade, and a pass full of futures — the same three shapes you now know how to write.