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
Last updated: 24 September 2026
"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).
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:
CPU, sequential 4.1s
CPU, 4 threads 4.0s
CPU, 4 processes 1.3s
I/O, sequential 4.0s
I/O, 8 threads 0.5sYour 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
| Threads | Processes | |
|---|---|---|
| Speeds up | Waiting: network, disk, database | Pure-Python computation |
| Start-up cost | Small | Larger: a new interpreter per worker |
| Memory | Shared | Each process has its own copy of what it needs |
| Passing data | Direct: same objects | Pickled and copied between processes |
| Shared state | Easy to share, easy to corrupt: needs locks | Isolated by default: share explicitly |
| Failure | An unhandled crash affects the whole program | A 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):
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)400000Better 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 time | Use |
|---|---|
| Waiting on many network or database calls, with synchronous libraries | ThreadPoolExecutor |
| Waiting on very many connections, with async-capable libraries | asyncio; see asyncio explained |
| Computing in pure Python on independent chunks | ProcessPoolExecutor |
| Computing inside NumPy or pandas | Vectorise first; see the NumPy guide and pandas performance |
| A mix: download many files, then parse each | Threads 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?
Do threads help with pandas?
Is multiprocessing always faster for CPU work?
Threading basics, multiprocessing and parallel execution are covered in the concurrency module of the Python Programming course.
Sources
- concurrent.futures β Launching parallel tasks β Python documentation
- Glossary: global interpreter lock β Python documentation
- multiprocessing β Process-based parallelism β Python documentation
- threading β Thread-based parallelism β Python documentation
- PEP 703 β Making the Global Interpreter Lock Optional in CPython β Python Enhancement Proposals
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.
Related articles
Learn Python from Scratch: A Complete 2026 Roadmap
How to learn Python from zero: install a current version (including the new Windows install manager), then work through seven stages, from values and loops to files, virtual environments, classes and tests, each ending with a small program you should be able to write on your own.
Python Interview Questions: Top 50 with Answers
Fifty conceptual Python interview questions with short, precise answers, grouped by topic: basics, types and mutability, data structures, functions and scope, OOP, iterators and decorators, errors and files, and internals, with runnable examples for the ones people most often get wrong.
Python for Data Science: The Essential Libraries
The Python data science stack explained by the job each library does: NumPy for arrays, pandas for tables, Matplotlib and seaborn for charts, SciPy and statsmodels for statistics, scikit-learn for models and Jupyter for exploration, with one runnable example that passes a real dataset through them.