Level 4 · Pythonic

Concurrency: Threads and Processes 🧵

Python's threading has a famous asterisk. Here is what it actually is, why it exists, and how to pick the right tool without folklore.

Two different problems

Kind of workWhat it isCalled
Downloading 100 web pagesMostly waiting for something elseI/O bound
Resizing 100 photosMostly using the CPUCPU bound

Nearly all confusion about Python concurrency comes from not asking this question first. The answer determines the tool, and using the wrong one makes your program slower, not faster.

The GIL, without the mythology

CPython has a Global Interpreter Lock: a single lock that means only one thread executes Python bytecode at a time. It exists because it makes the interpreter simpler and single-threaded code faster, and because CPython's memory management is not thread-safe without it.

The consequences, stated precisely:

ENCYCLOPEDIA[Formidable: Success]

PEP 703, accepted in 2023, lays out a path to making CPython work without the GIL, and 3.13 shipped it as an optional build. It is not the default: removing the lock costs single-threaded performance and requires every C extension in the ecosystem to be audited.

So 'Python cannot do threads' is folklore, and 'the GIL is about to disappear' is premature. Both statements are the kind of thing people repeat without checking.

Threads, where they shine

import time
from concurrent.futures import ThreadPoolExecutor


def fetch(page):
    """Pretend to download something: mostly waiting."""
    time.sleep(0.1)
    return f"page {page}: 200 OK"


start = time.perf_counter()
sequential = [fetch(n) for n in range(8)]
sequential_time = time.perf_counter() - start

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=8) as pool:
    threaded = list(pool.map(fetch, range(8)))
threaded_time = time.perf_counter() - start

print(sequential[0])
print(f"sequential took about 0.8s: {0.7 < sequential_time < 1.2}")
print(f"threaded took about 0.1s:   {threaded_time < 0.4}")
print(f"same results: {sequential == threaded}")
page 0: 200 OK
sequential took about 0.8s: True
threaded took about 0.1s:   True
same results: True

Eight tasks that each wait a tenth of a second: eight tenths sequentially, one tenth in parallel. The GIL is irrelevant here because none of these threads wants the CPU. This is most real-world concurrency: talking to APIs, databases and files.

Processes, for actual CPU work

from concurrent.futures import ProcessPoolExecutor


def count_primes(limit):
    """Deliberately CPU-heavy."""
    count = 0
    for n in range(2, limit):
        if all(n % d for d in range(2, int(n ** 0.5) + 1)):
            count += 1
    return count


if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=4) as pool:
        results = list(pool.map(count_primes, [20000, 20000, 20000, 20000]))
    print(results)

Each process is a separate Python interpreter with its own GIL, so they genuinely run on different cores. The costs are real: starting a process is slow (tens of milliseconds), and every argument and result must be pickled and copied between processes. Use them for chunky work, never for tiny tasks.

🪤 Processes need the __main__ guard

On Windows and macOS, child processes re-import your module to find the function they are running. Without if __name__ == "__main__":, every child re-runs your top-level code and spawns more children. It is a fork bomb written by accident, and everybody does it once.

Choosing

Your workUseWhy
Waiting on network or disk, tens of tasksThreadPoolExecutorSimple, shares memory, waiting is free
Waiting on network, thousands of tasksasyncio (Lesson 40)Threads cost about 8MB of stack each; coroutines cost bytes
Heavy CPU work on many coresProcessPoolExecutorReal parallelism, at the cost of copying
Heavy numeric arraysNumPy, and often nothing elseIt already releases the GIL and uses vector instructions
It is fast enough alreadyNothingConcurrency is a bug multiplier. Earn it first

The dangerous part: shared state

import threading

counter = 0


def increment_unsafe():
    global counter
    for _ in range(100_000):
        counter += 1          # read, add, write: three steps, interruptible


