Concurrency: Threading & Multiprocessing — The Complete Notebook
Comprehensive guide on Concurrency: Threading & Multiprocessing — The Complete Notebook.
Concurrency: Threading & Multiprocessing
1. Overview#
This note pairs with async-python.md. Where asyncio handles concurrency on a single thread (great for I/O-bound work), threading and multiprocessing are Python's other two concurrency tools — and each solves a different problem, largely because of a Python-specific constraint called the GIL.
2. The GIL (Global Interpreter Lock)#
CPython (the standard Python implementation) has a lock that allows only one thread to execute Python bytecode at a time, even on a multi-core machine.
| Consequence | Explanation |
|---|---|
| Threads don't speed up CPU-bound work | Only one thread runs Python code at any instant |
| Threads DO help I/O-bound work | The GIL is released during I/O waits (network, disk, time.sleep) |
True parallelism needs multiprocessing | Separate processes each get their own Python interpreter and GIL |
🐍 PythonInteractive WebAssemblyimport time
from threading import Thread
def cpu_heavy_task():
total = 0
for i in range(50_000_000):
total += i
return total
start = time.perf_counter()
t1 = Thread(target=cpu_heavy_task)
t2 = Thread(target=cpu_heavy_task)
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Threaded: {time.perf_counter() - start:.2f}s")
# Not meaningfully faster than running both sequentially — GIL blocks true parallel CPU work
3. When to Use What#
| Tool | Best For | Why |
|---|---|---|
asyncio | Many I/O-bound tasks (network calls, APIs) | Lightweight, single-threaded, huge scalability (thousands of tasks) |
threading | A handful of I/O-bound tasks, or integrating with blocking libraries | Simpler mental model than async; GIL released during I/O |
multiprocessing | CPU-bound work (data processing, ML inference, image processing) | Each process bypasses the GIL entirely, using separate cores |
4. threading in Practice#
4.1 Basic Threads#
🐍 PythonInteractive WebAssemblyimport threading
import time
def download_file(name, delay):
print(f"Starting download: {name}")
time.sleep(delay) # simulates network I/O — GIL is released here
print(f"Finished download: {name}")
threads = []
for name, delay in [("file1", 2), ("file2", 2), ("file3", 2)]:
t = threading.Thread(target=download_file, args=(name, delay))
threads.append(t)
t.start()
for t in threads:
t.join() # wait for all threads to finish
print("All downloads complete")
# Total time: ~2 seconds, not 6 — threads overlap during the I/O wait
4.2 Protecting Shared State with Lock#
Multiple threads modifying the same variable can cause a race condition — a Lock ensures only one thread touches it at a time.
🐍 PythonInteractive WebAssemblyimport threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100_000):
with lock: # only one thread can hold the lock at a time
counter += 1
threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(counter) # 400000 — correct, thanks to the lock
Without the lock, this same code would produce an unpredictable, usually-too-low number — two threads can read the same value of
counterbefore either writes back its increment.
4.3 ThreadPoolExecutor — A Cleaner API#
🐍 PythonInteractive WebAssemblyfrom concurrent.futures import ThreadPoolExecutor
import time
def fetch_url(url):
time.sleep(1) # simulate network call
return f"Data from {url}"
urls = [f"https://api.example.com/{i}" for i in range(5)]
with ThreadPoolExecutor(max_workers=3) as executor:
results = list(executor.map(fetch_url, urls))
print(results)
5. multiprocessing in Practice#
5.1 Basic Processes#
🐍 PythonInteractive WebAssemblyfrom multiprocessing import Process
import time
def cpu_heavy_task(n):
total = sum(i * i for i in range(n))
return total
if __name__ == "__main__": # required on Windows/macOS for multiprocessing
start = time.perf_counter()
processes = [Process(target=cpu_heavy_task, args=(20_000_000,)) for _ in range(4)]
for p in processes:
p.start()
for p in processes:
p.join()
print(f"Multiprocessing: {time.perf_counter() - start:.2f}s")
# Genuinely faster on a multi-core machine — each process runs on its own core
5.2 ProcessPoolExecutor — The Practical Way#
🐍 PythonInteractive WebAssemblyfrom concurrent.futures import ProcessPoolExecutor
def square(n):
return n * n
if __name__ == "__main__":
numbers = list(range(10))
with ProcessPoolExecutor(max_workers=4) as executor:
results = list(executor.map(square, numbers))
print(results)
5.3 Sharing Data Between Processes#
Unlike threads, processes don't share memory by default — each has its own copy. Sharing data requires explicit tools.
🐍 PythonInteractive WebAssemblyfrom multiprocessing import Process, Queue
def worker(queue, n):
queue.put(n * n)
if __name__ == "__main__":
queue = Queue()
processes = [Process(target=worker, args=(queue, i)) for i in range(5)]
for p in processes:
p.start()
for p in processes:
p.join()
results = [queue.get() for _ in range(5)]
print(results)
6. Comparing All Three Tools Side by Side#
🐍 PythonInteractive WebAssemblyimport time
def io_task():
time.sleep(1) # simulated I/O
# Sequential — ~5 seconds for 5 tasks
for _ in range(5):
io_task()
# Threading — ~1 second, threads overlap during the sleep
from threading import Thread
threads = [Thread(target=io_task) for _ in range(5)]
[t.start() for t in threads]
[t.join() for t in threads]
# asyncio — ~1 second, and scales to thousands of tasks with less overhead than threads
import asyncio
async def async_io_task():
await asyncio.sleep(1)
async def main():
await asyncio.gather(*(async_io_task() for _ in range(5)))
asyncio.run(main())
| Scenario | Best Tool |
|---|---|
| Downloading 5 files | threading or asyncio |
| Downloading 5,000 files | asyncio (far less overhead per task) |
| Crunching numbers on a large array across 4 cores | multiprocessing |
| Calling a blocking third-party library you can't rewrite as async | threading (or asyncio.to_thread) |
7. Common Pitfalls#
INCORRECT: Using Threads for CPU-Bound Work#
🐍 PythonInteractive WebAssembly# Threads won't speed this up — the GIL serializes Python bytecode execution
threads = [threading.Thread(target=cpu_heavy_task) for _ in range(4)]
CORRECT: Fix — Use Multiprocessing Instead#
🐍 PythonInteractive WebAssemblyprocesses = [Process(target=cpu_heavy_task) for _ in range(4)]
INCORRECT: Forgetting if __name__ == "__main__": with Multiprocessing#
On Windows and macOS, this causes each spawned process to re-import and re-execute the whole script, potentially spawning infinite processes.
INCORRECT: Sharing Mutable State Across Processes Without a Queue/Manager#
🐍 PythonInteractive WebAssemblyshared_list = []
def worker():
shared_list.append(1) # each process gets its OWN copy — this won't work as expected
CORRECT: Fix — Use multiprocessing.Queue or multiprocessing.Manager#
🐍 PythonInteractive WebAssemblyfrom multiprocessing import Manager
with Manager() as manager:
shared_list = manager.list() # a list proxy that IS shared across processes
8. Summary & Best Practices Checklist#
- Use
multiprocessingfor CPU-bound work; usethreading/asynciofor I/O-bound work. - Always guard multiprocessing code with
if __name__ == "__main__":. - Use a
Lockwhenever multiple threads write to shared state. - Prefer
ThreadPoolExecutor/ProcessPoolExecutorover rawThread/Processfor most tasks — cleaner API. - Remember processes don't share memory — use
QueueorManagerto pass data between them. - For very high task counts (thousands), prefer
asyncioover threads — lower per-task overhead.
Concurrency: Threading & Multiprocessing Checkpoint
Finished studying this notebook?
Mark this guide as completed to update your course progress roadmap.