The Story That Explains The GIL
Now imagine the audience thinks all 8 speakers are talking at once because the mic moves so fast. In reality, only one is ever speaking. Adding more speakers does not help — the microphone is the bottleneck.
That single microphone is Python's Global Interpreter Lock (GIL). The 8 speakers are your threads. No matter how many CPU cores you have, only one Python thread executes Python bytecode at a time.
The GIL is a mutex (mutual exclusion lock) inside CPython that protects access to Python objects. It prevents multiple native threads from executing Python bytecode simultaneously. This design keeps CPython simple and fast for single-threaded workloads, but it fundamentally limits CPU-bound multithreaded performance.
Python threads are real OS threads, but the GIL serialises their execution of Python code. Threads are still fantastic for I/O-bound work (network, disk, database) because the GIL is released during blocking calls. They are poor for CPU-bound work — for that, use processes.
What Exactly Is The GIL?
The Global Interpreter Lock is a single, process-wide mutex in the CPython interpreter. Every Python thread must acquire the GIL before it can execute a single bytecode instruction. When one thread holds it, all others are blocked at the Python level.
Every Python thread must funnel through the single GIL to reach the interpreter. No matter how many cores exist, only one thread ever runs Python bytecode at a time.
The GIL does not make Python "single-threaded". You can absolutely create many threads and they will run concurrently. The GIL simply prevents them from running Python bytecode in parallel. During I/O, sleeps, or calls into C extensions that release the GIL (NumPy, requests, etc.), other threads can make real progress.
Why Does The GIL Exist?
The GIL was introduced in the early 1990s to solve one problem: memory management safety. CPython uses reference counting for garbage collection. Without a global lock, two threads could increment or decrement a refcount at the same time, corrupting memory and crashing the interpreter.
Python 3.13 introduced an experimental free-threaded (no-GIL) build via PEP 703. It is available today but still opt-in and slightly slower for single-threaded workloads. For production code, assume the GIL exists and design accordingly — it will remain the default for years to come.
Behind the Scenes — Threads & PyThreadState
When you call threading.Thread(target=…).start(), Python does surprisingly
little. It creates a small C data structure called PyThreadState,
launches a native OS thread (a POSIX pthread on Linux/macOS, a Windows thread on Windows),
and hands it off. Thread scheduling itself is done entirely by the operating system —
Python has no thread scheduler of its own.
thread.start()The PyThreadState Structure
Each Python thread carries its own state — a stack frame, recursion depth, exception
info, tick counter, and tracing hooks. A global variable
_PyThreadState_Current always points to the state of whichever thread
is currently running.
/* Python/pystate.h — simplified */
typedef struct _ts {
struct _ts *next;
PyInterpreterState *interp;
struct _frame *frame; // current stack frame
int recursion_depth;
int tracing;
Py_tracefunc c_profilefunc;
Py_tracefunc c_tracefunc;
PyObject *curexc_type;
PyObject *curexc_value;
PyObject *curexc_traceback;
PyObject *dict; // thread-local storage
int tick_counter; // used by the check interval
int gilstate_counter;
PyObject *async_exc;
long thread_id;
} PyThreadState;
/* Points to currently executing thread's state */
PyThreadState *_PyThreadState_Current = NULL;
Because Python threads are real OS threads, they carry all the OS overhead (context switches, scheduling latency, cache misses). But because only one can hold the GIL, most of that parallelism is wasted. You pay for OS-level threads without getting OS-level parallelism.
Ticks, Check Intervals & The Modern Switch
How does Python decide when to release the GIL from a CPU-bound thread that never does I/O? It periodically performs a "check". The scheme changed dramatically between Python 2 and Python 3.2.
| Property | Value |
|---|---|
| Trigger | Every 100 bytecode ticks |
| Time-based? | No — count-based |
| Setting | sys.setcheckinterval() |
| Problem | 1 tick can take 6+ seconds if it's a C call like -1 in big_list |
| Multicore | Catastrophic — GIL battle across cores |
| Property | Value |
|---|---|
| Trigger | Every 5 ms of wall-clock time |
| Time-based? | Yes — real milliseconds |
| Setting | sys.setswitchinterval(0.005) |
| Problem | Much smoother — but GIL battle still exists |
| Multicore | Better fairness, still no true parallelism |
Python 2 counted bytecodes and could hang for seconds. Python 3.2+ uses wall-clock time, giving predictable 5 ms interleaving — but the multicore GIL battle remains.
What Is a "Tick"?
A tick loosely maps to one Python virtual-machine instruction. You can see them by
disassembling any function with dis:
>>> import dis
>>> def countdown(n):
... while n > 0:
... print(n)
... n -= 1
>>> dis.dis(countdown)
0 SETUP_LOOP 33 (to 36)
3 LOAD_FAST 0 (n) # <- Tick 1
6 LOAD_CONST 1 (0)
9 COMPARE_OP 4 (>)
12 JUMP_IF_FALSE 19 (to 34)
15 POP_TOP
16 LOAD_FAST 0 (n)
19 PRINT_ITEM
20 PRINT_NEWLINE # <- Tick 2
21 LOAD_FAST 0 (n)
24 LOAD_CONST 2 (1) # <- Tick 3
27 INPLACE_SUBTRACT
28 STORE_FAST 0 (n) # <- Tick 4
31 JUMP_ABSOLUTE 3
A single tick can hide a huge amount of work if it calls a C function.
In Python 2, -1 in range(100_000_000) counts as ONE tick and
can take 6+ seconds — during which no other thread runs
and even Ctrl-C is ignored. Python 3.2's time-based switch
interval largely fixed this specific issue.
The Check Itself — Simplified From ceval.c
/* Python/ceval.c — the heart of the interpreter loop */
if (--_Py_Ticker < 0) {
_Py_Ticker = _Py_CheckInterval;
if (things_to_do) {
if (Py_MakePendingCalls() < 0) { ... }
}
if (interpreter_lock) {
/* Give another thread a chance */
PyThread_release_lock(interpreter_lock);
/* Other threads may run now */
PyThread_acquire_lock(interpreter_lock, 1);
}
}
GIL Behavior — Cooperative I/O Release
For I/O-bound threads, the GIL is beautifully simple: a thread holds the GIL while running Python code, but releases it whenever it blocks for I/O. Any other ready thread immediately grabs the lock and runs.
While T1 waits on I/O, it releases the GIL and T2 gets to run its Python code. This "cooperative" pattern is why threads are excellent for I/O-heavy workloads.
socket.recv(), file.read(), or time.sleep() explicitly releases the GIL just before blocking. The kernel takes over.Because network and disk operations are 1000× slower than CPU work, the GIL is released far more than it's held during I/O-heavy workloads. This is why threads + Queue is still the best pattern for scraping, file processing, and network fanout in Python.
CPU-Bound vs I/O-Bound — When The GIL Bites
Same threading code, opposite results. The workload's nature — CPU vs I/O — decides whether threads help or hurt.
| Workload | Threads Help? |
|---|---|
| Prime number sieve | No |
| Image processing (pure Python) | No |
| Matrix multiply (pure Python) | No |
| Sorting large in-memory lists | No |
| JSON parsing millions of records | No |
| Solution | multiprocessing |
| Workload | Threads Help? |
|---|---|
| HTTP requests to 100 URLs | Yes — 10-50x |
| Reading many files | Yes |
| Database queries | Yes |
| Waiting for user input | Yes |
| Copying data over network | Yes |
| Solution | threading + Queue |
If your bottleneck is waiting (network, disk, database) → threads win.
If your bottleneck is computing (Python bytecode running hot) → processes win.
When in doubt, profile first with time.perf_counter() and check CPU vs wall time.
The Beazley Experiment — When 2 Threads Are SLOWER Than 1
This became one of the most influential Python performance talks ever given, and it exposed a truth most Python programmers still miss: the GIL is not just slow on multicore — it's catastrophically slow.
The famous inversion: adding a second core makes threaded Python slower, because two cores create constant GIL contention.
Recreating The Experiment
import time
from threading import Thread
def count(n):
while n > 0:
n -= 1
COUNT = 100_000_000
# --- Sequential ---
start = time.perf_counter()
count(COUNT)
count(COUNT)
print(f"Sequential : {time.perf_counter() - start:.1f}s")
# --- Two threads (each does half? no — each does full!) ---
start = time.perf_counter()
t1 = Thread(target=count, args=(COUNT,))
t2 = Thread(target=count, args=(COUNT,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Threaded : {time.perf_counter() - start:.1f}s")
This counter-intuitive result is the smoking gun for multicore GIL contention. With one core, only one thread can ever run — no wasted signaling. With two cores, the OS schedules both threads simultaneously and they enter a constant GIL battle: acquire, release, retry, acquire, release, retry — burning tens of thousands of system calls per second on lock contention.
The Multicore GIL Battle
On a single core, the OS runs one thread at a time — the GIL is barely contested. On multicore, the OS gleefully schedules multiple threads on different cores at the same time, and they immediately start fighting over the one interpreter lock.
T1 keeps winning because it's already scheduled and awake on Core 1. T2 wastes CPU cycles waking up on Core 2 only to find the lock is gone before it can grab it.
Beazley's Instrumented Trace — The GIL Battle Live
# t1, t2 = thread ids. Middle number = ticks remaining. Right = total checks.
t2 100 5392 ENTRY
t2 100 5392 ACQUIRE
t2 100 5393 RELEASE
t1 100 5393 ACQUIRE # thread switch — t1 wins
t2 100 5393 ENTRY
t2 27 5393 BUSY # t2 tries to run but GIL taken by t1
t1 100 5394 RELEASE
t1 100 5394 ENTRY # t1 releases and IMMEDIATELY re-enters
t1 100 5394 ACQUIRE # t1 reacquires before t2 can wake
t2 74 5394 RETRY # t2 wakes up, fails, goes back to sleep
t1 100 5395 RELEASE
t1 100 5395 ENTRY
t1 100 5395 ACQUIRE # t1 wins AGAIN
t2 83 5395 RETRY # t2 fails AGAIN
... # hundreds of failed retries before t2 gets a turn
Python wants to run single-threaded but delegates scheduling to the OS. The OS wants to use every available core and freely schedules threads simultaneously. These two goals are fundamentally incompatible. The result is the "GIL battle" — a fight the OS keeps starting and Python keeps losing.
Signaling Overhead — Where All The Time Goes
The GIL is not a simple mutex. On Unix it's implemented as either a POSIX unnamed semaphore or a pthreads condition variable. Every acquire/release involves signaling — and signaling costs system calls.
| Scenario | Unix syscalls | Mach syscalls | Comment |
|---|---|---|---|
| Sequential (OS-X, 1 CPU) | 736 | 117 | Baseline — no threading overhead |
| 2 CPU-bound threads (1 CPU) | 1,149 | ~3,300,000 | 3.3 million lock signals! |
| 2 CPU-bound threads (2 CPUs) | 1,149 | ~9,500,000 | Nearly 3× worse on multicore |
Every 5 ms the interpreter locks a mutex, signals a condition variable where another thread is always waiting, triggers a pthreads/kernel round-trip to deliver the signal, and then reacquires the lock. Multiplied by 200 checks per second, per core, this is where your CPU cycles vanish.
Priority Inversion — When CPU Threads Starve I/O Threads
Here's the truly ugly one. Imagine a mixed workload: one CPU-bound worker crunching numbers, one I/O-bound worker sitting on a socket waiting for a packet. The packet arrives — the I/O thread wants to run right now — but it can't get the GIL.
The OS thinks the I/O thread is urgent. The GIL doesn't care about OS priority. A low-priority CPU thread blocks a high-priority I/O thread — the textbook definition of priority inversion.
The OS thinks the I/O thread is high priority. The GIL doesn't care about OS priority. A low-priority CPU thread ends up blocking a high-priority I/O thread — a textbook priority inversion caused entirely by the interpreter lock. This is why mixing heavy CPU work with I/O in the same Python process is a bad idea.
Signals, Ctrl-C & Frozen Threaded Programs
kill -9 it. Extremely annoying —
and it's a direct consequence of how signals interact with the GIL.
thread.join() or a Lock, it will never get scheduled to handle the signal.
Safe Pattern — Interruptible Threaded Program
import threading
import time
stop_event = threading.Event()
def worker():
while not stop_event.is_set():
# do real work, but check stop_event periodically
time.sleep(0.1)
print("Worker exiting cleanly.")
t = threading.Thread(target=worker)
t.start()
try:
# Main thread stays responsive to signals
while t.is_alive():
t.join(timeout=0.5) # timeout lets main thread wake to check signals
except KeyboardInterrupt:
print("\nCtrl-C received — asking worker to stop")
stop_event.set()
t.join()
Never call t.join() with no timeout in the main thread if you want
Ctrl-C to work. Use a short timeout so the main thread periodically
wakes and can process pending signals. Pair it with a
threading.Event to signal graceful shutdown to workers.
Proving The GIL — A CPU-Bound Benchmark
import time
import threading
from multiprocessing import Process
# A pure Python CPU-bound task
def count_down(n):
while n > 0:
n -= 1
COUNT = 50_000_000
# --- 1. Single thread (baseline) ---
start = time.perf_counter()
count_down(COUNT)
print(f"Single thread: {time.perf_counter() - start:.2f}s")
# --- 2. Two threads (splitting the work) ---
start = time.perf_counter()
t1 = threading.Thread(target=count_down, args=(COUNT // 2,))
t2 = threading.Thread(target=count_down, args=(COUNT // 2,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Two threads: {time.perf_counter() - start:.2f}s")
# --- 3. Two processes (bypasses the GIL) ---
start = time.perf_counter()
p1 = Process(target=count_down, args=(COUNT // 2,))
p2 = Process(target=count_down, args=(COUNT // 2,))
p1.start(); p2.start()
p1.join(); p2.join()
print(f"Two processes: {time.perf_counter() - start:.2f}s")
Threads Shine For I/O — Same Benchmark, Different Workload
import time
import threading
import requests
URLS = ["https://httpbin.org/delay/1"] * 10
def fetch(url):
requests.get(url)
# --- Sequential ---
start = time.perf_counter()
for url in URLS:
fetch(url)
print(f"Sequential: {time.perf_counter() - start:.2f}s")
# --- 10 threads in parallel ---
start = time.perf_counter()
threads = [threading.Thread(target=fetch, args=(u,)) for u in URLS]
for t in threads: t.start()
for t in threads: t.join()
print(f"10 threads: {time.perf_counter() - start:.2f}s")
During requests.get(), the underlying socket call releases the GIL.
Other threads immediately grab it and fire their requests. All 10 network calls
happen in parallel, and the total time collapses to roughly the time of one call.
This is exactly why threads paired with a Queue are perfect for I/O work.
Enter The Queue — Thread-Safe Communication
queue.QueueProducers drop items into the queue with put(); consumers pull with get(). All locking is internal to the queue.
Python's
queue.Queue is that rail — a thread-safe, FIFO buffer
designed to hand work between producer and consumer threads without you needing to
write a single Lock statement.
(priority, data). Lowest priority number is served first.
Perfect for task schedulers where urgent items should jump the line.
Python lists are not thread-safe for all operations. Two threads
calling list.append() may work today but list.pop(0) plus
an index-based read from another thread is a race condition waiting to happen.
queue.Queue uses internal locks and condition variables so
put(), get(), and task_done() are always
atomic. You never need to write your own locking around it.
Queue API — The Essential Methods
| Method | What It Does | Blocking Behaviour |
|---|---|---|
q.put(item) | Add item to the queue | Blocks if queue is full (bounded queue) |
q.get() | Remove and return next item | Blocks forever if queue is empty |
q.put(item, timeout=5) | Try to add with a deadline | Raises queue.Full after 5s |
q.get(timeout=5) | Try to fetch with a deadline | Raises queue.Empty after 5s |
q.put_nowait(item) | Non-blocking add | Raises queue.Full immediately |
q.get_nowait() | Non-blocking fetch | Raises queue.Empty immediately |
q.task_done() | Signal that a fetched item is fully processed | Pairs with q.join() |
q.join() | Wait until every put item has had task_done called | Blocks until queue is fully drained |
q.qsize() | Approximate current size | Only an estimate — do not rely on it for logic |
Your First Producer-Consumer With Queue
import threading
import queue
import time
import random
# A thread-safe, bounded FIFO queue holding at most 5 items
q = queue.Queue(maxsize=5)
def producer(name):
for i in range(10):
item = f"{name}-item-{i}"
q.put(item) # blocks if queue is full
print(f"[P {name}] produced {item} (queue size={q.qsize()})")
time.sleep(random.uniform(0.05, 0.2))
def consumer(name):
while True:
item = q.get() # blocks if queue is empty
if item is None: # poison pill = shutdown signal
q.task_done()
break
print(f" [C {name}] consumed {item}")
time.sleep(random.uniform(0.1, 0.3))
q.task_done() # signal completion
# Start 2 producers and 3 consumers
producers = [threading.Thread(target=producer, args=(f"P{i}",)) for i in range(2)]
consumers = [threading.Thread(target=consumer, args=(f"C{i}",)) for i in range(3)]
for t in producers + consumers:
t.start()
# Wait for producers to finish
for t in producers: t.join()
# Wait until all produced items are processed
q.join()
# Send poison pills to shut consumers down cleanly
for _ in consumers:
q.put(None)
for t in consumers: t.join()
print("All work complete.")
Consumers loop forever on q.get(). To stop them cleanly, put one
None per consumer into the queue after the real work is done.
When a consumer sees None, it exits. Simpler and safer than
forcefully killing threads.
Real-World Example — Concurrent Web Scraper
Let's build something practical: a scraper that downloads 50 URLs using 10 worker threads. A queue holds the URLs, workers pull and process them, and results go into a second queue.
import threading
import queue
import requests
import time
URL_LIST = [f"https://httpbin.org/delay/1?id={i}" for i in range(50)]
NUM_WORKERS = 10
task_queue = queue.Queue()
result_queue = queue.Queue()
def worker(worker_id):
while True:
url = task_queue.get()
if url is None: # shutdown
task_queue.task_done()
break
try:
r = requests.get(url, timeout=10)
result_queue.put((url, r.status_code, len(r.content)))
print(f"[W{worker_id}] {url} -> {r.status_code}")
except Exception as e:
result_queue.put((url, "ERROR", str(e)))
finally:
task_queue.task_done()
# Feed the queue
for url in URL_LIST:
task_queue.put(url)
# Start workers
start = time.perf_counter()
workers = [threading.Thread(target=worker, args=(i,), daemon=True)
for i in range(NUM_WORKERS)]
for w in workers: w.start()
# Wait for all URLs to be processed
task_queue.join()
# Shutdown workers
for _ in workers:
task_queue.put(None)
for w in workers: w.join()
elapsed = time.perf_counter() - start
# Drain results
results = []
while not result_queue.empty():
results.append(result_queue.get())
print(f"\nFetched {len(results)} URLs in {elapsed:.2f}s using {NUM_WORKERS} threads")
print(f"Sequential would take ~{len(URL_LIST)}s")
PriorityQueue — When Some Tasks Matter More
A support-ticket system where paying customers jump the queue. The priority number goes first in the tuple — lowest number wins.
Insert in any order; the queue always yields the highest-priority item next. Perfect for task schedulers, alerts, and tiered SLA processing.
import threading
import queue
import time
from dataclasses import dataclass, field
from typing import Any
# Wrap payload so equal-priority items don't try to compare data
@dataclass(order=True)
class Ticket:
priority: int
data: Any = field(compare=False)
pq = queue.PriorityQueue()
# Producers add tickets with varied priority (1=urgent, 5=low)
pq.put(Ticket(3, "Fix typo on homepage"))
pq.put(Ticket(1, "PROD DOWN — payment gateway failing!"))
pq.put(Ticket(5, "Update the copyright year"))
pq.put(Ticket(2, "Enterprise customer login broken"))
pq.put(Ticket(1, "Data breach alert!"))
def handler():
while not pq.empty():
ticket = pq.get()
print(f"[P{ticket.priority}] {ticket.data}")
pq.task_done()
time.sleep(0.2)
threading.Thread(target=handler).start()
If two items share a priority, PriorityQueue tries to compare the
next tuple element (usually your data). If your data is an unhashable/unorderable
object, you get a TypeError. Use a @dataclass(order=True)
with field(compare=False), or include a monotonic counter as the
tie-breaker: (priority, counter, data).
The Modern Way — ThreadPoolExecutor
concurrent.futures.ThreadPoolExecutor gives you a queue-backed thread pool
with a clean API. It manages the queue, worker threads, and result collection for you.
For most new code, this is what you should reach for.
Under the hood, ThreadPoolExecutor is exactly a queue plus a worker pool — you just don't have to wire it up yourself.
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
import time
urls = [f"https://httpbin.org/delay/1?id={i}" for i in range(20)]
def fetch(url):
r = requests.get(url, timeout=10)
return url, r.status_code, len(r.content)
start = time.perf_counter()
# The executor manages an internal queue + worker threads
with ThreadPoolExecutor(max_workers=10) as pool:
# submit() returns a Future — put a task on the queue
futures = {pool.submit(fetch, u): u for u in urls}
# as_completed yields futures as they finish
for future in as_completed(futures):
url, code, size = future.result()
print(f"{code} {size:6d}B {url}")
print(f"\nDone in {time.perf_counter() - start:.2f}s")
Use ThreadPoolExecutor for one-shot batch jobs: submit N tasks, collect N results, done. Use Queue directly when you have long-running streaming pipelines, priority handling, or complex producer-consumer topologies (e.g. a scraper that queues newly-discovered URLs while workers process them).
Pipeline Pattern — Chained Queues
Real systems often have multiple stages. Example: download → parse → save. Chain queues between stages and each stage runs in its own thread pool.
Data flows left → right through queues. Slower stages get more workers; serial stages get exactly one to preserve order.
import threading
import queue
import time
download_q = queue.Queue()
parse_q = queue.Queue()
save_q = queue.Queue()
def downloader():
while True:
url = download_q.get()
if url is None: break
time.sleep(0.3) # simulated network
html = f"<html>from {url}</html>"
parse_q.put((url, html))
download_q.task_done()
def parser():
while True:
item = parse_q.get()
if item is None: break
url, html = item
time.sleep(0.1) # simulated parsing
record = {"url": url, "len": len(html)}
save_q.put(record)
parse_q.task_done()
def saver():
while True:
rec = save_q.get()
if rec is None: break
print(f"SAVED {rec}")
save_q.task_done()
# Start each stage as a thread pool
threads = []
for _ in range(4): threads.append(threading.Thread(target=downloader))
for _ in range(2): threads.append(threading.Thread(target=parser))
for _ in range(1): threads.append(threading.Thread(target=saver))
for t in threads: t.start()
# Feed URLs
for i in range(20):
download_q.put(f"https://example.com/page{i}")
# Wait for each stage to fully drain, then send poison pills
download_q.join()
for _ in range(4): download_q.put(None)
parse_q.join()
for _ in range(2): parse_q.put(None)
save_q.join()
save_q.put(None)
for t in threads: t.join()
print("Pipeline complete.")
Notice we used 4 downloaders, 2 parsers, and 1 saver. The slow I/O stage (downloading) gets the most workers. The saver is a single thread so writes to a file or database happen in order without contention. Match worker count to the bottleneck of each stage.
Bypassing The GIL — Your Options
multiprocessing.Queue or Pool.map for
CPU-bound work.
np.dot() on large arrays uses all cores.
Common Pitfalls And Fixes
task_done() → q.join() hangs forever. Every get() must be paired with a task_done(), even on exceptions. Use try/finally.
None) per consumer thread.
Queue(maxsize=1000) so producers block naturally under backpressure.
Lock. Use the queue for communication, not shared globals.
daemon=True for background workers OR call join() on all threads.
try/except and log or push errors to a dedicated error queue.
t.join() with no timeout. Always use t.join(timeout=0.5) in a loop so signals can be processed.
Every production worker should look like this: fetch from queue, check for poison
pill, wrap real work in try/except, always call task_done() in a
finally block. This eliminates 90% of threading bugs.
def safe_worker(work_q, error_q):
while True:
item = work_q.get()
try:
if item is None: # poison pill
break
process(item) # your real work
except Exception as e:
error_q.put((item, repr(e))) # never let exceptions vanish
finally:
work_q.task_done() # ALWAYS signal completion
Threads vs Processes vs Asyncio — Decision Table
| Property | threading + Queue | multiprocessing | asyncio |
|---|---|---|---|
| Best for | I/O-bound (up to ~100 workers) | CPU-bound | I/O-bound at massive scale (1000+) |
| Bypasses GIL? | No — but releases during I/O | Yes — each process has its own | No — single-threaded |
| Memory sharing | Free — shared address space | Expensive — pickling required | Free — same event loop |
| Overhead per unit | ~8 MB per thread | ~30 MB per process | ~few KB per coroutine |
| Startup cost | Fast (~ms) | Slow (~100ms fork/spawn) | Instant |
| Existing sync code works | Yes — unchanged | Yes — unchanged | No — needs async rewrite |
| Debugging difficulty | Medium (race conditions) | Medium (IPC issues) | High (cooperative scheduling) |
Golden Rules
multiprocessing or a
C-extension (NumPy) instead.
queue.Queue, never through
shared mutable objects. The queue's internal locks eliminate almost every race
condition.
q.get() with a q.task_done() in a
finally block. Without it, q.join() hangs forever if any
worker throws an exception.
maxsize on queues used in long-running pipelines.
An unbounded queue is a memory leak waiting to happen when producers outrun consumers.
None per
worker after your real work is done. Never rely on daemon=True alone
for clean shutdown of stateful workers.
t.join(timeout=0.5) in a loop instead of a bare t.join(),
so Ctrl-C actually works.
concurrent.futures.ThreadPoolExecutor over
hand-rolled thread+queue code. It manages the queue, the pool, and result collection
in a few lines.
try/except and route errors to a dedicated error queue or logger.
Silent thread deaths are the hardest bugs to find.
min(32, os.cpu_count() * 4). More is not always better — connection
pool limits and remote rate limits usually cap the useful concurrency.