← Debugging Guides

阅读中文版本

Python — Multithreaded CPU Work Runs No Faster Than Single Thread

Explain why threading.Thread gives no speedup for CPU-bound Python code, measure the GIL effect with timings, and switch to processes or native extensions.

intermediatedebuggingpythonconcurrencyperformance

Symptoms

  • A thread pool of 8 workers completes a CPU-bound batch in the same wall time as a single thread
  • top shows one core pinned at 100% while the others idle, regardless of thread count
  • Latency stays flat as workers increase, but drops instantly when the loop moves to C code or processes

Possible causes

  • The Global Interpreter Lock serializes bytecode execution, so pure-Python CPU work cannot run in parallel threads
  • The workload is dominated by Python-level loops rather than C-level operations that release the GIL
  • Threads were chosen for CPU parallelism instead of multiprocessing or native code
  • Occasional speedups come only from I/O waits or GIL-releasing C extensions, masking the structural limit

Diagnosis steps

Benchmark thread scaling directly

python -c "import time,concurrent.futures as cf; t=time.perf_counter(); [cpu_task() for _ in range(8)]; print('serial', time.perf_counter()-t); t=time.perf_counter(); with cf.ThreadPoolExecutor(8) as ex: list(ex.map(lambda _: cpu_task(), range(8))); print('threads', time.perf_counter()-t)"

Interpretation

Serial and threaded wall times within noise of each other confirm the GIL serializes the work; scaling with process pools proves the hardware was never the limit.

Watch core utilization

mpstat -P ALL 1 5 # or htop with per-core view

Interpretation

One saturated core with idle siblings during the threaded run visualizes the serialization.

Classify the workload mix

python -m cProfile -s cumtime script.py | head -25

Interpretation

If cumulative time concentrates in pure-Python frames (loops, dict churn), threads cannot help; concentration in numpy or zlib changes the answer entirely.

Root cause

CPython executes bytecode under a Global Interpreter Lock. Multiple Python threads cannot execute bytecode simultaneously, so CPU-bound pure-Python work is serialized regardless of thread count, and the added thread-switching overhead often makes the pool marginally slower than the loop.

Fix

Use ProcessPoolExecutor or multiprocessing for CPU-bound Python work, keep threads for I/O-bound concurrency, move hot loops to NumPy or Cython or Rust extensions that release the GIL, and on Python 3.13+ evaluate the free-threaded build when ecosystem compatibility allows.

Risk note

Processes multiply interpreter memory and serialize-on-pickle for large arguments. Chunk work coarsely and pass indices rather than dataframes.

Verify the fix

Re-run the benchmark with a process pool and assert near-linear wall-time reduction with core count, while the thread version stays flat as a control.

Symptom walkthrough

The tell is in the wall-clock numbers, not an error message. Run the same 8 tasks three ways:

serial            12.4s
threads (8)       12.6s   <- no better, maybe worse
processes (8)      1.7s   <- the hardware was always ready

Threads that neither help nor hurt identify pure-Python CPU work under the GIL: one core pegged at 100% in top while siblings idle. Contrast with an I/O-bound workload, where the same thread pool shows the expected 8x overlap — the difference between the two experiments is the diagnosis.

Fix in practice

Match the tool to the workload class. CPU-bound Python goes to processes:

from concurrent.futures import ProcessPoolExecutor

with ProcessPoolExecutor(max_workers=8) as pool:
    results = list(pool.map(cpu_task, work_items))

Hot loops can also move below the GIL entirely — NumPy ufuncs, Cython with nogil, or Rust via PyO3 release the lock during their C-level execution. Threads remain the right answer for I/O: sockets, subprocess waits, file reads. And in mixed workloads, profile first (cProfile, cumtime sort): time inside GIL-releasing calls behaves like I/O, time inside pure-Python frames is the serialized part.

Verification

  Benchmark all three shapes with identical inputs and record the table: serial, thread pool, process pool. The acceptance criteria are a process-pool speedup close to linear in core count and a thread-pool line that stays flat, proving the GIL boundary is the variable under test. Repeat with a NumPy-dominated variant to show the exception — GIL-releasing C code where threads do parallelize — so the team learns to classify workloads by measurement rather than by folklore.