The problem: a thread per task doesn't scale
The most naive way to run work concurrently is: whenever there's a task, spawn a new thread for it. This works fine for a handful of tasks and falls over hard the moment load grows, for three compounding reasons:
- Creation and teardown cost. Spinning up an OS thread isn't free — it's a real syscall that allocates kernel data structures, and tearing one down isn't free either. If tasks are short-lived (a few milliseconds of work), the overhead of creating and destroying the thread can dwarf the work itself.
- Memory per thread. Every thread gets its own stack — commonly on the order of 512 KB–1 MB by default on the JVM and in native pthreads. Ten thousand threads at 1 MB each is 10 GB of address space committed to stacks alone, before any of them do useful work.
- Context-switch overhead. Once the number of runnable threads meaningfully exceeds the number of CPU cores, the OS scheduler spends an increasing share of time switching between threads instead of running them. Throughput doesn't just plateau past this point — it can actively fall as contention and cache-thrashing increase.
A server that spawns one thread per incoming request degrades exactly when you need it most: under a traffic spike, thread count balloons, memory and scheduler overhead spike with it, and the system can collapse under its own bookkeeping rather than the actual work.
The pool pattern
The fix is to decouple "a task exists" from "a thread exists to run it." A thread pool keeps a fixed (or bounded) set of long-lived worker threads that repeatedly pull tasks from a shared queue:
submit(task):
enqueue task onto the shared queue
worker loop (run by each of the N pool threads):
while pool is running:
task = dequeue from shared queue # blocks if queue is empty
run task
Threads are created once, amortizing their creation cost across every task they ever run, and the number of threads is a controlled, deliberate resource — not an emergent side effect of load. This is the same idea behind connection pools, object pools, and buffer pools: expensive-to-create, reusable resources are checked out and returned rather than created and destroyed per use.
Virtually every mainstream runtime ships some form of this: Java's ExecutorService/ThreadPoolExecutor, Python's concurrent.futures.ThreadPoolExecutor, .NET's ThreadPool, and Go's runtime scheduler all apply the same core pattern, even though the knobs differ.
Sizing for CPU-bound work
If tasks are pure computation — no blocking on I/O, no waiting on locks — then once you have as many runnable threads as CPU cores, adding more threads can't add throughput; every extra thread just competes for the same fixed compute capacity and adds context-switch overhead. The standard starting point is:
threads ≈ N_cpu (sometimes N_cpu + 1, to keep a core busy
during an occasional page fault or GC pause)
This is a narrow, easy case precisely because there's no waiting to account for — the pool exists purely to avoid oversubscribing the CPU.
Sizing for I/O-bound work
I/O-bound tasks spend most of their time blocked — waiting on a network response, a disk read, a database round-trip — during which the thread holds a stack and an OS thread slot but does no CPU work at all. Here, running only N_cpu threads chronically under-uses the CPU, because most of those threads are asleep waiting on I/O at any given moment. The classic formula (from Brian Goetz's Java Concurrency in Practice, and re-derivable from Little's Law) is:
threads = N_cpu * U_target * (1 + W / C)
N_cpu — available cores.
U_target — the fraction of CPU capacity you want to target (often just 1 for "fully utilize the CPU").
W / C — the ratio of a task's average wait time (blocked on I/O) to its average compute time (actual CPU work). A task that waits 45 ms and computes for 5 ms has a ratio of 9, meaning it needs roughly 10x as many threads as a purely CPU-bound task to keep the CPU saturated.
Read the assumptions, not just the formula. This model assumes:
- The thread pool itself is the bottleneck resource you're trying to size — not some downstream system. If a database connection pool caps you at 20 concurrent queries, sizing your thread pool to 200 doesn't help; you've just moved the queue from your code to the database's.
- Tasks are reasonably homogeneous in their wait/compute ratio. A pool mixing a 5 ms cache lookup with a 30-second batch job doesn't have one meaningful ratio to plug in.
W and C are measured under realistic load, not guessed. This is a formula you calibrate with profiling and tune with load testing — it's a starting point for an experiment, not a certificate of correctness.
- More threads keep helping only up to the point some other resource (memory, downstream service, lock contention) becomes the new bottleneck; the formula doesn't know where that point is for your system.
Core size, max size, and keep-alive
Most production pool implementations expose a two-tier growth model rather than one fixed size:
- Core size — the number of threads kept alive persistently, even when idle (this is your steady-state capacity).
- Max size — a ceiling the pool may grow to under burst load, beyond the core count.
- Keep-alive time — how long a thread beyond the core count is allowed to sit idle before it's torn down, shrinking the pool back toward its core size once the burst subsides.
The subtlety worth internalizing: in the most common implementations, growth from core size toward max size is typically triggered only after the task queue itself is full — not as soon as a task arrives and all core threads are busy. That ordering (queue first, then grow toward max) is precisely why the next subtopic on queues and rejection policies matters so much: an unbounded queue means the pool can never actually reach max size, silently making that setting meaningless.
Pitfalls and interview gotchas
- Trusting convenience factory methods blindly. Pool-creation helpers that "just work" (fixed pool, cached pool, etc.) often hide an unbounded queue or an unbounded max size. Know what queue and bounds you're actually getting, not just the thread count you asked for.
- Sizing in a vacuum. A bigger thread pool cannot outrun a downstream bottleneck (a database, a rate-limited API, a single-threaded resource behind a lock). Increasing pool size in that situation just grows queued/in-flight work without increasing real throughput.
- Treating the I/O-bound formula as exact. It's a first approximation built on an average wait/compute ratio; real workloads have variance, tail latency, and bursts the formula doesn't model. Use it to pick a starting point, then measure.
- Ignoring memory cost at scale. Even "just" a few hundred idle core threads carry real, fixed memory cost (stacks) — this compounds badly if you create many separate pools across a large application instead of sharing a smaller number of well-sized ones.
- Conflating "more threads" with "more parallelism." Once cores are saturated for CPU-bound work, additional threads only add scheduling overhead — they cannot make CPU-bound code run in less wall-clock time.