Thread & Worker Pools

Pool Saturation

Every worker busy, the queue growing, wait time climbing. Saturation is not a CPU problem and often not even a pool problem — it is arrival rate exceeding completion rate, and Little's Law tells you exactly what the wait will be before you measure it.

▶ Run the lab

The question this answers

The question

All eight workers are busy and the queue is growing — is the pool too small, or is something else the real constraint?

The work

A pool of 8 workers serving checkout-confirmation tasks that each take 200 ms, against an arrival rate that rises from 20/s at lunchtime to 55/s during a flash sale.

What is shared

The queue, and — usually the actual culprit — the resource every worker reaches for while occupied: a 10-connection database pool, a payment gateway with a rate limit, or a single lock on a shared cache.

The invariant — what must stay true under every interleaving

Over any sustained window, completions must keep up with arrivals — queue depth must return to its baseline rather than establish a higher floor. When it does not, wait time grows without bound and every latency SLO fails in order.

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?

What saturation actually looks like

The pool has capacity 8 and each task takes 200 ms, so the ceiling is 8 / 0.2 = 40 tasks per second. At 20/s the pool is half busy and the queue is empty. At 38/s it is nearly always busy, the queue starts fluctuating, and latency is already noticeably worse — this is the part people find counter-intuitive, because nothing is broken yet and the graphs look fine. At 55/s arrivals exceed the ceiling by 15/s, and the queue grows by 15 tasks every second, forever, until something rejects.

Notice the shape: the failure is not gradual. Below the ceiling, wait time rises slowly and then steeply as utilisation approaches 1. Above the ceiling there is no equilibrium at all — wait time is not high, it is *unbounded*, and it rises linearly with how long the overload lasts. A pool that was fine at 38/s is in an unrecoverable queue at 42/s, and no amount of "it was fine yesterday" changes that.

The timeline below shows the three regimes on the same pool. Worker utilisation is the signal that saturates first and then stops telling you anything: it reads 100% at 40/s and 100% at 400/s, so the metric that looks most relevant is the one that goes blind exactly when you need it. Queue depth and queue age are the signals that keep talking.

  • Capacity = workers ÷ mean service time. Above it there is no steady state, only a growing queue.
  • Utilisation saturates at 100% and then conveys no information about how overloaded you are.
  • Queue depth and queue age are the signals that stay informative past the ceiling.
  • Latency degrades steeply *before* the ceiling, not at it — a pool at 95% utilisation is already in trouble.
One pool of 8 at three arrival rates. Modelled from a deterministic service time — a real system with variable service times queues earlier and worse.SIMULATED
20/s · workers
busy 4 of 8
busy 4 of 8
20/s · queue
empty — tasks start on arrival
38/s · workers
busy 8 of 8
38/s · queue
fluctuating 0–12
55/s · workers
busy 8 of 8 — identical to 38/s
55/s · queue
growing +15/s
still growing — no equilibrium exists
↑ flash sale begins↑ first client timeouts — queue age exceeds the 3 s client deadline
runningreadywaitingblockedidle1 tick ≈ 200 ms (one task)

Little's Law: the wait is arithmetic, not a mystery

Little's Law states that for a stable system, the average number of items in it equals the arrival rate times the average time each spends in it: L = λW. Applied to a pool it does two useful things. Forwards: with a queue of 300 and a completion rate of 40/s, the average wait is 300 / 40 = 7.5 seconds — you can read the latency off the queue graph without instrumenting latency at all. Backwards: if the client deadline is 3 seconds and you complete 40/s, the queue must never exceed 120 items, which is your rejection threshold. Perf's littles-law lesson has the general treatment; this is the pool-shaped use of it.

The trap in applying it here is the definition of "in the system". A task holding a worker while blocked on a database connection is in the system. So the second and much more common diagnosis is that the pool is not the constraint at all: eight workers are occupied waiting on a ten-connection database pool that fifty other threads also want, and adding workers makes the queue for *that* longer while the CPU sits at 4%. Raising the pool size here is not a fix, it is a way to build a bigger waiting room.

So the diagnostic question is never "is the pool too small". It is: what is every busy worker actually doing? A thread dump answers it in one look — all eight on-CPU means genuine capacity shortage, all eight parked in a connection acquire means the constraint is downstream, all eight in a lock acquire means the constraint is What Contention Actually Costs.

pool.workers.total        8
pool.workers.active       8        <- saturated, but of what?
pool.queue.depth        412  (+15/s, monotonic for 6m)
pool.queue.age.p99      9.8s       <- clients time out at 3s
pool.rejections           0        <- no bound: the queue is the leak

host.cpu.utilisation     4.1%      <- the tell
db.pool.size             10
db.pool.waiting           8        <- all eight workers are HERE
db.pool.wait.p99        7.4s

thread dump, 8 of 8 workers:
  "worker-1" WAITING  ConnectionPool.acquire(...)   <- not on CPU
  "worker-2" WAITING  ConnectionPool.acquire(...)
  ... x8

DIAGNOSIS: the pool is saturated; the pool is not the constraint.
  Raising pool size 8 -> 32 moves 32 threads into db.pool.waiting
  and changes nothing except memory and the size of the thread dump.
CONTRAST: same read-out with host.cpu.utilisation 98% and workers
  RUNNABLE on-CPU would be a genuine capacity shortage, and the
  answers are more machines, cheaper tasks, or shedding load.
A read-out that distinguishes the two diagnoses. Constructed for teaching; the shape of the numbers is what matters.

What to actually do about it

There are exactly four levers, and they are not interchangeable. Raise capacity (more workers, more machines) — correct only when workers are genuinely on-CPU and cores are available. Reduce service time (make the task cheaper, batch the downstream call, remove the N+1) — usually the highest-leverage and least-attempted. Reduce arrival rate (rate-limit upstream, coalesce duplicate work with Single-Flight Coalescing). Or shed load: bound the queue and reject, which is the only lever that works *during* an overload rather than after a deploy.

