Quick Answer

Threads do not speed up CPU-bound Python because the GIL allows only one thread to execute bytecode at a time. Use multiprocessing for CPU work and threads for waiting on input and output.

The measurement

Summing two million square roots, four times over, three ways:

import time, math
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def work(n):
    return sum(math.sqrt(i) for i in range(n))

if __name__ == "__main__":
    tasks = [2_000_000] * 4

    t0 = time.perf_counter(); [work(n) for n in tasks]
    print(f"sequential : {time.perf_counter()-t0:.2f}s")

    t0 = time.perf_counter()
    with ThreadPoolExecutor(4) as ex: list(ex.map(work, tasks))
    print(f"4 threads  : {time.perf_counter()-t0:.2f}s")

    t0 = time.perf_counter()
    with ProcessPoolExecutor(4) as ex: list(ex.map(work, tasks))
    print(f"4 processes: {time.perf_counter()-t0:.2f}s")
sequential : 2.88s
4 threads  : 2.96s
4 processes: 0.62s

Threads were marginally slower than doing nothing special — the overhead of switching between them, with no parallelism gained. Processes were about four and a half times faster on an eight-core machine.

Why threads did nothing

CPython has a global interpreter lock. Only one thread may execute Python bytecode at any instant, so four threads doing arithmetic take turns rather than running together.

The lock exists because CPython's memory management is not thread-safe, and a single coarse lock is far simpler and faster for single-threaded code than locking every object individually.

The crucial detail is that the GIL is released during input and output. A thread waiting on a network response or a disk read is not executing bytecode, so others run meanwhile. That is why threads genuinely help for downloading fifty URLs and do nothing for computing fifty checksums.

Processes escape it entirely because each has its own interpreter and its own GIL. The cost is that they cannot share memory directly.

Choosing between them

  • CPU-bound work — image processing, numeric computation, parsing large files, cryptography. Use ProcessPoolExecutor.
  • I/O-bound work — HTTP requests, database queries, file reads. Use ThreadPoolExecutor, or asyncio for very large numbers of concurrent waits.

The quick test: if the task would be faster on a faster CPU, it is CPU-bound. If it spends its time waiting for something else, it is I/O-bound.

One important exception: libraries such as NumPy release the GIL inside their C code, so numeric work in NumPy can genuinely benefit from threads. If your heavy lifting is already in a compiled library, measure before assuming you need processes.

The practical gotchas

The __main__ guard is mandatory on Windows and macOS. Those platforms start child processes by importing your module, so without the guard the child re-runs your top-level code and spawns more children. The failure is dramatic and confusing, and on Windows it also means you cannot run a multiprocessing example straight from an interactive prompt.

if __name__ == "__main__":
    # multiprocessing code goes here

Arguments and results are pickled. Everything crossing a process boundary must be serialisable, so lambdas, open file handles and database connections cannot be passed. Sending large objects is also genuinely slow — the transfer can cost more than the computation saves.

Processes do not share memory. Each has its own copy of everything, so a global modified in a worker is invisible to the parent. Return values instead, or use the explicit shared-memory types.

More processes than cores does not help for CPU work. Default the pool to the core count.

The practical pattern

concurrent.futures is the interface to use. It gives one API for both, so switching between threads and processes is a single word change:

from concurrent.futures import ProcessPoolExecutor

with ProcessPoolExecutor() as ex:
    results = list(ex.map(process_image, filenames))

That default pool size is the CPU count, the context manager waits for completion and shuts down cleanly, and map returns results in input order.

Before reaching for either, check whether the work is worth parallelising at all. Process startup costs real time, so parallelising a task that takes 50 milliseconds makes it slower. Measure the sequential version first — the numbers at the top of this article exist because they were measured, not assumed.

See process vs thread for the operating-system level distinction.

Frequently Asked Questions

What is the GIL? The global interpreter lock in CPython, which allows only one thread to execute Python bytecode at a time. It prevents threads from parallelising CPU-bound work, though it is released during input and output.
When should I use threads instead of processes? When the work waits rather than computes — network requests, file and database access. Threads are lighter and share memory, and the GIL is released while waiting.
Why does my multiprocessing code spawn endlessly on Windows? You are missing the if __name__ == '__main__' guard. Windows starts children by importing your module, so without it the child re-runs your top-level code and spawns more children.
Why is multiprocessing slower for my small task? Starting processes and pickling data across boundaries costs real time. For short tasks or large arguments, that overhead can exceed any parallel gain.
Does NumPy suffer from the GIL? Less so. NumPy releases the GIL inside its compiled routines, so threaded numeric work can genuinely parallelise. Measure before assuming processes are required.