Engineering Atlas › Low-Level Design

Reusing workers with a thread pool

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The problem

An image service resizes uploads. The first version starts a new thread per request. At 50 requests per second this works. During a marketing push traffic hits 2,000 per second, the process creates 2,000 threads, each with its own stack, the CPU spends its time switching between them, latency climbs for everyone, and finally thread creation fails with "unable to create native thread". The server did not run out of CPU; it ran out of a way to say "not now".

A thread pool fixes both halves. Threads are created once and reused, so the per-task cost of creating one (tens of microseconds plus stack memory) disappears. And the pool size is a hard limit on concurrency: with 16 workers, at most 16 resizes run at once, and everything else waits in a queue or is turned away.

The parts

Every pool has the same four pieces:

A worker's loop is short:

def worker_loop(queue):
    while True:
        task = queue.take()          # blocks while empty
        if task is SHUTDOWN:
            return
        try:
            task.run()
        except Exception as e:
            task.fail(e)             # never let one task kill the worker

The queue is the producer-consumer pattern (see Producer Consumer Pattern); the pool is that pattern plus a lifecycle and a policy for overload.

Java's admission rules, the canonical example

ThreadPoolExecutor takes a core size, a maximum size, a queue and a keep-alive time. On execute(task) it does exactly this:

  1. If fewer than corePoolSize threads exist, start a new thread with this task, even if other threads are idle.
  2. Otherwise, try to put the task in the queue. If it fits, done; an idle worker will take it.
  3. If the queue is full and fewer than maximumPoolSize threads exist, start a new thread with this task.
  4. Otherwise, call the RejectedExecutionHandler.

Two consequences matter. First, with an unbounded queue (which Executors.newFixedThreadPool uses), step 2 never fails, so the pool never grows beyond core and never rejects; overload turns into an ever-longer queue and rising memory. Second, with a bounded queue, a burst first fills the queue and only then adds threads, so the tasks that arrive last may run before tasks queued earlier. Threads above the core count that stay idle for keepAliveTime are retired.

The four built-in rejection handlers are the menu of overload policies: AbortPolicy throws, CallerRunsPolicy runs the task on the submitting thread (which slows the submitter down, a crude but effective form of backpressure), DiscardPolicy silently drops it, and DiscardOldestPolicy drops the oldest queued task and retries. Silently dropping is almost never what you want.

Sizing

For CPU-bound work, more threads than cores only adds context switches; use about the number of cores. For work that blocks, a thread that spends 90 ms waiting on a database for every 10 ms of CPU leaves the core free most of the time, so the rough rule is cores × (1 + wait / compute): 8 × (1 + 9) = 80 threads. Little's law gives the same answer from the outside: to sustain 800 requests per second at 100 ms each, you need 80 in flight. Then check the downstream: 80 threads that each hold a database connection need a connection pool of at least 80, and the database must be able to take it. The pool is usually the cheapest place to enforce the limit of the slowest dependency.

Separate pools for separate work

One pool for everything means a slow dependency can take every worker, and then fast, unrelated requests wait behind it. Giving each dependency or workload its own pool is the bulkhead pattern (see Bulkhead Pattern). Node.js does a version of this internally: libuv runs file system calls, dns.lookup, some crypto and zlib on a thread pool of 4 by default, separate from the event loop, and a burst of slow DNS lookups can delay unrelated file reads because they share those 4 threads.

Work stealing

A single shared queue becomes a point of contention when tasks are tiny. Work-stealing pools give each worker its own double-ended queue: a worker pushes and pops its own tasks at one end, and an idle worker steals from the other end of someone else's. Java's ForkJoinPool, Go's runtime scheduler and Tokio's multi-threaded runtime all work this way. It suits recursive, fine-grained tasks; for coarse request handling a shared queue is simpler and fair.

Reviewing agent-written code

Agents tend to create a new executor inside a function that is called per request (with ThreadPoolExecutor() as ex: in a handler), which recreates threads on every call and defeats the pool. They also call future.result() from inside a task running on the same pool, which deadlocks once all workers are doing it. Ask for one pool per workload, created at startup and injected, with a bounded queue, an explicit rejection policy, named threads, and every future's exception either awaited or logged.

The same shape elsewhere

A thread pool is a limited set of servers in front of a queue, the overload shape. A database connection pool, a Kubernetes cluster with a fixed number of nodes, a call centre with 20 agents and a hold queue, and an AWS Lambda concurrency limit all have the same three knobs (how many servers, how long a queue, what to do when both are full) and the same failure: an unbounded queue turns a brief overload into minutes of latency.

Down the stack

Layer 1The contract

A pool promises: every accepted task runs exactly once on some worker; no more than max tasks run concurrently; a rejected task is reported to the submitter (by exception, return value, or running it on the caller), never lost without a signal unless the policy says so; a task's exception is delivered to its future and does not kill the worker; after shutdown, no new tasks are accepted and queued ones either run or are returned. It does not promise ordering between tasks, and under Java's rules not even FIFO order of submission.

Layer 2The mechanism

ThreadPoolExecutor packs the run state and the worker count into one AtomicInteger called ctl (3 bits of state, 29 bits of count) so that "check count and add a worker" is one compare-and-set. Each Worker is a small lock around a thread; getTask() calls queue.poll(keepAliveTime) when the pool has more than core threads and queue.take() otherwise, which is how idle extra threads time out. Python's ThreadPoolExecutor uses an unbounded SimpleQueue and starts threads lazily up to max_workers, which defaults to min(32, CPU count + 4) in recent versions. Go has no pool in the standard library because goroutines are cheap; the idiom is a fixed number of goroutines ranging over a channel, or a semaphore (a buffered channel or errgroup.SetLimit) to cap concurrency. Go's scheduler itself is a work-stealing pool: each P has a local run queue of 256 goroutines plus a global queue, and an idle P steals half of another P's queue.

