Advanced
12 min read
#Python#Threading#Multiprocessing#GIL#Concurrency

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.

ConsequenceExplanation
Threads don't speed up CPU-bound workOnly one thread runs Python code at any instant
Threads DO help I/O-bound workThe GIL is released during I/O waits (network, disk, time.sleep)
True parallelism needs multiprocessingSeparate processes each get their own Python interpreter and GIL
🐍 Python
import 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#

ToolBest ForWhy
asyncioMany I/O-bound tasks (network calls, APIs)Lightweight, single-threaded, huge scalability (thousands of tasks)
threadingA handful of I/O-bound tasks, or integrating with blocking librariesSimpler mental model than async; GIL released during I/O
multiprocessingCPU-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#

🐍 Python
import 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.

🐍 Python
import 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 counter before either writes back its increment.

4.3 ThreadPoolExecutor — A Cleaner API#

🐍 Python
from 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#

🐍 Python
from 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#

🐍 Python
from 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.

🐍 Python
from 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#

🐍 Python
import 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())
ScenarioBest Tool
Downloading 5 filesthreading or asyncio
Downloading 5,000 filesasyncio (far less overhead per task)
Crunching numbers on a large array across 4 coresmultiprocessing
Calling a blocking third-party library you can't rewrite as asyncthreading (or asyncio.to_thread)

7. Common Pitfalls#

INCORRECT: Using Threads for CPU-Bound Work#

🐍 Python
# 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#

🐍 Python
processes = [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#

🐍 Python
shared_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#

🐍 Python
from 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 multiprocessing for CPU-bound work; use threading/asyncio for I/O-bound work.
  • Always guard multiprocessing code with if __name__ == "__main__":.
  • Use a Lock whenever multiple threads write to shared state.
  • Prefer ThreadPoolExecutor/ProcessPoolExecutor over raw Thread/Process for most tasks — cleaner API.
  • Remember processes don't share memory — use Queue or Manager to pass data between them.
  • For very high task counts (thousands), prefer asyncio over threads — lower per-task overhead.
Knowledge Checkpoint

Concurrency: Threading & Multiprocessing Checkpoint

Q1.What is the Python Global Interpreter Lock (GIL)?
AA mutex that prevents multiple native threads from executing CPython bytecode simultaneously on multiple CPU cores.
BA security firewall that blocks unauthorized network sockets.
CA mechanism that prevents infinite recursion in functions.
DA disk lock for file writing.
Q2.For heavy CPU-bound tasks (e.g. matrix multiplication, image transformations), which module should you use in standard CPython to utilize multiple CPU cores?
A`threading`
B`multiprocessing` (or `concurrent.futures.ProcessPoolExecutor`)
C`asyncio`
D`queue`
Q3.What synchronization primitive is used to protect shared mutable state across multiple threads from race conditions?
A`threading.Lock` (Mutex)
B`threading.Event`
C`asyncio.sleep`
D`sys.setswitchinterval`
Track Your Learning

Finished studying this notebook?

Mark this guide as completed to update your course progress roadmap.