Thread & Worker Pools

Thread Pools

A pool is not a performance trick, it is a *bound*. Tasks go into a queue, a fixed set of workers takes them out, and the number of things running at once stops being a function of how many requests arrived. Everything interesting about pools is about what queues behind them.

▶ Run the lab

The question this answers

The question

Why hand work to a fixed set of workers instead of starting a thread for every task?

The work

Forty thousand image-resize jobs arriving over an hour in uneven bursts, each needing roughly 80 ms of CPU and a 12 MB scratch buffer.

What is shared

The submission queue itself — every submitting thread writes to it and every worker reads from it — plus the per-worker scratch buffers and whatever the tasks themselves touch. The queue is the shared state most people forget is shared.

The invariant — what must stay true under every interleaving

Every submitted task is executed exactly once, by exactly one worker, and the number of tasks executing simultaneously never exceeds the pool size.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

The shape: submit, queue, fixed workers

A thread pool has three parts and the middle one is the point. Callers submit tasks; the tasks land in a queue; a fixed number of long-lived workers loop forever taking the head of the queue and running it. The threads themselves are created once, at pool construction, and outlive every individual task. Operating Systems covers what that thread *is* and how the kernel puts it on a core — see Threads: Several Instruction Streams in One Process and The Thread Pool Server. This lesson is about what the shape buys you.

What it buys is a ceiling. Without a pool, concurrency is whatever the arrival rate happens to be: 4,000 uploads in one minute means 4,000 threads, 4,000 stacks, 4,000 scratch buffers and a scheduler thrashing between them. With a pool of eight, 4,000 uploads means eight running and 3,992 waiting. The system got *slower per request* and *survivable in aggregate*, and that trade is the entire reason pools exist.

The reuse is the secondary benefit and the one usually quoted first. Thread creation is not free — a stack allocation, a kernel object, a scheduler entry — and paying it 40,000 times to do 80 ms of work each is measurable waste. But reuse would not justify a pool on its own. The bound would.

  • The pool size is a concurrency limit that happens to be spelled as a thread count.
  • The queue is the shock absorber: it converts a burst in arrival rate into a rise in latency instead of a rise in resource use.
  • A queue with no depth limit converts the burst into unbounded memory growth instead — see Bounded vs Unbounded Queues.
  • The rejection policy is a required design decision, not an edge case: drop, block the caller, run on the caller's thread, or fail fast.
Submit → queue → fixed workers → results
submit()submit()queue fulltake()take()take()Caller ACaller BCaller CTask queue (bounded, depth 512)Rejection policyWorker 1Worker 2Worker 3Completed / futures resolved
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

The queue is shared state, and a naive one loses tasks

The invariant — every task runs exactly once — lives entirely in the queue implementation. Engineers reach for a pool precisely to avoid thinking about synchronization, and then hand-roll the queue. The schedule below is what a non-atomic "read head, then advance head" looks like when two idle workers wake at the same moment.

Note what breaks and what does not. No memory is corrupted; both workers read a valid pointer. The failure is *logical*: a task runs twice and another is skipped entirely, and if the task is "charge this card" the second execution is a duplicate charge. That is a race condition, not a data race — see Reasoning About Races: A Method, Not an Instinct for the distinction and Data Race Is Not Race Condition for the other one.

Every production pool solves this with a queue whose take operation is a single indivisible step, usually a lock or a compare-and-swap loop. That is the whole reason you use the runtime's pool rather than writing one.

Two workers racing on a hand-rolled take(). Illustrative trace, not a captured execution.ILLUSTRATIVE
Invariant · Every queued task is taken by exactly one worker.
#Worker 1Worker 2State
1read head index (3)·head=3 queue[3]=resize#77 queue[4]=resize#78
2·read head index (3)head=3 w1 sees=3 w2 sees=3
3load queue[3] → resize#77·w1 task=resize#77
4·load queue[3] → resize#77w2 task=resize#77
✕ Two workers now hold the same task; resize#77 will execute twice.
5write head = 4·head=4
6·write head = 4head=4
7run resize#77·resize#77 runs=1
8·run resize#77resize#77 runs=2 resize#78=never taken
✕ resize#78 is skipped: head advanced past it without anyone holding it.
A lost update on the head index produces both a duplicated task and a silently dropped one. Nothing throws, no metric moves, and the only symptom is one customer charged twice and one thumbnail that never appears.

