The high-level concurrency API — the right starting point. Also see the Real Python GIL guide for deep understanding.
The Global Interpreter Lock (GIL) is a mutex that ensures only one Python thread runs at a time, even on a multi-core CPU. This is the most important Python-specific concept for a JS developer to understand — it has no JS equivalent.
In JavaScript, there's one thread and one event loop — you never worry about multiple threads. Python has real threads (threading.Thread), but the GIL prevents them from running Python code simultaneously on multiple cores.
The GIL impact, summarised:
I/O-bound work (network, disk, database):
→ threading WORKS — threads release the GIL while waiting for I/O
→ asyncio WORKS (and is often preferred)
CPU-bound work (number crunching, parsing, compression):
→ threading does NOT help — GIL limits you to 1 core
→ multiprocessing WORKS — each process has its own GIL
→ numpy/pandas WORK — they release the GIL internally
What kind of work is it?
│
├── I/O bound (network, DB, files)?
│ ├── Are you already in an async codebase? → asyncio (Lesson 10)
│ └── Sync codebase / legacy library? → threading
│
└── CPU bound (heavy computation)?
├── Pure Python? → multiprocessing (ProcessPoolExecutor)
└── numpy/C extension? → often releases GIL — threading or asyncio fine
| Tool | Best for | Memory | Complexity |
|---|---|---|---|
asyncio | I/O bound, high concurrency | Shared | Medium |
threading | I/O bound, sync libraries | Shared | Low |
multiprocessing | CPU bound | Separate | High |
Don't use raw threading.Thread or multiprocessing.Process directly. Use concurrent.futures — it's the clean, high-level interface for both.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import requests
urls = [
"https://api.github.com/users/torvalds",
"https://api.github.com/users/gvanrossum",
"https://api.github.com/users/antirez",
]
def fetch(url: str) -> dict:
return requests.get(url, timeout=5).json()
# ThreadPoolExecutor — I/O bound (network)
with ThreadPoolExecutor(max_workers=5) as pool:
# map — submit all, collect results in order
results = list(pool.map(fetch, urls))
# submit — submit one at a time, collect when needed
futures = [pool.submit(fetch, url) for url in urls]
results = [f.result() for f in futures]
# ProcessPoolExecutor — CPU bound (computation)
import math
def compute(n: int) -> float:
return sum(math.sqrt(i) for i in range(n))
with ProcessPoolExecutor() as pool:
results = list(pool.map(compute, [10_000_000] * 4))
from concurrent.futures import ThreadPoolExecutor, as_completed
def risky_fetch(url: str) -> dict:
res = requests.get(url, timeout=2)
res.raise_for_status()
return res.json()
with ThreadPoolExecutor(max_workers=10) as pool:
future_to_url = {pool.submit(risky_fetch, url): url for url in urls}
# as_completed — process results as they finish (not in submission order)
for future in as_completed(future_to_url):
url = future_to_url[future]
try:
data = future.result() # raises if the function raised
print(f"✅ {url}: {data['name']}")
except Exception as e:
print(f"❌ {url} failed: {e}")
import threading
import time
# Thread-safe counter using a Lock
counter = 0
lock = threading.Lock()
def increment(n: int):
global counter
for _ in range(n):
with lock: # acquire/release automatically
counter += 1 # only one thread here at a time
threads = [threading.Thread(target=increment, args=(10_000,)) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join() # wait for all to finish
print(counter) # 100_000 ✅
# threading.Event — signal between threads
stop_event = threading.Event()
def worker():
while not stop_event.is_set():
do_work()
time.sleep(0.1)
t = threading.Thread(target=worker, daemon=True)
t.start()
time.sleep(5)
stop_event.set() # signal the worker to stop
A daemon thread dies automatically when the main program exits — unlike non-daemon threads which keep the process alive. Use daemon=True for background worker threads.
Unlike JavaScript's single-threaded model, Python threads genuinely run concurrently (for I/O). Modifying shared state without locks causes race conditions. Use threading.Lock, threading.Queue, or design stateless workers.
# Thread-safe communication with Queue
import queue, threading
task_queue = queue.Queue()
result_queue = queue.Queue()
def worker():
while True:
item = task_queue.get() # blocks until item available
if item is None: break # poison pill to stop
result = process(item)
result_queue.put(result)
task_queue.task_done()
# Start workers
threads = [threading.Thread(target=worker, daemon=True) for _ in range(4)]
for t in threads: t.start()
# Enqueue work
for item in data:
task_queue.put(item)
# Signal workers to stop
for _ in threads:
task_queue.put(None)
task_queue.join() # wait for all tasks to complete
1. You need to compress 8 large files simultaneously in Python. What's the right tool?
2. Why does ThreadPoolExecutor work well for network requests despite the GIL?
3. What does as_completed(futures) do differently from iterating futures directly?
Questions answered correctly.