threads = [threading.Thread(target=increment_unsafe) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(f"expected 400000, got {counter}")
print(f"correct? {counter == 400_000}")

That program is a genuine race condition: counter += 1 is three operations, and a thread can be interrupted between them, losing an update. On modern CPython you will often get the right answer anyway, which is worse than always getting it wrong, because the bug only appears under load, in production, at 3am.

import threading

counter = 0
lock = threading.Lock()


def increment_safe():
    global counter
    for _ in range(100_000):
        with lock:            # only one thread inside at a time
            counter += 1


threads = [threading.Thread(target=increment_safe) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(f"expected 400000, got {counter}")
expected 400000, got 400000

The better answer: do not share

from concurrent.futures import ThreadPoolExecutor


def work(n):
    """Pure function: takes a value, returns a value, shares nothing."""
    return n * n


with ThreadPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(work, range(6)))

print(results)
print(sum(results))
[0, 1, 4, 9, 16, 25]
55

No lock, no race, no possibility of one. Threads that communicate by returning values rather than by mutating shared state are dramatically easier to get right. When you must share, use queue.Queue, which is thread-safe by design, rather than a list and a prayer.

Futures, when tasks finish at different times

import time
from concurrent.futures import ThreadPoolExecutor, as_completed


def fetch(page):
    time.sleep(0.05 * (4 - page))       # later pages finish first
    return f"page {page}"


with ThreadPoolExecutor(max_workers=4) as pool:
    futures = {pool.submit(fetch, n): n for n in range(4)}
    for future in as_completed(futures):
        print("finished:", future.result())
finished: page 3
finished: page 2
finished: page 1
finished: page 0

submit returns a Future: a promise of a result. as_completed yields them in the order they finish, so you can start processing the fast ones without waiting for the slow one. Exceptions are re-raised when you call .result(), which is a considerate design: nothing is swallowed.

Exercise 1

I/O bound or CPU bound?

For each, name the tool.

  1. Download 500 images from an API.
  2. Resize those 500 images.
  3. Read 10,000 small files and count the lines.
  4. Train a machine learning model.
  5. Poll three sensors every second for a day.
Reveal solution
  1. I/O bound. Threads, or asyncio at this volume.
  2. CPU bound. Processes, or a library like Pillow that releases the GIL.
  3. Mostly I/O bound, though at 10,000 files the per-file overhead starts to matter. Threads, and measure.
  4. CPU bound, and you should not be writing the concurrency yourself: PyTorch already uses every core and your GPU.
  5. I/O bound and mostly idle. Threads are plenty; asyncio is elegant. The real constraint is the sensors, not Python.
Exercise 2

Fix the race

This should collect results from four threads. It sometimes loses some. Fix it two ways.

import threading

results = []


def work(n):
    results.append(n * n)


threads = [threading.Thread(target=work, args=(i,)) for i in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
Reveal solution

Fix one, a lock:

import threading

results = []
lock = threading.Lock()


def work(n):
    value = n * n
    with lock:
        results.append(value)


threads = [threading.Thread(target=work, args=(i,)) for i in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(sorted(results))
[0, 1, 4, 9]

Fix two, which is better: stop sharing.

from concurrent.futures import ThreadPoolExecutor

with ThreadPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(lambda n: n * n, range(4)))

print(results)
[0, 1, 4, 9]

The second version has no shared state, needs no lock, keeps the results in order, and is four lines. As a bonus, list.append is actually atomic in CPython today, so version one was probably safe by accident, which is exactly the kind of thing you should never rely on.

Exercise 3

Measure before you optimise

Write a small benchmark comparing sequential and threaded execution of a waiting task, and report the speedup honestly.

Reveal solution
import time
from concurrent.futures import ThreadPoolExecutor


def slow_task(n):
    time.sleep(0.05)
    return n


def timed(label, func):
    start = time.perf_counter()
    result = func()
    elapsed = time.perf_counter() - start
    print(f"{label:12} {elapsed:.2f}s")
    return elapsed, result


sequential, _ = timed("sequential", lambda: [slow_task(n) for n in range(10)])

def threaded_run():
    with ThreadPoolExecutor(max_workers=10) as pool:
        return list(pool.map(slow_task, range(10)))

threaded, _ = timed("threaded", threaded_run)

print(f"speedup: {sequential / threaded:.1f}x")
print(f"worth it: {sequential / threaded > 2}")

This one has no run button because the exact timings depend on the machine, and a lesson should not promise numbers it cannot prove. Run it locally: you should see roughly 0.5s sequential and 0.05s threaded, close to a tenfold speedup. Then change time.sleep to a CPU-heavy loop and watch the speedup vanish. That experiment teaches the GIL better than any explanation.

+100 XP