Pool versus thread-per-task versus unbounded spawn

The comparison worth internalising is not "pool is faster". It is what each option does to resource use as the arrival rate climbs, because that is the axis on which the unbounded version fails and it fails suddenly rather than gradually.

Thread-per-task is not always wrong. If tasks are rare, long and mostly blocked on I/O, a thread each is simple, debuggable and costs little. The moment the arrival rate is driven by something you do not control — user traffic, a retry storm, a queue drain — the absence of a bound becomes the outage. Unbounded Concurrency is the anti-pattern lesson; this is the constructive answer.

ApproachConcurrency at peakMemory at peakFailure modeBackpressure signal
Thread per task, unboundedEqual to arrival rateStacks × arrival rate — hundreds of MB to GBAllocation failure or scheduler collapse; the whole process diesNone until it is over
Fixed pool + unbounded queuePool size (bounded)Queued task objects grow without limitHeap exhaustion, hours later, far from the causeQueue depth, if you graph it
Fixed pool + bounded queue + rejectPool size (bounded)Bounded and predictableSubmissions rejected — visible, attributable, survivableRejection count, immediately
Fixed pool + bounded queue + caller blocksPool size (bounded)BoundedSubmitting threads stall; can deadlock if submitters are pool workersSubmit latency
What happens as arrival rate climbs from 10/s to 10,000/s.

Key points

  • A pool bounds simultaneous execution; thread reuse is a secondary benefit that would not justify the pattern on its own.
  • The queue converts a burst in arrival rate into a rise in latency rather than a rise in resource use — which is the trade you actually wanted.
  • The queue is shared mutable state; a non-atomic take() duplicates one task and drops another, with no error anywhere.
  • An unbounded queue behind a bounded pool has moved the unboundedness, not removed it.
  • The rejection policy is part of the design: drop, block, run-on-caller or fail fast each has a different failure mode.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • At construction, N worker threads are created and each enters a loop: take a task from the queue, run it, repeat.
  • A caller submits a task; the submission appends to a shared queue under a lock or a lock-free protocol and typically returns a future.
  • If the queue is at its depth limit, the rejection policy runs on the submitting thread — before the task is ever queued.
  • A worker blocks on an empty queue rather than spinning, so an idle pool costs no CPU; the wakeup is a condition-variable signal (Condition Variables: Waiting Until a Predicate Is True).
  • The task runs to completion on the worker thread; its result is published to the future, which is where a happens-before edge to the waiting caller is established (Safe Publication: Handing Over a Finished Object).
  • On shutdown the pool stops accepting submissions, drains or discards the queue per policy, and joins the workers (Draining a Pipeline).
Interleavings that matter
  • Two idle workers wake on the same signal, both read head = 3, both take task 3, both advance head to 4 — task 3 runs twice and task 4 is never taken.
  • A caller submits while a worker is mid-take: with a correct queue the two operations serialize and the task is either seen or queued behind, never half-visible.
  • A worker throws an uncaught exception; if the loop does not catch it the worker thread dies, the pool silently shrinks from 8 to 7, and throughput degrades with no error surfaced anywhere.
  • A task submitted from *inside* a pool worker waits on the result of another task in the same pool: with all workers doing this, no worker is left to run the awaited tasks — pool deadlock, no lock involved (Deadlock).
  • Shutdown is called while a worker is mid-task: the worker finishes the current task, sees the closed flag on its next take, and exits — the invariant that no task runs partially is preserved only if shutdown never interrupts a running task.
