Skip to content
v0.1.1latest release34 skills2 categories4 sub-categories79 tested scripts157 reference docsContact us

Debugging Thread Pools

Reference for python-threadpool-executor-builder · 430 lines

Programming / Pythonv1.0.0Updated OCT 21 20267 references1 script

Find out what a thread pool is doing, why it lost data, why it is slow, and why it will not stop. For a full hang investigation (thread dumps of a stuck production process, lock-order cycles, watchdogs) use python-async-deadlock-debugger; this file covers the pool-specific failure modes and every linter rule.

Contents

  1. Triage
  2. Error messages explained
  3. Every TPE rule, before and after
  4. Seeing what every thread is doing
  5. The process will not exit
  6. The pool is slow
  7. Instrumenting an executor

1. Triage

ObservationFirst checkLikely cause
Output has fewer rows than input, exit code 0executor_tool.py check for TPE001/TPE005/TPE015Futures never read; except Exception: pass
RSS grows during "submitting", no progress outputTPE003/TPE004Unbounded submission
Stuck at N% foreverThread dump (section 4)Pool exhaustion (TPE009), a call without timeout, a lock
Finishes work but does not exitthreading.enumerate() at the end of mainNon-daemon thread, executor never shut down, queued work
Ctrl-C does nothing for minutesMain thread in with exit / result()No cancel_futures, no stop event (TPE012)
Throughput far below the rate limitLimiter wait vs call latency (section 6)Too few workers, small connection pool, GIL-bound parsing
429s despite a limiterIs the bucket shared? Do retries take tokens?One bucket per thread, retries bypassing it, several processes
Wrong totals, flaky testsTPE006Unlocked read-modify-write
Request ids missing in worker logsTPE019Context not copied

2. Error messages explained

MessageMeaningFix
RuntimeError: cannot schedule new futures after shutdownsubmit after shutdown() / after the with blockSubmit inside the block; keep app-wide executors alive until app shutdown
RuntimeError: cannot schedule new futures after interpreter shutdownsubmit while the interpreter exits: from atexit, __del__, a daemon thread, or after main returned while threads still runStop producers and join threads before main returns
concurrent.futures.thread.BrokenThreadPool: A thread initializer failed, the thread pool is not usable anymoreinitializer raised; traceback logged as Exception in initializer:Fix the initializer; validate config before creating the pool
exception calling callback for <Future ...> (logged)A done-callback raised; the exception was swallowed after loggingMake callbacks total (try/except inside)
TimeoutError from as_completed / mapThe overall deadline passedThe tasks still run: cancel queued ones, decide what to do with the rest
concurrent.futures.CancelledError from result()The future was cancelled (by you, shutdown(cancel_futures=True) or map after an error)Expected during shutdown: count it
Connection pool is full, discarding connection: host (urllib3 warning)More threads than pool_maxsize for one hostHTTPAdapter(pool_maxsize=workers)
httpx.PoolTimeoutAll connections busy longer than the pool timeoutLimits(max_connections=...) >= workers, or fewer workers
sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in that same threadShared connectionConnection per thread / writer thread
RuntimeError: dictionary changed size during iterationOne thread iterates a dict another mutatesSnapshot under a lock (with lock: items = list(d.items()))
RuntimeError: cannot enter context: <Context> is already enteredOne copy_context() result shared by concurrent tasksOne copy per task
Exception in thread writer: + traceback on stderrA raw Thread died; printed by threading.excepthookCatch in the thread and forward the error; or use a pool

3. Every TPE rule, before and after

Run python scripts/executor_tool.py check --list-rules for the canonical why/fix text of each rule.

TPE001 future-never-read - exceptions vanish.

python
# before
for doc in docs:
    ex.submit(upsert, doc)
# after
futures = {ex.submit(upsert, doc): doc for doc in docs}
for fut in as_completed(futures):
    try:
        fut.result()
    except Exception as exc:
        failed[futures[fut]] = exc

TPE002 executor-not-shutdown - leaked threads, no collection point.