Layer 3The machine

On Linux, pthread_create is a clone() call with flags such as CLONE_VM | CLONE_FILES | CLONE_THREAD, plus an mmap for the stack: 8 MB of virtual address space by default for C threads, 1 MB for Java threads unless -Xss says otherwise. Physical memory is touched lazily, but each thread still costs kernel structures and tens of microseconds to create and join. A context switch between threads costs roughly 1 to 5 us directly, and more indirectly when the incoming thread finds the caches cold. When a worker blocks on an empty queue, the JVM parks it with a futex wait; a submit wakes one sleeper with futex wake, a syscall of about a microsecond. Hot pools spin briefly before parking to avoid that cost. Java 21 virtual threads change the arithmetic: they are scheduled by the JVM onto a small ForkJoinPool of carrier threads, start with a stack of a few hundred bytes, and make pooling threads unnecessary for blocking I/O, though you still need a semaphore to cap concurrency to a downstream.

Why it's built this way

  1. Creating and destroying OS threads costs syscalls, memory and microseconds, so threads are created once and reused.
  2. Each running thread competes for cores and caches, and each blocked one holds memory and often a connection, so concurrency must be capped.
  3. Work arrives in bursts, so a queue absorbs short spikes instead of rejecting them.
  4. A queue without a bound converts long overload into unbounded latency and memory, so the queue is bounded and the pool needs an explicit policy for the overflow.
  5. One shared queue becomes contended with tiny tasks, so high-throughput runtimes split it per worker and steal.

Diagram

flowchart TD
  S["submit(task)"] --> A{"workers < core?"}
  A -- yes --> N1["start worker with task"]
  A -- no --> I{"idle worker?"}
  I -- yes --> R["idle worker runs task"]
  I -- no --> Q{"queue has room?"}
  Q -- yes --> E["enqueue"]
  Q -- no --> M{"workers < max?"}
  M -- yes --> N2["start extra worker with task"]
  M -- no --> P["rejection policy: abort, caller runs, drop oldest"]
  E --> W["worker finishes, takes next from queue"]

Worked example

A report service renders PDFs. Each render takes 40 ms of CPU and 160 ms waiting on two internal APIs. The hosts have 8 cores. Sizing: 8 × (1 + 160/40) = 40 threads. At 200 ms per task, 40 threads complete 200 renders per second.

The team first deployed Executors.newFixedThreadPool(40). During month-end, traffic rose to 260 per second for 15 minutes. The pool could not grow or reject, so the unbounded queue grew by 60 tasks per second, reaching 54,000 tasks. Queue wait passed 4 minutes, clients timed out after 30 seconds and retried, and the service kept rendering PDFs nobody was waiting for. Heap filled with queued request bodies and the JVM spent most of its time in GC.

The fix was a ThreadPoolExecutor(40, 40, ..., new ArrayBlockingQueue<>(400), new AbortPolicy()). A queue of 400 at 200 per second is two seconds of buffering, well inside the client timeout. Excess requests got an immediate 503 with Retry-After, the load balancer moved them to other hosts, and month-end latency stayed under 2.5 seconds. They also dropped tasks whose client deadline had already passed when a worker picked them up, which removed a quarter of the wasted work during the next spike.

Trade-offs

OptionStrengthsWeaknessesChoose when
Thread per taskSimple; no queueing delayUnbounded threads; collapses under loadLow, predictable task rates only
Fixed pool, unbounded queueNever rejects; easyOverload becomes memory and latency growthBatch jobs where waiting is fine
Fixed pool, bounded queue, abortPredictable latency; fast failureCallers must handle rejectionRequest serving behind a load balancer
Bounded queue, caller runsNatural backpressure on producersSlows the submitting thread, which may be an I/O loopInternal pipelines where the producer can slow down
Work-stealing poolLow contention for tiny tasksNo global ordering; harder to reason aboutRecursive or fine-grained parallel computation
Virtual threads plus semaphoreCheap blocking; no pool sizing for I/OStill needs explicit caps for downstreamsBlocking I/O workloads on Java 21 or later

Pitfalls in production

Leadership lens

Check yourself

  1. A ThreadPoolExecutor has core 10, max 50 and an unbounded LinkedBlockingQueue. How many threads will it ever run, and why?

    Show answer

    10. Extra threads are only started when the queue rejects an offer, and an unbounded queue never does, so the max size is never reached.

  2. Why is cores × (1 + wait/compute) a starting point and not the answer?

    Show answer

    It ignores downstream limits such as connection pools and the dependency's own capacity, and the wait and compute times change with load; measure queue wait and saturation and adjust.

  3. How does CallerRunsPolicy create backpressure, and when is it dangerous?

    Show answer

    The submitting thread runs the task itself, so it cannot submit more until that finishes. It is dangerous when the submitter is an event loop or acceptor thread, because the whole server stalls while it runs one task.

  4. A pool of 8 threads hangs with all workers in future.get(). What happened?

    Show answer

    Each task submitted a subtask to the same pool and blocked on it; with all 8 workers blocked, no worker is free to run the subtasks, so it is a starvation deadlock.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →