Observing Thread Pool Saturation

A thread pool that has run out of workers does not report an error and does not report slow tasks. Tasks wait in a queue before they start, the waiting is added to whoever submitted them, and every measurement taken inside the task looks normal. This page covers making that wait visible, the gauges that turn saturation into a line touching a ceiling, and the decision between growing the pool and bounding its queue. It is a task article under concurrency and GIL observability, part of the Python profiling and performance observability section.

Where the waiting happens A thread pool with four workers is drawn with an unbounded queue in front of it. Callers submit tasks at a rate higher than four workers can complete them. All four workers are busy, each running a task that takes forty milliseconds. Behind them the queue holds a growing number of waiting tasks. A timer inside each task records forty milliseconds regardless of load. A timer around the caller's submission and result records forty milliseconds plus however long the task spent queued, which grows steadily. The diagram marks three measurements that make the situation visible: queue wait time from submission to start, busy workers against the pool's configured size, and queue length. arrivals faster than four workers can serve callers submit() queue — unbounded waiting — not yet timed by anything inside the task 4 workers, all busy each task: 40 ms which is all they report three measurements make this visible queue wait (submit → start) · busy workers against pool size · queue length none of them exists by default, and without them the pool looks healthy right up to the outage
The latency is real and it is in the queue. Every measurement taken from inside the worker sits on the wrong side of it.

Prerequisites

pip install "prometheus-client>=0.20.0,<1.0.0" \
            "opentelemetry-api>=1.27.0,<2.0.0"

Implementation

Step 1 — Wrap submission so queue wait is measured. The executor gives no hook for when a task starts, so the wrapper records the submission time and measures the gap at the top of the task itself. The difference between submission and start is the queue wait, and it is the number that changes when the pool saturates.

# instrumented_pool.py
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor

from opentelemetry import context as otel_context
from prometheus_client import Gauge, Histogram

QUEUE_WAIT = Histogram("pool_queue_wait_seconds", "Submit to start", ["pool"],
                       buckets=(0.001, 0.005, 0.025, 0.1, 0.25, 0.5, 1, 2.5, 5, 10))
RUN_TIME = Histogram("pool_task_run_seconds", "Start to finish", ["pool"])
BUSY = Gauge("pool_busy_workers", "Workers currently running a task", ["pool"])
SIZE = Gauge("pool_max_workers", "Configured pool size", ["pool"])
QUEUED = Gauge("pool_queued_tasks", "Tasks submitted but not started", ["pool"])


class InstrumentedPool:
    def __init__(self, name: str, max_workers: int):
        self._name = name
        self._pool = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix=name)
        SIZE.labels(name).set(max_workers)

    def submit(self, fn, *args, **kwargs) -> Future:
        submitted = time.perf_counter()
        ctx = otel_context.get_current()          # 1. capture the caller's trace context
        QUEUED.labels(self._name).inc()

        def run():
            started = time.perf_counter()
            QUEUED.labels(self._name).dec()
            QUEUE_WAIT.labels(self._name).observe(started - submitted)
            BUSY.labels(self._name).inc()
            token = otel_context.attach(ctx)      # 2. restore it inside the worker
            try:
                return fn(*args, **kwargs)
            finally:
                otel_context.detach(token)
                BUSY.labels(self._name).dec()
                RUN_TIME.labels(self._name).observe(time.perf_counter() - started)

        return self._pool.submit(run)

Step 2 — Gauge busy workers against the pool size. Two lines on one chart — workers busy and workers configured — turn saturation into something visible at a glance: the first line touching the second and staying there. An average utilisation figure hides this, because a pool that is saturated half the time and idle the other half averages to a healthy-looking fifty percent.

Step 3 — Gauge the queue length. Queue length is the earliest indicator that arrivals exceed capacity, and its trend is more informative than its value. A queue that grows during a peak and drains afterwards is coping; one that grows without draining is a pool that has fallen permanently behind.

Expected Output: the three measurements during a saturation event.

pool_max_workers{pool="pdf"}          4
pool_busy_workers{pool="pdf"}         4
pool_queued_tasks{pool="pdf"}       118
pool_queue_wait_seconds p99         6.8
pool_task_run_seconds p99           0.041

Every task runs in forty-one milliseconds and every caller waits nearly seven seconds. Only the first three lines explain the last two.

Step 4 — Carry trace context into the worker. Context is stored per thread, so a task running on a pool thread starts with an empty context unless one is attached explicitly. The wrapper above captures and restores it, so the task's spans join the originating trace and the queue wait appears as a visible gap between the submitting span and the task span — which is the view that makes saturation obvious to anyone reading the trace. The mechanics generalise to any pool boundary, as covered in propagating context across thread and process pools.

Step 5 — Bound the queue when growing the pool will not help. The default executor's queue is unbounded, so overload accumulates as backlog and latency rather than as errors. A semaphore around submission converts it: when the queue is full, submission fails immediately, the caller can return an error or a degraded response, and the backlog never exceeds what the pool can clear in a reasonable time.

class BoundedPool(InstrumentedPool):
    def __init__(self, name: str, max_workers: int, max_queued: int):
        super().__init__(name, max_workers)
        self._slots = threading.BoundedSemaphore(max_workers + max_queued)

    def submit(self, fn, *args, **kwargs) -> Future:
        if not self._slots.acquire(blocking=False):
            raise RuntimeError(f"{self._name} pool full")   # fail fast
        future = super().submit(fn, *args, **kwargs)
        future.add_done_callback(lambda _: self._slots.release())
        return future
Unbounded queues turn overload into minutes of latency Caller latency is plotted over a period that includes a five minute burst of load above the pool's capacity. With an unbounded queue, latency climbs throughout the burst as the backlog grows, reaching tens of seconds, and continues to be elevated for several minutes after the burst ends while the backlog drains; every request during and after the burst is slow. With a bounded queue, latency rises to a ceiling set by the queue bound and stays there during the burst, while requests beyond the bound are rejected immediately with an error; when the burst ends, latency returns to normal at once because there is no backlog to drain. The note records that the bounded version has errors and the unbounded version does not, and that the errors are the better outcome because they are fast and they stop. a five-minute burst above capacity burst latency unbounded — slow long after the burst bounded — capped, and recovers at once excess rejected fast the bounded version has errors and the unbounded one does not — and the errors are the better outcome
An unbounded queue converts overload into latency that outlasts the overload. A bounded one converts it into fast errors that stop when the overload does.

Growing the pool or bounding it

When the measurements show saturation, there are two responses and they suit different situations.

Grow the pool when the resource behind it has capacity. If the workers call a service or a database that is lightly loaded, more workers mean more concurrent calls and more throughput. The check is whether the downstream resource's own latency stays flat as the pool grows; if it rises, the pool was not the real constraint and growing it has moved the queue somewhere else.

Do not grow the pool when the work is CPU-bound Python. Additional threads contend for the interpreter lock, as described in measuring GIL contention in Python, and throughput stays flat. The right change is a process pool.

Bound the queue when overload is possible and waiting is worse than failing. A request that will time out after ten seconds gains nothing from sitting in a queue for nine of them. Rejecting it immediately lets the caller retry elsewhere, degrade gracefully or report the problem honestly, and it prevents the backlog that makes recovery slow. This is the right default for any pool on a request path.

Leave the queue unbounded only for work that must eventually complete and is not latency-sensitive. Background tasks that can run late but must run belong on a durable queue anyway; an in-memory unbounded queue is a place where work is lost when the process exits.

The measurements decide which case applies. Queue wait growing while downstream latency is flat says grow the pool; queue wait growing while downstream latency also grows says the pool is protecting something that is already saturated, and bounding it is the kinder option for everyone.

Choosing the pool size from the measurements

Pool sizes are usually set once, from a guess, and never revisited. The measurements above make a better choice possible, and the reasoning is straightforward once the numbers exist.

Little's law gives the relationship: the average number of busy workers equals the arrival rate multiplied by the average run time. A pool receiving eighty tasks per second, each running for fifty milliseconds, keeps four workers busy on average. That is the floor, not the answer — a pool sized exactly at its average load saturates whenever arrivals bunch up, which they always do.

The useful sizing question is what queue wait is acceptable at the peak. The run time histogram gives the service time; the arrival rate at the busiest minute of the busiest hour gives the load; and the gap between the average busy count and the configured ceiling is the headroom that absorbs bursts. Most request-path pools want the busy count at peak to sit around seventy percent of the ceiling, which leaves room for bursts without queueing becoming the dominant latency.