python
# before
def embed_all(batches):
    ex = ThreadPoolExecutor(16)
    return list(ex.map(embed, batches))
# after
def embed_all(batches):
    with ThreadPoolExecutor(16, thread_name_prefix="embed") as ex:
        return list(ex.map(embed, batches))

TPE003 unbounded-submit and TPE004 map-over-generator - memory grows with the input.

python
# before
futures = [ex.submit(score, line) for line in open("prompts.jsonl")]
results = ex.map(score, (json.loads(l) for l in open("prompts.jsonl")))
# after: sliding window (any version) or buffersize (3.14+)
for i, res in window_map(score, open("prompts.jsonl"), ex, max_in_flight=64):
    write(i, res)
results = ex.map(score, (json.loads(l) for l in open("prompts.jsonl")), buffersize=64)

TPE005 map-results-unconsumed - exceptions never raised; timeout never enforced.

python
# before
with ThreadPoolExecutor(8) as ex:
    ex.map(upsert, batches)
    it = ex.map(fetch, urls, timeout=30)
pages = list(it)                       # after the with: everything already finished, timeout meaningless
# after
with ThreadPoolExecutor(8) as ex:
    for _ in ex.map(upsert, batches):
        pass
    pages = list(ex.map(fetch, urls, timeout=30))

TPE006 shared-read-modify-write and TPE007 shared-container-mutation.

python
# before
def work(doc):
    global TOKENS
    TOKENS += count(doc)
    RESULTS.append(parse(doc))
# after: return values and aggregate in the caller
def work(doc):
    return count(doc), parse(doc)
pairs = list(ex.map(work, docs))
tokens = sum(n for n, _ in pairs)

TPE008 shared-unsafe-client - one requests.Session / sqlite3 connection used by all workers.

python
# before
SESSION = requests.Session()
def fetch(url):
    return SESSION.get(url, timeout=10).text
# after
_local = threading.local()
def fetch(url):
    if not hasattr(_local, "s"):
        _local.s = requests.Session()
    return _local.s.get(url, timeout=10).text

TPE009 executor-self-wait - pool exhaustion deadlock under load.

python
# before
def summarize_doc(doc):
    parts = [POOL.submit(summarize_chunk, c) for c in chunks(doc)]
    return combine([p.result() for p in parts])        # holds a POOL worker while waiting on POOL
results = [POOL.submit(summarize_doc, d) for d in docs]
# after: flatten (submit leaves from the caller), or a separate pool per level
chunk_futs = {POOL.submit(summarize_chunk, c): d for d in docs for c in chunks(d)}

Why it deadlocks and how to prove it from a thread dump: section 6 of threading-and-multiprocessing-deadlocks.md.

TPE010 pool-size-smell - max_workers=200 or max_workers=1.

python
# before
ex = ThreadPoolExecutor(max_workers=200)
# after
ex = ThreadPoolExecutor(max_workers=workers_for(rpm / 60, p95_latency_s), thread_name_prefix="llm")

TPE011 result-without-timeout (request handlers).

python
# before
def search(queries):
    futures = [IO_POOL.submit(call_llm, q) for q in queries]
    return [f.result() for f in futures]
# after
def search(queries):
    futures = [IO_POOL.submit(call_llm, q) for q in queries]
    deadline = time.monotonic() + 10
    return [f.result(timeout=max(0.0, deadline - time.monotonic())) for f in futures]

TPE012 shutdown-nowait-at-exit - queued work keeps running after Ctrl-C.

python
# before
try:
    run_all(ex)
except KeyboardInterrupt:
    ex.shutdown(wait=False)
# after
try:
    run_all(ex)
except KeyboardInterrupt:
    stop.set()
    ex.shutdown(wait=True, cancel_futures=True)
    raise

TPE013 daemon-thread-side-effects - writes cut off at exit.

python
# before
threading.Thread(target=flush_to_s3, args=(q,), daemon=True).start()
# after
t = threading.Thread(target=flush_to_s3, args=(q,), name="s3-flusher")
t.start()
q.put(None)
t.join()

