Multithreading vs Multiprocessing in Python

Threads or processes? A timed experiment with concurrent.futures shows the rule the global interpreter lock creates: threads help while programs wait, processes help while they compute. Then the costs of each, shared state and locks, the Windows and macOS start-up rule, and a decision table that includes asyncio.

GloryTecks Team

Training & Placement Faculty

6 May 2026 6 min read

Last updated: 24 September 2026

Share:

"Use threads to make it faster" is half right. In Python, whether threads help depends entirely on what your program spends its time doing: computing, or waiting. Processes have the opposite profile. Rather than start with theory, here is an experiment you can run, followed by the one rule it demonstrates and the practical details that decide real designs.

The experiment

Two kinds of work. count_primes is pure computation: it keeps a CPU core busy. fake_download spends almost all of its time waiting, which is what network calls, database queries and disk reads do. Each is run sequentially, with a thread pool and, for the CPU task, with a process pool from concurrent.futures, the standard library's high-level interface to both (Python docs: concurrent.futures).

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

def count_primes(limit: int) -> int:
    """CPU-bound: pure Python arithmetic, no waiting."""
    return sum(1 for n in range(2, limit) if all(n % d for d in range(2, math.isqrt(n) + 1)))

def fake_download(url: str) -> int:
    """I/O-bound: most of the time is spent waiting."""
    time.sleep(0.5)
    return len(url)

def timed(label, fn):
    start = time.perf_counter()
    fn()
    print(f"{label:<22}{time.perf_counter() - start:5.1f}s")

if __name__ == "__main__":
    jobs = [400_000] * 4
    timed("CPU, sequential", lambda: [count_primes(j) for j in jobs])
    with ThreadPoolExecutor(max_workers=4) as pool:
        timed("CPU, 4 threads", lambda: list(pool.map(count_primes, jobs)))
    with ProcessPoolExecutor(max_workers=4) as pool:
        timed("CPU, 4 processes", lambda: list(pool.map(count_primes, jobs)))

    urls = [f"https://example.com/file{i}" for i in range(8)]
    timed("I/O, sequential", lambda: [fake_download(u) for u in urls])
    with ThreadPoolExecutor(max_workers=8) as pool:
        timed("I/O, 8 threads", lambda: list(pool.map(fake_download, urls)))

On a Windows laptop with Python 3.11, one run printed:

text
CPU, sequential         4.1s
CPU, 4 threads          4.0s
CPU, 4 processes        1.3s
I/O, sequential         4.0s
I/O, 8 threads          0.5s

Your absolute numbers will differ with your hardware, but the pattern will not:

  • Four threads did nothing for the computation. It took as long as doing the jobs one after another.
  • Four processes cut the computation to about a third. Not a quarter, because starting processes and passing data to them has a cost.
  • Eight threads cut eight half-second waits from 4 seconds to 0.5. All the waits overlapped.

The rule behind it: the GIL

CPython, the standard Python interpreter, has a global interpreter lock: only one thread can execute Python bytecode at any moment (Python glossary: GIL). Four threads doing arithmetic therefore take turns on one core. But a thread that is waiting, in time.sleep, a socket read or a file read, releases the lock, so other threads run during the wait. Hence the rule:

Threads speed up waiting. Processes speed up computing.

Each process has its own interpreter and its own GIL, so four processes really do run on four cores. Many libraries also release the GIL inside their own compiled code. Large NumPy operations, for example, can run in parallel across threads, because the heavy lifting happens outside the Python interpreter.

The GIL is not permanent. Python 3.13 introduced an experimental free-threaded build of CPython without it (PEP 703), but the standard builds most people install still have it, and much of the library ecosystem is still adapting.

What each option costs

ThreadsProcesses
Speeds upWaiting: network, disk, databasePure-Python computation
Start-up costSmallLarger: a new interpreter per worker
MemorySharedEach process has its own copy of what it needs
Passing dataDirect: same objectsPickled and copied between processes
Shared stateEasy to share, easy to corrupt: needs locksIsolated by default: share explicitly
FailureAn unhandled crash affects the whole programA crashed worker can be detected by the pool

Because process pools pickle the function and its arguments to send them to workers, both must be picklable: functions defined at the top level of a module, and data such as numbers, strings, lists and dictionaries. Lambdas and open file handles cannot be sent. And sending a large DataFrame to each of eight workers means eight copies; pass file paths or row ranges instead and let each worker load its own slice.

The Windows and macOS rule: guard the entry point

On Windows and macOS, new processes start by importing your main module afresh (the "spawn" start method). Without the if __name__ == "__main__": guard, each worker would run the top-level code again, including creating its own pool. The multiprocessing documentation requires the guard for exactly this reason (Python docs: multiprocessing). It is why the experiment above keeps everything that starts work under the guard. In Jupyter notebooks on these systems, the worker function often has to live in a separate .py file that the notebook imports.

Shared state in threads: use a lock

Threads share memory, so two threads can update the same object at the same time. An operation such as counter += 1 is several steps (read, add, write), and a switch between threads in the middle can lose an update. A threading.Lock makes the update exclusive (Python docs: threading):

python
import threading

counter = 0
lock = threading.Lock()

def add_many(n: int) -> None:
    global counter
    for _ in range(n):
        with lock:
            counter += 1

threads = [threading.Thread(target=add_many, args=(100_000,)) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(counter)
text
400000

Better still, avoid sharing: have each worker return its result, and combine the results in the main thread, as pool.map does. Code built that way needs no locks.

Choosing

Your program spends its timeUse
Waiting on many network or database calls, with synchronous librariesThreadPoolExecutor
Waiting on very many connections, with async-capable librariesasyncio; see asyncio explained
Computing in pure Python on independent chunksProcessPoolExecutor
Computing inside NumPy or pandasVectorise first; see the NumPy guide and pandas performance
A mix: download many files, then parse eachThreads for the downloads, processes for the parsing

Before reaching for either, measure where the time actually goes. A program that is slow because of one inefficient loop gains more from fixing the loop than from spreading it across cores. And start with the pools: ThreadPoolExecutor and ProcessPoolExecutor share one interface (submit, map), so switching between them to test a hypothesis is a one-word change.

How many workers should I use?

For processes, start with the number of CPU cores, which os.cpu_count() reports. For I/O-bound threads, more workers than cores is normal, because most are waiting; the limit is usually what the remote service will tolerate.

Do threads help with pandas?

Rarely for typical DataFrame code. Vectorised pandas operations already run in compiled code, and row-by-row Python code is limited by the GIL. Vectorising gives far bigger wins.

Is multiprocessing always faster for CPU work?

Only when each task is large enough to repay the cost of starting workers and copying data. Thousands of tiny tasks can be slower in a process pool; batch them into larger chunks, for example with the chunksize argument of map.

Threading basics, multiprocessing and parallel execution are covered in the concurrency module of the Python Programming course.

Sources

GloryTecks Team

Training & Placement Faculty

The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.

Want to master Python?

Join GloryTecks’ expert-led Python Programming training in Hyderabad with real-time projects and 100% placement support.

Explore course