← Back to Course Index
Lesson 17 of 20

Concurrency: Threading, Multiprocessing & the GIL

~20 min · GIL explained, threading, multiprocessing, concurrent.futures, when to use what

Ref
Primary Source
Official Docs — concurrent.futures

The high-level concurrency API — the right starting point. Also see the Real Python GIL guide for deep understanding.

1 — The GIL: Python's Biggest Gotcha

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.

⚠ The GIL means Python threading ≠ parallelism

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

2 — The Decision Tree

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
ToolBest forMemoryComplexity
asyncioI/O bound, high concurrencySharedMedium
threadingI/O bound, sync librariesSharedLow
multiprocessingCPU boundSeparateHigh

3 — concurrent.futures — The High-Level API

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))

4 — Future Objects & Error Handling

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}")

5 — Raw Threading (when you need it)

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
💡 daemon=True

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.

6 — Shared State — The Danger Zone

⚠ Shared mutable state across threads = race conditions

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

🧠 Quiz

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?

0/3

Questions answered correctly.