Two constraints override that arithmetic. The downstream resource's capacity is a hard ceiling: a pool of fifty workers calling a service that accepts twenty concurrent requests has thirty workers queueing somewhere else. And for CPU-bound work the useful ceiling is the number of cores, whatever the arrival rate, because workers beyond that only contend.

Re-running the sizing when the run time distribution shifts — after a dependency slows down, or a feature adds work per task — catches the case where a pool that was comfortably sized last quarter is now saturating every afternoon. The run time histogram changing is the trigger; the queue wait histogram is the confirmation.

Four metrics for one pool A table of four metrics that together describe a thread pool. Active workers, a gauge, shows how many threads are busy; pinned at the maximum means saturation. Queue depth, a gauge, shows tasks waiting for a thread; growing means arrivals exceed capacity. Queue wait time, a histogram, shows how long tasks wait before starting; this is the latency the pool adds. Task duration, a histogram, shows how long each task holds a thread; rising durations shrink effective capacity. The note says wait time is the one to alert on, since it is what callers experience. metric type shows active workers gauge pinned at max → saturated queue depth gauge growing → arrivals exceed capacity queue wait time histogram latency the pool adds — alert on this task duration histogram rising → less effective capacity wait time is what callers experience, so it is the one to alert on
Busy threads and queue length describe the pool; wait time describes its effect on requests.

Configuration options

Metric Type What it shows
pool_queue_wait_seconds histogram saturation, directly
pool_task_run_seconds histogram the work itself, independent of load
pool_busy_workers gauge utilisation against the ceiling
pool_max_workers gauge the ceiling, on the same chart
pool_queued_tasks gauge backlog and its trend
Queue bound semaphore converts overload into fast failures
Trace context captured at submit queue wait visible in traces

Verification

Drive the pool past its capacity in staging and confirm each measurement moves the way it should.

import time
from instrumented_pool import BoundedPool

pool = BoundedPool("probe", max_workers=4, max_queued=8)
rejected = 0
for _ in range(100):
    try:
        pool.submit(time.sleep, 0.2)
    except RuntimeError:
        rejected += 1
print(f"rejected {rejected} of 100")

Expected Output: the bound engaging, and the gauges reflecting it.

rejected 88 of 100
pool_busy_workers{pool="probe"}   4
pool_queued_tasks{pool="probe"}   8

Common mistakes

Timing only inside the task. Error signature: normal task durations during a latency incident. Root cause: queue wait happens before the timer starts. Remediation: measure from submission, as in step 1.

Averaging utilisation. Error signature: a pool reported at fifty percent that saturates every afternoon. Root cause: an average hides alternating saturation and idleness. Remediation: chart busy workers against the ceiling, at full resolution.

Growing the pool for CPU-bound work. Error signature: no throughput gain from more workers. Root cause: interpreter lock contention. Remediation: move the work to a process pool.

Leaving the queue unbounded on a request path. Error signature: latency that stays high for minutes after load subsides. Root cause: a backlog that must drain. Remediation: bound the queue and fail fast when it is full.

Losing trace context at the pool. Error signature: worker spans appearing as unrelated root traces. Root cause: context is per thread and was not carried across. Remediation: capture at submission and attach inside the worker.

Frequently Asked Questions

Why is my thread pool's task latency fine while requests are slow?

Because a task's duration is usually measured from when a worker starts it. When all workers are busy, tasks wait in the queue first, and that wait is added to the caller's latency but not to the task's. Measuring from submission reveals it.

Does ThreadPoolExecutor bound its queue?

No. Its internal queue is unbounded, so under sustained overload the backlog grows until memory runs out or the load subsides. Bounding it requires a semaphore around submission or a custom executor.

Should I just make the pool bigger?

Only if the resource the workers use can accept more concurrency. A pool calling a database that is already saturated, or doing CPU-bound Python under the interpreter lock, gains nothing from more workers and may lose throughput to contention.

How do I get traces to connect across the pool?

Capture the current context when submitting and attach it inside the worker. Without that, the worker's spans become new root spans and the connection to the originating request is lost, along with any view of the queue wait.