TPE014 polling-future-done - latency, CPU and lost exceptions.

python
# before
while not all(f.done() for f in futures):
    time.sleep(1)
# after
for f in as_completed(futures, timeout=3600):
    handle(f.result())

TPE015 swallowed-future-exception.

python
# before
try:
    rows.append(fut.result())
except Exception:
    pass
# after
try:
    rows.append(fut.result())
except Exception as exc:
    log.warning("task failed", exc_info=exc)
    errors.append(exc)

TPE016 executor-per-call - per request / per batch / per task pools.

python
# before
@app.post("/answer")
def answer(q: str):
    with ThreadPoolExecutor(4) as ex:
        return list(ex.map(call_tool, plan(q)))
# after: one executor for the app, created in the lifespan, shut down on stop
def answer(q: str):
    return [f.result(timeout=20) for f in [IO_POOL.submit(call_tool, t) for t in plan(q)]]

TPE017 thread-not-joined.

python
# before
threading.Thread(target=warm_cache).start()
# after
t = threading.Thread(target=warm_cache, name="warm-cache")
t.start()
t.join(timeout=60)

TPE018 cpu-bound-thread-pool - pure-Python arithmetic gains nothing from threads on a GIL build.

python
# before
with ThreadPoolExecutor(8) as ex:
    scores = list(ex.map(edit_distance_matrix, pairs))
# after
with ProcessPoolExecutor() as ex:
    scores = list(ex.map(edit_distance_matrix, pairs, chunksize=64))

Confirm the diagnosis before switching: python-gil-bottleneck-analyzer.

TPE019 contextvar-not-propagated.

python
# before
fut = ex.submit(call_llm, prompt)                  # call_llm logs request_id.get() -> "-"
# after
fut = ex.submit(contextvars.copy_context().run, call_llm, prompt)

4. Seeing what every thread is doing

Name every pool (thread_name_prefix) and long-lived thread (name=) first; dumps are useless otherwise.

ToolCommandNotes
faulthandler on demandfaulthandler.register(signal.SIGUSR1, all_threads=True) then kill -USR1 <pid>stdlib, prints every thread's stack to stderr; Unix
faulthandler timerfaulthandler.dump_traceback_later(600, repeat=True)Dumps if the job is still running after N seconds
py-spypy-spy dump --pid <pid> (add --locals for variables)No code changes; may need sudo / CAP_SYS_PTRACE
In-processsys._current_frames() + threading.enumerate()Build a /debug/threads endpoint
Watchdogpython-async-deadlock-debugger/scripts/hang_watchdog.pyRuns the target and dumps on stall
python
import sys
import threading
import time
import traceback
from concurrent.futures import ThreadPoolExecutor


def dump_threads() -> str:
    names = {t.ident: t.name for t in threading.enumerate()}
    out = []
    for ident, frame in sys._current_frames().items():
        top = traceback.extract_stack(frame)[-1]
        out.append(f"{names.get(ident, ident)}: {top.name} line {top.lineno}")
    return "\n".join(sorted(out))


def stuck_call() -> None:
    time.sleep(0.2)                                     # e.g. a request without a timeout


with ThreadPoolExecutor(max_workers=2, thread_name_prefix="llm") as ex:
    ex.submit(stuck_call).add_done_callback(lambda f: f.result())
    ex.submit(stuck_call).add_done_callback(lambda f: f.result())
    time.sleep(0.05)
    print("\n".join(line.split(" line ")[0] for line in dump_threads().splitlines() if line.startswith("llm")))
# llm_0: stuck_call
# llm_1: stuck_call

Reading a pool dump:

  • All llm_* threads in _worker -> work_queue.get: workers idle; the bottleneck is upstream (producer, limiter, main thread).
  • All in a socket recv/ssl.read: waiting on the network; check timeouts and the provider.
  • All in TokenBucket.acquire / Event.wait: the rate limiter is the bottleneck (expected at the limit).
  • All in Future.result / wait inside your task: TPE009 pool exhaustion.
  • In Lock.acquire on one lock: contention or a lock held across I/O.

