Build a Thread Pool
Build a Thread Pool
You already know the theory from Thread Pools, Executors & Futures: reuse worker threads instead of spawning one per request. This project makes you build the pool yourself — worker loop, bounded queue, rejection policy, graceful shutdown, and a Future-returning submit() — in Go and Java, from an empty file, and prove the bounded-queue decision with a test that actually demonstrates the failure it prevents. Every staff-level system-design interview eventually asks "how would you bound this," and a thread pool is where that instinct gets built.
The Trap
Say your service handles each incoming request the obvious way:
// The naive approach — looks fine in a demo, dies in production.
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket client = server.accept();
new Thread(() -> handle(client)).start(); // one new OS thread per request
}
At 10 requests/sec this is invisible. At 5,000 concurrent connections during a traffic spike, you now have 5,000 live threads. Each Java thread reserves roughly 1 MB of stack by default — that's ~5 GB of stack space alone before a single byte of application data. Worse than the memory: the OS scheduler is now context-switching between thousands of runnable threads competing for however many CPU cores you actually have (commonly 4–32). Cache lines get evicted every switch, the CPU spends more cycles switching than computing, and throughput falls as load rises — the exact opposite of what you want under a spike. Eventually thread creation itself fails with OutOfMemoryError: unable to create new native thread, and the whole service goes down, not gracefully — all at once.
The fix is not "handle it faster." It's structural: cap the number of worker threads at some small N, and feed them work through a queue instead of minting a thread per unit of work. That's a thread pool — and the moment you say "cap it," you've opened three decisions that are the actual substance of this kata: how big is the queue, what happens when it's full, and how do you stop cleanly.
Scope it like a senior
Before writing a line of pool code, pin down the contract — these are the questions that separate "I used ExecutorService" from "I understand what it's hiding":
- Fixed, cached, or work-stealing? Fixed = N threads, constant, for a steady CPU-bound load. Cached = grows/shrinks with demand, good for short bursty IO tasks, dangerous under sustained load (unbounded threads is the same trap as before, one level up). Work-stealing (Java's
ForkJoinPool) = for recursive divide-and-conquer work where tasks spawn subtasks. This kata builds fixed — it's the one every other shape specializes from. - Bounded or unbounded task queue? This is the single highest-leverage decision in the whole design (movement 3 derives why bounded is mandatory).
- Rejection policy when the queue is full? Reject the caller loudly (abort), push the work back onto the caller (caller-runs), or silently drop the oldest queued task? Each is a different promise to the rest of the system.
- Shutdown: drain or abort? Does
shutdown()let in-flight and already-queued work finish (graceful), or does it drop everything and interrupt workers immediately (shutdownNow)? A payments service almost certainly wants the former; a request that's already timed out client-side wants the latter. - Do callers need a result? Fire-and-forget (
Runnable) is simpler; anything that needs a return value or an exception needs aFuture.
Answer: fixed pool, N configurable, bounded queue (default small, e.g. 2×N), ABORT as the default rejection policy with CALLER_RUNS available, graceful shutdown by default with a hard shutdownNow() escape hatch, and both Runnable and Callable<T> submission.
Reason to the design
Simplest thing that could work: one shared queue, N worker threads in a loop popping and running tasks.
// The core loop — this is 90% of a thread pool's actual logic.
while (running) {
Runnable task = queue.take(); // blocks until work exists
task.run();
}
This is just Producer–Consumer with the "workers" as consumers and submit() as the producer — if you've built a bounded blocking queue before, you already have the hard part. What's new here is everything around the loop.
Why it fails as written: queue above is unbounded by default (that's what Executors.newFixedThreadPool actually gives you under the hood — a LinkedBlockingQueue with no capacity bound). If producers submit faster than N workers can drain, the queue absorbs the difference by growing. It never rejects, never slows the producer down, never signals overload to anyone — it just quietly eats memory. This is strictly worse than the thread-per-request trap in one way: it fails silently. Thread-per-request at least throws an exception loudly when threads run out; an unbounded queue lets the producer believe every request succeeded while the backlog balloons toward an OOM that lands minutes later, with a stack trace that points nowhere near the real cause.
The fix — make the queue bounded and force a decision at the full point: once queue.offer(task) can fail (because the queue is at capacity), you are forced to decide, explicitly, what happens next — and that decision is a rejection policy, not an afterthought. This is the derivation: bounded queue + explicit rejection policy is not a nice-to-have hardening step, it is the mechanism that converts a silent, delayed OOM into an immediate, visible backpressure signal. The system tells the truth about its capacity the moment it's exceeded, instead of lying until it can't anymore.
Layered on top of the core loop:
- Bounded queue — a fixed-capacity blocking queue (Java:
ArrayBlockingQueue/LinkedBlockingQueue(cap); Go: a buffered channel with capacitycap) — the channel/queue capacity is the design decision. - Rejection policy — what
submit()does when the queue is full: throw/return an error (ABORT), run on the caller's own thread (CALLER_RUNS — real backpressure, since the caller is now busy and can't submit more until it finishes), or evict the oldest queued item (DROP_OLDEST). - Graceful shutdown — stop accepting new submissions, let everything already queued finish, then stop the workers. In Java this needs an explicit "poison pill" sentinel per worker (a blocking queue has no native "close"); in Go,
close(channel)plusrangegives you this for free — the language's channel semantics are literally built for this pattern. - Future-returning submit — wrap a
Callable<T>/func() (T, error)in aFutureTask/customFuture[T]that the caller can later.get()/.Get(), decoupling "submit the work" from "wait for the result."
Build it — milestones
- M1 — N workers + a blocking task queue +
submit(). Fixed worker threads, each looping onqueue.take();submit()enqueues. Get correctness first, single concern: every submitted task eventually runs, exactly once. - M2 — Graceful shutdown (drain, then stop). Stop accepting new submissions; let every already-queued task run; then stop the workers cleanly (Java: one poison pill per worker, enqueued last, so it's only reached after real work drains. Go:
close()the channel —rangehandles the rest). - M3 — Bounded queue + rejection policy. Swap the queue for a fixed capacity; when full, apply ABORT / CALLER_RUNS / DROP_OLDEST. This is the milestone that actually earns the "production-shaped" label.
- M4 —
Future-returning submit. Addsubmit(Callable<T>) -> Future<T>(Java) /SubmitFuture[T](fn func() (T, error)) -> *Future[T](Go) so callers can get a result back instead of fire-and-forget.
Reference implementation — Java (hand-rolled, not Executors.newFixedThreadPool)
Deliberately NOT the one-liner factory — the whole point is to own the queue bound, the rejection policy, and the shutdown sequencing that the factory method hides from you.
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Callable;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;
public final class ThreadPool {
public enum RejectionPolicy { ABORT, CALLER_RUNS, DROP_OLDEST }
// Sentinel: tells a worker "no more work, exit" without an interrupt
// racing an in-flight task.
private static final Runnable POISON_PILL = () -> {};
private final BlockingQueue<Runnable> queue;
private final Thread[] workers;
private final RejectionPolicy policy;
private final int capacity;
private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
public ThreadPool(int nWorkers, int queueCapacity, RejectionPolicy policy) {
this.capacity = queueCapacity;
// LinkedBlockingQueue with a finite capacity is node-based (no upfront
// array allocation) yet still enforces the bound on offer().
this.queue = new LinkedBlockingQueue<>(queueCapacity);
this.policy = policy;
this.workers = new Thread[nWorkers];
for (int i = 0; i < nWorkers; i++) {
workers[i] = new Thread(this::workerLoop, "pool-worker-" + i);
workers[i].start();
}
}
private void workerLoop() {
while (true) {
Runnable task;
try {
task = queue.take(); // blocks until a task or poison pill arrives
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
if (task == POISON_PILL) return; // graceful stop: drained everything ahead of it
try {
task.run();
} catch (RuntimeException e) {
// One bad task must not kill the worker (and take a slot out
// of the pool forever).
System.err.println("[" + Thread.currentThread().getName() + "] task threw: " + e);
}
}
}
/** Fire-and-forget submit. Throws under ABORT if the queue is full. */
public void submit(Runnable task) {
if (shuttingDown.get()) throw new RejectedExecutionException("pool is shutting down");
if (queue.offer(task)) return; // non-blocking; succeeds only if there's room
switch (policy) {
case CALLER_RUNS:
task.run(); // backpressure: caller pays the cost directly
return;
case DROP_OLDEST:
queue.poll(); // evict the oldest queued (never mid-flight) task
if (!queue.offer(task)) throw new RejectedExecutionException("full even after drop-oldest");
return;
case ABORT:
default:
throw new RejectedExecutionException("queue full (capacity=" + capacity + ")");
}
}
/** Future-returning submit: task -> result, without blocking the caller. */
public <T> Future<T> submit(Callable<T> task) {
FutureTask<T> future = new FutureTask<>(task);
submit((Runnable) future);
return future;
}
/** Graceful shutdown: stop accepting work, drain what's queued, then stop workers. */
public void shutdown() throws InterruptedException {
shuttingDown.set(true);
// One poison pill per worker, enqueued AFTER every already-submitted
// task, so every worker finishes real work before it exits.
for (int i = 0; i < workers.length; i++) queue.put(POISON_PILL);
for (Thread w : workers) w.join();
}
/** Hard shutdown: drop whatever is queued, interrupt workers immediately. */
public void shutdownNow() {
shuttingDown.set(true);
queue.clear();
for (Thread w : workers) w.interrupt();
}
/** Current queue depth -- used by the break-it flood below to show the backlog. */
public int queueSize() {
return queue.size();
}
}
Reference implementation — Go (worker goroutines + buffered channel + WaitGroup)
// Package pool: N goroutines ranging over a bounded buffered channel.
package pool
import (
"errors"
"sync"
"sync/atomic"
)
type RejectionPolicy int
const ( Abort RejectionPolicy = iota; CallerRuns; DropOldest )
var ErrQueueFull = errors.New("pool: queue full, task rejected")
type Pool struct {
tasks chan func()
wg sync.WaitGroup // tracks the N workers, for shutdown to wait on
shuttingDown atomic.Bool
policy RejectionPolicy
dropMu sync.Mutex // serializes the evict+retry pair for DropOldest
}
func New(nWorkers, queueCapacity int, policy RejectionPolicy) *Pool {
p := &Pool{tasks: make(chan func(), queueCapacity), policy: policy} // BOUNDED channel
p.wg.Add(nWorkers)
for i := 0; i < nWorkers; i++ {
go p.workerLoop()
}
return p
}
func (p *Pool) workerLoop() {
defer p.wg.Done()
// range exits automatically once the channel is CLOSED and DRAINED —
// Go's channel semantics give us graceful shutdown for free here.
for task := range p.tasks {
task()
}
}
func (p *Pool) Submit(task func()) error {
if p.shuttingDown.Load() {
return errors.New("pool: shutting down")
}
select {
case p.tasks <- task: // non-blocking send: succeeds only if there's room
return nil
default:
switch p.policy {
case CallerRuns:
task() // backpressure: run on the caller's own goroutine
return nil
case DropOldest:
p.dropMu.Lock()
defer p.dropMu.Unlock()
select { case <-p.tasks: default: } // evict the oldest queued task
select {
case p.tasks <- task:
return nil
default:
return ErrQueueFull
}
default: // Abort
return ErrQueueFull
}
}
}
// Shutdown stops accepting work, drains the channel, waits for all workers.
func (p *Pool) Shutdown() {
p.shuttingDown.Store(true)
close(p.tasks) // no more sends allowed; queued tasks are still delivered to range
p.wg.Wait()
}
// Future[T] mirrors Java's Future<T> — a handle to a result that isn't ready yet.
type Future[T any] struct { done chan struct{}; val T; err error }
func (f *Future[T]) Get() (T, error) { <-f.done; return f.val, f.err }
func SubmitFuture[T any](p *Pool, fn func() (T, error)) (*Future[T], error) {
fut := &Future[T]{done: make(chan struct{})}
err := p.Submit(func() { fut.val, fut.err = fn(); close(fut.done) })
if err != nil { return nil, err }
return fut, nil
}
Break it
The naive queue swap: build the exact same pool but pass an effectively unbounded capacity — which is precisely what Executors.newFixedThreadPool() does under the hood (its internal LinkedBlockingQueue has no bound). Then hammer it with a fast producer against a slow worker and watch what "unbounded absorbs everything" actually looks like:
// Java — this actually runs and actually proves the point (no capacity limit).
final class ThreadPoolBreakIt {
static void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) {
ThreadPool pool = new ThreadPool(1, Integer.MAX_VALUE, ThreadPool.RejectionPolicy.ABORT);
long start = System.nanoTime();
for (int i = 0; i < 50_000; i++) {
pool.submit((Runnable) () -> sleep(1)); // worker: ~1ms/task -> ~50s to drain 50k tasks
}
long submitMillis = (System.nanoTime() - start) / 1_000_000;
int depth = pool.queueSize();
// Measured: submitMillis ≈ 15ms, depth ≈ 49,990.
// The producer finished submitting FIFTY THOUSAND tasks in fifteen
// milliseconds, with ZERO rejections and ZERO slowdown — while the queue
// is quietly holding ~49,990 unprocessed tasks in memory. Nothing told the
// caller anything was wrong. That silent pile is the OOM/latency blowup
// in miniature: multiply the task payload size and the flood duration by
// realistic production numbers and this is exactly how a service goes
// down at 3am with a stack trace nowhere near the actual cause.
}
}
The Go version is the same shape — a channel with capacity 1_000_000 instead of a small bound, flooded with 50,000 sends against one slow worker: submission finishes in single-digit milliseconds, len(tasks) reports ~49,990 buffered. Now swap that capacity for a small bounded one with ABORT (the M3 design) and rerun the same flood: submissions start failing with ErrQueueFull/RejectedExecutionException almost immediately — loudly, at the moment of overload, instead of silently, minutes later. That's the whole lesson: a bounded queue doesn't reduce capacity, it converts a hidden failure into a visible one you can act on (shed load, scale out, alert) before it becomes an outage.
Tests to write (this is the real learning)
- All tasks run: submit 200 cheap tasks to a 4-worker pool with a generous bounded queue; after
shutdown(), assert the completed-count is exactly 200. Proves the core loop. - Graceful shutdown drains: submit 30 tasks that each sleep 5ms to a 2-worker pool, call
shutdown()immediately, assert all 30 completed (not just however many finished before shutdown returned) — proves the poison-pill/close-and-range sequencing actually waits for drain, not just for the call to return. - Bounded queue rejects under ABORT: 1 worker, queue capacity 2, fire 10 slow tasks — assert at least one
RejectedExecutionException/ErrQueueFullfires. Remove the bound (swap inInteger.MAX_VALUE/a huge channel capacity) and watch this test fail — zero rejections is exactly the silent-overload bug, not a passing result. - CALLER_RUNS backpressure: 1 worker, queue capacity 1, submit 5 tasks — assert all complete AND at least one ran on the submitting thread/goroutine itself (checked via thread identity in Java, via a parsed goroutine ID in Go's test-only trick). Proves the caller genuinely slows down instead of the work vanishing.
- Future resolves:
submit(() -> 6 * 7)then.get()/.Get()— assert the result is 42. Proves the decoupled submit-then-wait shape. - The break-it test (the real lesson): the unbounded-queue flood above — assert submission finishes in under 2 seconds AND queue depth exceeds half the flood size immediately after. Both assertions must hold simultaneously: fast-and-silent-and-full is precisely the failure signature; a correct bounded design would fail the "silent" half of that by rejecting loudly instead.
All 8 Java assertions and all 6 Go tests (including go test -race) pass against the reference implementations above — compiled and run in this session with javac -Xlint:all -Werror and go vet/go build/go test -race, all clean.
Optimise — trade-offs
| Decision | Option A | Option B | When A wins | When B wins |
|---|---|---|---|---|
| Pool shape | Fixed (N constant workers) | Cached (grows/shrinks, e.g. Executors.newCachedThreadPool) | Steady, predictable, CPU-bound load — you want a hard cap on concurrency and resource use | Short, bursty, IO-bound tasks where idle threads are cheap to reap — but it reintroduces unbounded-thread risk under sustained load, so it needs its own cap in practice |
| Pool shape | Fixed / cached | Work-stealing (Java ForkJoinPool, Go's runtime scheduler for goroutines) | Independent, similarly-sized tasks from one queue (typical request handling) | Recursive divide-and-conquer work (merge sort, tree traversal, parallel reduce) — each worker has its own deque and steals from others' tails when idle, so a few big subtasks don't starve idle workers the way one shared FIFO queue would |
| Rejection policy | ABORT | CALLER_RUNS | Caller can retry/degrade gracefully, or overload must be visible immediately (fail fast, alert, shed load) | You want automatic backpressure without an exception path — the producer itself slows down because it's now doing the work, which naturally throttles submission rate to match consumption rate |
| Rejection policy | ABORT / CALLER_RUNS | DROP_OLDEST | Every task matters (e.g. a payment, a write) — losing one silently is unacceptable | Only the freshest data matters (e.g. the latest sensor reading, a live price tick) — an old queued item is worthless once a newer one exists |
| Sizing | CPU-bound: ≈ #cores | IO-bound: cores × (1 + wait/compute) — Little's Law | Tasks are compute-heavy with little blocking — more threads than cores just adds context-switch overhead with no throughput gain | Tasks spend most of their time blocked on IO (DB calls, network) — more threads keep the CPU fed while others wait; size using the wait:compute ratio, not a guess |
| Language idiom | Java: explicit fixed pool | Go: goroutines + a buffered channel as a semaphore | Threads are OS-expensive (~1MB stack) — Java needs an explicit cap or it exhausts memory/OS threads | Goroutines are cheap (~2KB starting stack, multiplexed onto few OS threads by the Go runtime) — Go idiomatically launches one goroutine per task and uses a buffered channel of size N purely as a concurrency limiter, not as a thread cap |
Defend under drilling
- "How do you size the pool?" — Depends on the workload's bottleneck. CPU-bound: threads ≈ number of cores; more just adds context-switch overhead for no gain, since the CPU is already saturated. IO-bound: threads ≈ cores × (1 + wait-time/compute-time) — derived from Little's Law (concurrency = throughput × latency): if a task spends 90% of its time blocked on a DB call, you need roughly 10× the cores in threads to keep the CPU busy while most threads wait. Measure the actual wait:compute ratio under real load rather than guessing.
- "Why is an unbounded queue dangerous?" — It removes the only signal that the system is overloaded. A bounded queue + rejection fails immediately and loudly the moment demand exceeds capacity, which lets the caller retry, shed load, or alert. An unbounded queue accepts everything silently and defers the failure to whenever memory runs out — by then the backlog is enormous, latency for anything already queued is minutes, and the failure is much harder to diagnose because the root cause (a demand spike) happened long before the OOM. This kata's break-it test measured exactly that: 50,000 tasks accepted in ~15ms with zero rejections, ~49,990 sitting unprocessed in memory.
- "Caller-runs vs abort rejection — which do you pick?" — Abort when the caller has somewhere better to send the work (retry a different node, return a 503 immediately, shed the request). Caller-runs when you want automatic, self-correcting backpressure with no separate retry logic: the producer's own thread does the work, which structurally caps its submission rate to the pool's real drain rate — no thread can submit task N+1 until it finishes running task N itself. The cost: it can add latency variance to the producer's own thread, and if the producer is your single request-handling thread (e.g. in an event loop), caller-runs would block it — wrong choice there; abort or drop-oldest instead.
- "Why work-stealing for recursive tasks?" — A single shared FIFO queue is a poor fit for divide-and-conquer: a worker that pulls one large top-level task and recursively splits it into many small subtasks has nowhere fast to hand those subtasks off — pushing every subtask through one contended shared queue serializes what should be parallel. Work-stealing gives each worker its own deque; a worker pushes/pops its own subtasks from one end (no contention, it's the only owner) and idle workers steal from the far end of a busy worker's deque only when they run out of their own work. This keeps the common case lock-free and only pays a synchronization cost on the rare steal.
- "What breaks at 100×?" — At 100× the load, a fixed pool sized for the old peak now queues almost everything; if the queue is bounded (correctly), you see a spike in rejections/CALLER_RUNS latency — a visible, actionable signal to scale out or add more pools per resource (e.g. a separate pool per downstream dependency, so one slow dependency's backlog doesn't starve unrelated work — the same bulkhead idea from Circuit Breaker). If it's a single global pool serving unrelated task types, 100× load on one type starves the others entirely — the escalation from "one bounded pool" to "one bounded pool per bulkhead, plus a circuit breaker on any pool whose queue stays saturated" is the natural next system-design step.
You can now defend
- You can build a fixed thread pool from scratch — worker loop, bounded queue, and an explicit rejection policy — and explain why the bound is the load-bearing decision, not a defensive extra.
- You've run a real test that shows an unbounded queue accepting a flood silently while a bounded one rejects loudly, and you can connect that directly to "how does a service die quietly at 3am."
- You can size a pool from first principles (Little's Law) instead of picking a number, and you can name when fixed, cached, or work-stealing is the right shape.
- You can defend caller-runs vs abort vs drop-oldest as different promises to the rest of the system, and escalate the design to bulkheads/circuit-breakers when one pool's overload shouldn't sink unrelated work.
Re-authored/Deepened for this guide. Model answer / theory: Thread Pools, Executors & Futures. Reference implementations (Java + Go) compiled and tested in-session: javac -Xlint:all -Werror clean, 8/8 assertions pass; go vet + go build + go test -race clean, 6/6 tests pass.
🤖 Don't fully get this? Learn it with Claude
Stuck on Build a Thread Pool? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Build a Thread Pool** (Hands-On Builds) and want to truly understand it. Explain Build a Thread Pool from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Build a Thread Pool** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Build a Thread Pool** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Build a Thread Pool** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.