What it guarantees — and does not
  • Guaranteed: at most N tasks execute simultaneously, for the pool's definition of "execute" — a task blocked in a syscall still occupies a worker.
  • Guaranteed: each queued task is handed to exactly one worker (given a correct queue implementation).
  • NOT guaranteed: that tasks run in submission order. FIFO queueing orders the *hand-off*, not completion, and with N workers, task 5 routinely finishes before task 2.
  • NOT guaranteed: that a submitted task ever runs. A rejected submission never ran; a task queued at shutdown may be discarded.
  • NOT guaranteed: any isolation between tasks. Two tasks on the same worker share thread-local state, and thread-locals set by one task are visible to the next unless cleaned up.
  • NOT guaranteed: that the pool stays size N. An uncaught exception in a poorly written worker loop shrinks it permanently.
Where contention appears
  • Every submit and every take touches the queue's lock. With 64 submitting threads and a pool of 8, that single lock is the hottest object in the process — see What Contention Actually Costs and perf's lock-contention lesson.
  • Workers waking on the same condition variable produce a thundering herd on the queue lock: all N wake, one wins, N−1 sleep again (Thundering Herd).
  • The tasks themselves contend on whatever they share; the pool does nothing about that. A pool of 16 all hammering one counter behaves like one thread with extra steps.
  • Cache locality suffers when tasks migrate between workers and cores; the resize buffer warmed on core 3 is cold when the same task family lands on core 7.
How it fails
  • Duplicate execution and silent task loss from a non-atomic take — a lost update on the queue head index.
  • Pool deadlock: tasks in the pool waiting on results of tasks that need a free worker in the same pool. Classic, and invisible in CPU graphs because everything is blocked, not busy.
  • Silent pool shrinkage: an exception escaping the worker loop kills the thread and no metric records it.
  • Thread-local leakage between unrelated tasks sharing a worker — including leaked request identity, which is a security bug, not a performance one.
  • Unbounded queue growth behind a bounded pool: memory exhaustion, hours after the burst that caused it.
When it helps
  • When the arrival rate is driven by something you do not control and the resource cost per concurrent task is real (memory, connections, file handles).
  • When tasks are short relative to thread creation cost, so reuse actually matters — thousands of tasks of tens of milliseconds.
  • When you need one place to attach the concurrency limit, the metrics and the rejection policy for a whole class of work.
  • When downstream capacity is the real constraint and the pool size is how you express it (Bounding Concurrency).
When it hurts
  • When tasks block on each other. A pool whose tasks await other tasks in the same pool is a deadlock waiting for the right arrival pattern.
  • When tasks are long-running and heterogeneous: one 40-minute task occupies a worker that thousands of 10 ms tasks are queued behind — head-of-line blocking with no fairness.
  • When the work is CPU-light and I/O-heavy in a runtime that already multiplexes I/O — an async runtime handles 10,000 waiting sockets without 10,000 workers (Async Is Not Parallelism).
  • When there is exactly one task. A pool for a single job is ceremony.
How you would know
  • Queue depth over time, not its average. A depth that returns to zero between bursts is healthy; one with a rising floor is a pool that is undersized for the sustained rate.
  • Time-in-queue (queue age) per task at p50/p99 — the number users experience, which pool utilisation completely hides.
  • Active worker count versus pool size. Persistently equal means saturated (Pool Saturation).
  • Rejection count and the reason. Zero rejections with a rising queue means the bound is in the wrong place.
  • Worker thread count over the process lifetime — a monotonic decline is silent shrinkage from escaped exceptions.
Complexity it introduces
  • You now have two failure surfaces instead of one: the tasks, and the pool that runs them. Stack traces stop showing the submitter, so causality has to be carried explicitly.
  • Every task must be safe to run on an arbitrary thread that has run arbitrary other tasks — no thread affinity assumptions, no dirty thread-locals.
  • Shutdown becomes a designed protocol rather than a return statement: drain or discard, wait how long, what about in-flight work.
  • Debugging moves from "read the stack" to "read the thread dump plus the queue depth graph" (Reading a Thread Dump).