5. The process will not exit

At interpreter exit Python joins every non-daemon thread, including pool workers, after their queues drain.

python
import threading


def report_live_threads() -> list:
    """Call at the end of main(): anything listed here will keep the process alive."""
    return sorted(t.name for t in threading.enumerate()
                  if t is not threading.main_thread() and not t.daemon and t.is_alive())


print(report_live_threads())                            # [] when everything was joined/shut down

Typical culprits: a ThreadPoolExecutor created at module level with work still queued; a background thread running while True without a stop event; a queue.get() with no sentinel; an asyncio loop thread never stopped; a third-party SDK's own background threads (telemetry, batch span processors) that need client.close() / provider.shutdown(). Diagnosis of the stuck thread is the same as section 4; the shutdown-hang catalogue is in section 14 of threading-and-multiprocessing-deadlocks.md.

6. The pool is slow

Measure where time goes per task: queue wait (submitted -> started), limiter wait, call latency, post-processing. Then:

MeasurementMeaningAction
Queue wait high, all workers busy in callsToo few workers for the latencyRaise workers (Little's law)
Limiter wait highYou are at the rate limitExpected; raise the limit with the provider, or cache/batch
Call latency rises with workersServer or DB saturatedFewer workers, bigger batches
Main thread at 100% CPU, workers idleProducer/consumer in the main thread is the bottleneck (JSON parsing, writing)Move parsing into tasks or processes
One core busy, many threads, little I/OGIL-bound work in tasksProcesses; see the GIL skill
Many new TLS connectionsConnection pool smaller than workersRaise pool size (see the bounding reference)

Turn on library debug logs briefly: logging.getLogger("urllib3").setLevel(logging.DEBUG) shows connection creation; logging.getLogger("httpx").setLevel(logging.DEBUG) shows requests.

7. Instrumenting an executor

A subclass that records queue wait and run time per task, plus in-flight count, gives you the numbers for section 6 and feeds metrics (Prometheus, OpenTelemetry) or logs.

python
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor
from typing import Any, Callable, Dict, List


class InstrumentedExecutor(ThreadPoolExecutor):
    def __init__(self, *args: Any, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        self._lock = threading.Lock()
        self.in_flight = 0
        self.queue_wait: List[float] = []
        self.run_time: List[float] = []
        self.errors: Dict[str, int] = {}

    def submit(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Future:
        submitted = time.monotonic()

        def timed() -> Any:
            started = time.monotonic()
            with self._lock:
                self.in_flight += 1
                self.queue_wait.append(started - submitted)
            try:
                return fn(*args, **kwargs)
            except Exception as exc:
                with self._lock:
                    self.errors[type(exc).__name__] = self.errors.get(type(exc).__name__, 0) + 1
                raise
            finally:
                with self._lock:
                    self.in_flight -= 1
                    self.run_time.append(time.monotonic() - started)

        return super().submit(timed)


def call(i: int) -> int:
    time.sleep(0.01)
    if i % 10 == 0:
        raise TimeoutError("read timeout")
    return i


with InstrumentedExecutor(max_workers=4, thread_name_prefix="api") as ex:
    futures = [ex.submit(call, i) for i in range(40)]
    outcomes = [f.exception() is None for f in futures]
p95_wait = sorted(ex.queue_wait)[int(0.95 * len(ex.queue_wait))]
print(sum(outcomes), ex.errors, f"p95 queue wait > run time: {p95_wait > 0.01}")
# 36 {'TimeoutError': 4} p95 queue wait > run time: True

A p95 queue wait far above the run time with idle rate-limit headroom means: add workers. Export in_flight, latency histograms, error counts by type, retries and limiter wait as metrics in long-running services; log a summary line at the end of batch jobs.

More in Python

python-async-deadlock-debugger

Diagnose and fix hangs, deadlocks, livelocks and starvation in asyncio, threaded and multiprocessing Python: classify CPU spin vs blocked IO vs deadlock vs lost wakeup vs event loop blocked by sync code, collect evidence with faulthandler, py-spy dump, gdb py-bt, asyncio.all_tasks, python -m asyncio ps/pstree, debug mode and sys._current_frames, then fix lock-order cycles, non-reentrant asyncio.Lock re-acquisition, run_coroutine_threadsafe().result() on the loop thread, Queue.join without task_done, Condition lost wakeups, semaphore leaks, futures nobody resolves, nested event loops, thread pool exhaustion, multiprocessing Queue/fork deadlocks and shutdown hangs. Ships a static hang scanner and a watchdog that dumps every thread and asyncio task.

python-asyncio-pattern-advisor

Recommend, implement and review Python asyncio patterns: async vs threads vs processes (IO vs CPU-bound), coroutines vs tasks vs futures, TaskGroup, gather and return_exceptions, wait and as_completed, Semaphore and Queue worker pools with backpressure, asyncio.timeout vs wait_for, cancellation, shield and cleanup, token-bucket RPM/TPM rate limits for LLM APIs, retries with backoff and jitter, async generators and SSE token streaming, to_thread/run_in_executor, background task references, eager tasks, asyncio.run/Runner, uvloop, Jupyter, FastAPI async vs def routes, httpx.AsyncClient lifespan, async DB/vector-store clients, debug mode and pytest-asyncio.

python-beginner-concept-tutor

Teach Python concepts to beginners heading into AI engineering (data scientists, analysts, students, career switchers): plain-language definitions, analogies, minimal examples, line-by-line walkthroughs, common mistakes, check-your-understanding questions and mini exercises; Socratic hints before solutions; explaining tracebacks; mutable vs immutable, references and aliasing, is vs ==, integer division, floating point, slicing and off-by-one, indentation errors; a staged learning path from variables to calling an LLM API, embeddings and a tiny RAG. Ships a script that lists the concepts a snippet uses in learning order and traces execution step by step like Python Tutor.

python-comprehension-refactorer

Refactor Python loops into list, dict and set comprehensions and generator expressions, and unroll comprehensions that are too clever: filtering, mapping, nested-loop flattening, conditional expressions, walrus in comprehensions, dict inversion, grouping with defaultdict/Counter, lazy generators for sum/any/all/max, streaming large files, itertools (chain, islice, groupby, batched, pairwise, accumulate, product), scoping rules, side-effect anti-patterns and performance (when map/filter or numpy/pandas vectorization win). AI-engineering focus: building prompt lists, batching texts for embeddings, filtering retrieved chunks by score, id->embedding dicts, flattening tool-call results, lazy dataset streaming and token counting.

python-context-manager-writer

Write correct Python context managers and find resource-handling bugs — __enter__/__exit__ semantics and exception suppression, contextlib (contextmanager, asynccontextmanager, ExitStack, AsyncExitStack, closing, aclosing, suppress, redirect_stdout, nullcontext, chdir), async with, re-entrancy, leaked files/connections/clients/pools/locks, and AI-engineering recipes: torch.no_grad/inference_mode/autocast, eval()/train() switches, GPU cache cleanup, token and latency meters, tracing spans, temp env vars, rate-limit semaphores, httpx/aiohttp client lifecycle, FastAPI lifespan, DB/vector-store transactions, atomic checkpoint writes, seeds and agent sandboxes.

python-dataclass-converter

Convert boilerplate Python classes, dicts, tuples and JSON payloads into dataclasses, or into pydantic v2 BaseModel, TypedDict, NamedTuple, attrs or msgspec when that fits better. Covers every @dataclass option (frozen, slots, kw_only, order, unsafe_hash, match_args, weakref_slot), field() (default_factory, init=False, repr, compare, hash, metadata), __post_init__, InitVar, KW_ONLY, inheritance and default ordering, replace(), asdict deep-copy pitfalls, hashing/equality, serialization and validation. AI focus: LLM request/response and chat Message types, RAG chunks with embeddings (numpy eq gotcha), eval records, configs, typed OpenAI/Anthropic JSON.