The lever nobody wants is the one that matters most in the moment. An unbounded queue does not protect you from overload; it converts a fast, attributable failure into a slow, unattributable one where every task completes long after its caller gave up. Work whose caller has already timed out is pure waste being done at the expense of work whose caller is still waiting — which is why a queue with an age-based drop ("if this has been waiting longer than the client deadline, discard it") frequently restores a system faster than any capacity change (Bounding Concurrency, and Architecture's backpressure lesson).

And check the composition. A saturated pool with a bimodal task mix is often not overloaded at all — it is head-of-line blocked by a handful of very slow tasks. The remedy there is separation (a second pool, or a per-class limit), not capacity.

What you observeMost likely causeLever that worksLever that makes it worse
Workers 8/8 on-CPU, host CPU ~100%, queue growingGenuine capacity shortageMore capacity, cheaper tasks, shed loadMore workers on the same cores — oversubscription
Workers 8/8 blocked in connection acquire, CPU lowDownstream limit is the constraintRaise the downstream limit, or cut connections held per taskRaise pool size — builds a bigger waiting room
Workers 8/8 in lock acquire, CPU lowLock contention inside the tasksShrink the critical section, shard the lockMore workers — more contenders on the same lock
Queue depth spikes and drains fully between burstsHealthy absorption of burstinessNothing — this is the queue doing its jobSizing up to flatten a graph that is already correct
Queue depth has a rising floor across daysSustained arrival rate above capacityCapacity or arrival-rate change; this will not self-correctWaiting for it to recover
Queue age p99 huge, p50 fine, workers busyHead-of-line blocking from a few very slow tasksSeparate pools per task class; timeout the slow classUniform sizing — wrong for both modes
Queue growing, zero rejections, memory climbingUnbounded queue absorbing an overload it cannot resolveBound the queue; drop work older than the client deadlineAdding queue capacity
Signal → diagnosis → the lever that actually applies.

Key points

  • Capacity is workers ÷ mean service time; above it no steady state exists and queue depth grows linearly with the overload.
  • Worker utilisation saturates at 100% and then carries no information — queue depth and queue age are the signals that keep working.
  • Little's Law (L = λW) converts queue depth into expected wait, and a latency budget into a rejection threshold.
  • The most common diagnosis is that the pool is saturated but not the constraint: workers are blocked on a downstream limit, and raising pool size only enlarges the waiting room.
  • A thread dump distinguishes the cases in one look: on-CPU means capacity, blocked in acquire means downstream, blocked in a lock means contention.
  • Serving work whose caller already timed out is waste; dropping by queue age often recovers a system faster than adding capacity.

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
  • Arrival rate exceeds the pool's completion rate, so tasks accumulate in the queue.
  • Queue depth grows at (arrivals − completions) per second; there is no equilibrium while that difference is positive.
  • Each task's total latency becomes queue age plus service time, and queue age dominates rapidly.
  • As queue age crosses the client deadline, callers time out and retry — increasing arrival rate, which is the feedback loop that turns overload into collapse — perf's retry-storms lesson has the amplification arithmetic.
  • Without a bound, memory grows with the queue; with a bound, submissions are rejected and the failure becomes attributable.
  • Recovery requires arrival rate below capacity for long enough to drain the accumulated backlog — the backlog itself is extra work the system must do on top of current traffic.
Interleavings that matter
  • All 8 workers hold a task; a 9th arrives and waits; the queue length is 1 and total latency for that task is now service time plus one worker's remaining time — the first moment latency depends on other people's work.
  • Workers 1–8 each acquire a database connection from a pool of 10; two more threads elsewhere take the last two; every subsequent worker task blocks in acquire while holding a pool worker — the pool and the connection pool are now deadlocked on each other in effect if any task needs two connections (Deadlock).
  • A client times out at 3 s and retries; the original task is still queued; both now occupy the queue and both will run — arrival rate has effectively doubled at the worst possible moment.
  • Shutdown is requested during saturation: with a drain-on-shutdown policy the pool tries to complete 412 queued tasks whose callers are all long gone, and the deploy hangs.
  • One 90-second task occupies a worker while 300 200-ms tasks queue behind it; p99 queue age is dominated by a task class that is 0.1% of volume.
What it guarantees — and does not
  • Guaranteed: with a bounded queue, memory is bounded and overload surfaces as rejections rather than as a heap dump.
  • Guaranteed: Little's Law holds for any stable system regardless of arrival distribution — it needs no assumptions about randomness.
  • NOT guaranteed: that a saturated pool recovers on its own. If arrivals stay above capacity there is no recovery, only a longer queue.
  • NOT guaranteed: that queued work is still wanted. The queue has no idea the caller left.
  • NOT guaranteed: that raising the pool size raises throughput. It does so only when workers are on-CPU with cores to spare.
  • NOT guaranteed: that low CPU means spare capacity. Eight workers blocked in acquire is a fully saturated pool at 4% CPU.
Where contention appears
  • Every saturated pool is contending on something; the diagnostic job is naming it. Cores, a connection pool, a lock, a rate limit, or disk queue depth.
  • The queue lock itself becomes contended at high submission rates, adding cost precisely when the system is least able to absorb it.
  • Retries from timed-out callers contend with fresh work for the same workers, and the queue cannot distinguish them.
  • Downstream contention is invisible in pool metrics and visible only in the downstream's own saturation graphs — which is why cross-service dashboards matter more than per-service ones.
How it fails
  • Unbounded queue growth ending in memory exhaustion long after the burst that caused it.
  • Latency collapse: queue age exceeds client deadlines, every caller times out, and the pool spends 100% of its capacity on work nobody is waiting for.
  • Retry amplification turning a 1.4× overload into a 3× overload.
  • Pool exhaustion deadlock when tasks in the pool wait on results from tasks that need a free worker.
  • Head-of-line blocking from a heavy-tailed task mix, misdiagnosed as capacity shortage.
  • Silent worker attrition making the pool smaller than configured, so capacity is below what the configuration claims.
When it helps
  • A pool running near saturation with a draining queue is efficient use of capacity — high utilisation is the goal for batch work with no latency target.
  • A queue absorbing genuine burstiness is the system working: it converts a spike in arrivals into a bounded spike in latency.
  • Saturation is a useful *signal*: it is the earliest honest indication that arrival rate has outgrown the design, and reacting to it beats reacting to the outage.
When it hurts
  • For latency-sensitive work, saturation is failure. Queue age is directly the user's experience, and there is no configuration that makes waiting fast.
  • When the queue is unbounded, saturation converts a survivable rejection into an unrecoverable backlog.
  • When retries are automatic and unbounded, saturation is self-amplifying and the system cannot recover without shedding.
  • When it is misdiagnosed as a pool-size problem, the "fix" moves the queue somewhere with fewer metrics.
How you would know
  • Queue depth over time, watching the floor rather than the peaks — a rising floor is structural, spikes that drain are healthy.
  • Queue age at p50 and p99, compared directly against the client deadline. Age above deadline means capacity is being spent on abandoned work.
  • Active workers versus pool size, but only as a saturation flag, never as a load level.
  • Downstream saturation and acquire-wait time, graphed on the same dashboard as pool metrics — the two-graph correlation is the whole diagnosis.
  • A thread dump during the incident: the state of the eight busy workers is the single most informative artefact available.
  • Rejection rate and reason; and if it is zero while the queue grows, that itself is the finding.
  • Retry rate from clients, to detect the amplification loop before it closes.
Complexity it introduces
  • Diagnosing saturation requires correlating metrics across a boundary — pool, downstream, and client — which is more observability plumbing than a single service usually has.
  • Load shedding needs a policy: what to drop, by what priority, with what response to the caller, and a way to say "this was shed" that does not look like a bug.
  • Age-based dropping requires carrying an enqueue timestamp and a deadline through the task, which touches every submission site (Deadlines vs Timeouts).
  • Separate pools per task class multiply configuration and require deciding how to divide finite capacity between classes.
Simpler alternatives
  • Bound the queue and reject early — the smallest change that converts an unrecoverable backlog into an attributable error.
  • Reduce service time instead of adding workers: batching a per-task downstream call is often a 5× capacity gain with no concurrency change at all.
  • Coalesce duplicate in-flight work with single-flight, when saturation is driven by many callers wanting the same result (Single-Flight Coalescing).
  • Autoscale worker count on queue age rather than CPU, when capacity is elastic — CPU is the wrong trigger for an I/O-bound pool.
  • Admission control at the edge: rate-limit or prioritise before the work enters the pool, where rejection is cheap and attributable.

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.

Bounding concurrency with permits

Bounding concurrency — the permit count protects the dependency, not you
10K tasks behind a semaphore. The downstream service can serve a fixed number at once; the permit slider decides how many you throw at it.
permitsgoodputmean latencytimeoutsfailed of 10K
1 25/s43 ms0.00%0
5 125/s43 ms0.00%0
10 250/s43 ms0.00%0
25 625/s43 ms0.00%0
50 1000/s53 ms0.00%0
100 1000/s103 ms0.00%0
200 1000/s203 ms0.00%0
350 1000/s353 ms0.00%0
500 0/s503 ms100.0%10K
in flight
50
goodput
1000/s
queueing delay added
10 ms
tasks that time out
0
50 permits against a dependency that serves 40 at a time. The extra 10 requests are not being served faster — they are sitting in the dependency's queue adding 10 ms to every latency, and 0 of the 10K tasks time out because of it. Goodput is 1000/s against a peak of 1000/s: you added concurrency and got errors, not throughput. The permit count you want is the one that keeps in-flight work at the dependency's capacity — which you measure, you do not guess.
SIMULATED40 ms service · 400 ms client timeout

What people believe, and what is true

Claim

All workers are busy, so the pool is too small.

Reality

Busy doing what? If they are blocked acquiring a downstream resource, more workers add contenders, not capacity. Read the thread dump before changing the number.

Claim

Utilisation is at 100%, so we are at capacity.

Reality

Utilisation reads 100% at 1.01× overload and at 10× overload. It tells you that you are saturated, never how badly. Queue age does.

Claim

A bigger queue gives the system room to recover.

Reality

A bigger queue lets the backlog grow longer before anything fails, so recovery takes longer and more of the work completed is already abandoned. Bounding it is the intervention.

Go deeper

Overview

Work is arriving faster than it is finishing. The queue grows, waiting time grows, and it will not fix itself while that is true.

Practical

Graph queue depth and queue age, not utilisation. Take a thread dump during the incident to find out what the busy workers are actually waiting on. Bound the queue.

Advanced

Use Little's Law backwards: the latency budget divided by completion rate is your maximum acceptable queue depth, and therefore your rejection threshold. Drop by age, not by arrival order.

Internals

Wait time rises non-linearly with utilisation because of service-time variability, so a pool at 95% is far worse than 95% of a pool at 100%. Above capacity the queueing model has no steady state at all.

Apply it