Skip to main content

Threads, the GIL & concurrent.futures

Level 4: Level 4: Engineeringhard20 minconcurrencythreadingconcurrent-futuresgil

Choose a concurrency model and parallelize a batch with a thread pool.

Doing more than one thing at once

Every real service waits: on a database, an HTTP API, a file, a message queue. If you fetch 100 URLs one at a time, your program spends nearly all its wall-clock time blocked, doing nothing. Concurrency lets those waits overlap, so 100 slow calls finish in roughly the time of the slowest one instead of the sum of all of them. Choosing the right model (threads, processes, or async) is a classic interview question because the wrong choice makes code either no faster or outright wrong.

The reason overlapping waits pays off so enormously is that the costs are not close to each other. Each rung below is roughly ten times the one before it:

What a Python program is actually waiting on
Step 1 / 5
CPU instruction~1 ns
Main memory read
SSD read
Datacenter round trip
Internet API call

CPU instruction: ~1 ns. One bytecode step. The GIL serialises these across threads.

One HTTP call costs about as much time as 100 million CPU instructions. Overlapping those waits is where nearly all the win comes from, which is why I/O-bound work is the case threads help.

Read the gap between the bottom rung and the top one and the decision rule below almost writes itself: if your program is sitting on the top rung, giving it more cores changes nothing, but letting the waits overlap changes everything.

Check yourself
A report takes 80 seconds, all of it a pure-Python scoring loop over records already in memory. The box has 8 cores. You wrap the work in a ThreadPoolExecutor with 8 workers. What happens to the wall clock?

The GIL: one bytecode at a time

CPython protects its internal memory with a Global Interpreter Lock. Only one thread executes Python bytecode at any instant, so pure-Python threads never run in parallel across cores. What rescues threading is that the GIL is released during blocking I/O (and inside many C extensions like numpy). While one thread waits on a socket, it drops the lock and another thread runs. That gives you the decision rule:

  • I/O-bound work (network, disk, waiting): threads help, because the waits overlap.
  • CPU-bound work (hashing, parsing, pure-Python math): threads do not help. Use ProcessPoolExecutor (separate processes, each with its own GIL) or push the work into a native library.
Check yourself

Each of these jobs takes too long. Sort them by whether a thread pool speeds them up.

Fetching 200 product records from a slow vendor API
Scoring 2 million rows with a pure-Python loop
Reading 500 small config files off the local SSD
Multiplying two large NumPy matrices
Resizing 500 photos with a hand-written pixel loop in Python
Waiting on 40 database queries that each take about half a second server-side

concurrent.futures: one API for both

concurrent.futures gives threads and processes the same interface. The common pattern maps a function over inputs:

from concurrent.futures import ThreadPoolExecutor

def double(n):
    return n * 2

with ThreadPoolExecutor(max_workers=4) as executor:
    print(list(executor.map(double, [1, 2, 3])))   # [2, 4, 6]

executor.map returns results in input order, not completion order, even though the tasks finish out of order. Swap in ProcessPoolExecutor and the code is identical. For finer control, executor.submit(fn, x) returns a Future, and as_completed(futures) yields them as they finish. Two things to remember about map: it returns a lazy iterator, so wrap it in list(...) when you need a real list (the exercise does), and it re-raises a worker's exception when you iterate to that result, not when you call map.

Check yourself
Two threads each do counter += 1 five hundred thousand times against one shared global. What is the final value?

Pitfall: threads sharing state

Independent tasks are safe to parallelize. Shared mutable state is not. count += 1 is read, add, write: three steps, and the interpreter can switch threads between them, so two threads read the same value and one increment is lost. The GIL does not make your code thread-safe. Fix it with a Lock, or better, design the work so tasks never touch shared state (as double does here).

Running where there are no threads

This in-browser sandbox (Pyodide/WASM) has no OS threads, so building a pool raises RuntimeError: can't start new thread. A portable run_all tries the pool and falls back to a sequential map, producing identical ordered results everywhere:

try:
    with ThreadPoolExecutor(max_workers=4) as executor:
        return list(executor.map(double, numbers))
except RuntimeError:
    return [double(n) for n in numbers]

Interview nuance: Interviewers often follow up with "what is the difference between concurrency and parallelism?" Concurrency is structuring work so tasks make progress by interleaving, which is what a thread pool and async give you under the GIL. Parallelism is tasks running at the same instant on different cores, which is what a process pool gives you (or the experimental free-threaded no-GIL build added in Python 3.13). So a ThreadPoolExecutor buys you concurrency and overlaps I/O waits, but only processes buy you CPU parallelism. Naming that distinction, and tying it to the GIL, is exactly the signal they are listening for.

Check yourself
A nightly job pulls 500 documents over HTTP (about 100 ms of waiting each) and then runs a pure-Python scoring pass over each one (about 200 ms of CPU each). Sequentially that is 50 seconds of waiting plus 100 seconds of computing. The box has 4 cores. What is the best structure?
Worked example (Python)
from concurrent.futures import ThreadPoolExecutor


def double(n):
    return n * 2


try:
    with ThreadPoolExecutor(max_workers=4) as executor:
        print(list(executor.map(double, [1, 2, 3])))   # threads where available
except RuntimeError:
    print([double(n) for n in [1, 2, 3]])              # sequential fallback -> [2, 4, 6]

Apply

Your turn

The task this lesson builds to.

Warm-up (one file): implement run_all(numbers) to return a list with each number doubled, in order. (This is the sequential baseline; the workspace step parallelizes it.)

run_all([1, 2, 3]) is [2, 4, 6].

2 hints and 4 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Implement run_all(numbers) in jobs/runner.py: map the read-only double worker over numbers with a concurrent.futures.ThreadPoolExecutor, returning the results in input order. This sandbox has no OS threads, so catch the RuntimeError and fall back to a sequential map. Some tests are hidden.

3 hints and 2 automated checks are waiting in the workspace.