Simpler alternatives
  • Run it inline on the calling thread. If the work is short and the caller has nothing better to do, a pool adds hand-off latency and a scheduling hop for nothing.
  • An async runtime with a concurrency limiter, when the work is I/O-bound: thousands of in-flight operations over a handful of threads, without the per-task stack (Event Loops as a Concurrency Model).
  • A durable queue plus separate worker processes, when tasks must survive process restart or be retried — an in-memory pool loses everything on crash (Worker Pools Beyond Threads).
  • A semaphore around the resource that actually needs bounding, keeping the caller's own threads: sometimes you want the limit without the hand-off (Bounding Concurrency).

Thread pool: utilization and queue

Thread pool — utilization, queue depth, and the point where the numbers stop existing
A pool of workers serving a stream of requests. Sakasegawa's M/M/c approximation, with the honest answer above the knee.
utilization ρ75% · capacity 160/s
pool workers busy6 of 8
utilization
75.0%
mean queue depth
1.2
mean wait for a worker
9.8 ms
mean in flight (L = λW)
7.2
capacity  = workers / service = 8 / 50 ms = 160.0 req/s
ρ         = arrivals / capacity = 120 / 160.0 = 0.750
Little    L = λ × W  →  0.120/ms × 59.8 ms = 7.2 in flight
engine    status = healthy
ρ = 75.0%, mean wait 9.8 ms on top of 50 ms of service. Queueing is non-linear: the wait term carries 1/(1 − ρ), so the step from 80% to 90% utilization costs more than everything before it. Little's Law ties the three numbers together — L = λ × W, so 7.2 requests are inside the system at any moment. That is the number to size the pool against, and it is measurable in production; the pool size is not something to derive from a formula about core counts. Push arrivals past 160/s and watch the numbers refuse to answer.
SIMULATEDsmooth arrivals; real traffic is burstier and queues earlier

The producer is faster than the consumer

The producer is faster than the consumer
A permanent surplus has to go somewhere: into memory, into a blocked producer, or into the bin. The one option that does not exist is for it to go nowhere.
1/60 · t+1s
queue memory25 MB
queue depth400 · no ceiling declared
queue latency
400 ms
delivered
1,000
items lost
0
status
alive, 20s left
t+0sunbounded queue · producer 1,400/s · consumer 1,000/s
t+20squeue holds 8,000 items · 500 MB · GC pauses lengthening, latency climbing
t+21sOOM: 512 MB exhausted. Process killed. Everything still in the queue is gone, and the producer finally stops — because it died too.
400 items per second have nowhere to go, so they go into the heap: 25 MB at t+1s, and the OOM killer arrives at t+21s. Notice what this system does *not* have: it does not have "no backpressure". It has backpressure with a 512 MB buffer and a process death as its signalling mechanism. Every queue is bounded — an unbounded queue is one whose bound is the machine, whose signal is a crash, and whose overflow policy is "lose everything, including the items that were already safely queued". Whichever you pick, pick it on purpose and export the counter that proves which one fired.
SIMULATEDFixed rates over 60 model seconds, 64 KB per item, 512 MB before the process dies. Real heaps degrade before they die — GC pressure and swapping make the last few seconds far worse than this straight line suggests.

What people believe, and what is true

Claim

A thread pool makes my code faster.

Reality

It makes resource use bounded and predictable. Throughput improves only when thread creation cost or oversubscription was the bottleneck; otherwise it adds a hand-off.

Claim

The pool guarantees tasks finish in the order I submitted them.

Reality

A FIFO queue orders the hand-off to workers. With N workers running concurrently, completion order is arbitrary and depends on task duration.

Claim

The pool protects me from concurrency bugs.

Reality

It bounds how many tasks run at once. If two of them touch the same object, everything in Shared Mutable State still applies.

Go deeper

Overview

Tasks go in a queue; a fixed set of long-lived workers takes them out. The fixed part is the point: it caps how much work runs at once.

Practical

Size the queue, choose the rejection policy, catch everything inside the worker loop, and never let a pool task block on another task in the same pool.

Advanced

The queue is the contended object and the shutdown protocol is the subtle one. Separate pools for separate task classes stop one long task from starving a thousand short ones.

Internals

Take is a lock-guarded dequeue or a CAS loop over a ring buffer; the worker parks on a condition variable, so an idle pool costs nothing and a wakeup costs a syscall and a scheduler transition.

Apply it