datarekha

Threading

ThreadPoolExecutor — the modern, sane way to do I/O-bound concurrency in Python.

9 min read Advanced Python Lesson 31 of 41

What you'll learn

  • Why concurrent.futures is the right entry point, not raw threading.Thread
  • submit vs map, and what a Future actually is
  • Fanning out HTTP requests in parallel with as_completed
  • Race conditions, and when you actually need a Lock

Before you start

Threads in Python are for I/O-bound work — tasks that spend most of their time waiting, on the network, the disk, or a remote database. While one thread waits, it releases the GIL, so the others can run Python in the meantime. (For CPU-bound work that stays in Python bytecode, threads do not help, because only one runs at a time — that was the GIL lesson.) And the right way to use threads is almost never the raw threading.Thread class; it is concurrent.futures.ThreadPoolExecutor, which hides the bookkeeping behind a clean “send work in, get results out” interface.

ThreadPoolExecutor in eight lines

from concurrent.futures import ThreadPoolExecutor
import time

def fetch(url):
    time.sleep(0.1)              # stand in for a real HTTP call
    return f"{url} -> 200 OK"

urls = [f"https://api.example.com/u/{i}" for i in range(10)]

with ThreadPoolExecutor(max_workers=10) as pool:
    results = list(pool.map(fetch, urls))

for r in results:
    print(r)
https://api.example.com/u/0 -> 200 OK
https://api.example.com/u/1 -> 200 OK
https://api.example.com/u/2 -> 200 OK
https://api.example.com/u/3 -> 200 OK
https://api.example.com/u/4 -> 200 OK
https://api.example.com/u/5 -> 200 OK
https://api.example.com/u/6 -> 200 OK
https://api.example.com/u/7 -> 200 OK
https://api.example.com/u/8 -> 200 OK
https://api.example.com/u/9 -> 200 OK

pool.map is the simplest form — it is just map(fn, iterable), but the calls run concurrently across threads. Note that results come back in input order even though the calls finish in whatever order they happen to. And the with block matters: it waits for all the work to finish, then shuts the pool down cleanly, so always use the executor as a context manager.

submit() and Future objects

map shines for a uniform list of inputs. When you need finer control — different arguments, handling each failure as it lands — use submit, which returns a Future: a placeholder for a result that does not exist yet. Here each job is given a fixed delay so the completion order is predictable, and two are rigged to fail:

from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def fetch(url, delay, fail):
    time.sleep(delay)
    if fail:
        raise ConnectionError(f"flake on {url}")
    return f"OK {url}"

# Fixed delays make the finish order deterministic; /u/3 and /u/6 fail.
jobs = [(f"/u/{i}", 0.02 * i, i in (3, 6)) for i in range(8)]

with ThreadPoolExecutor(max_workers=8) as pool:
    futures = {pool.submit(fetch, url, delay, fail): url for url, delay, fail in jobs}
    for fut in as_completed(futures):
        url = futures[fut]
        try:
            print(fut.result())
        except Exception as e:
            print(f"FAIL {url}: {e}")
OK /u/0
OK /u/1
OK /u/2
FAIL /u/3: flake on /u/3
OK /u/4
OK /u/5
FAIL /u/6: flake on /u/6
OK /u/7

Three mechanisms are at play. submit(fn, *args) schedules the call and returns a Future immediately. as_completed(...) is a generator that yields each Future the instant its work finishes — here the rising delays make that order /u/0 through /u/7, but with real network latency it would be whichever responds first. And fut.result() returns the value or re-raises the exception the function threw, which is why the failures land in the except. The {future: url} dict is the standard trick for keeping each input associated with its future.

A realistic fan-out

This exact shape appears everywhere in production: enrich a list of records by calling one API per record. The enrichment here is deterministic so you can check the result, but picture each call as a slow network round-trip:

from concurrent.futures import ThreadPoolExecutor

user_ids = list(range(1, 21))

def enrich(uid):
    # In reality, a slow external API call.
    return {"id": uid, "score": uid * 7 % 100}

with ThreadPoolExecutor(max_workers=8) as pool:
    enriched = list(pool.map(enrich, user_ids))

print("enriched:", len(enriched), "users")
print("first 3:", enriched[:3])
enriched: 20 users
first 3: [{'id': 1, 'score': 7}, {'id': 2, 'score': 14}, {'id': 3, 'score': 21}]

Now reason about the time. If each call took about 0.1s, the serial loop over 20 users would take roughly 20 × 0.1 = 2s. With eight workers the calls overlap, so the batch finishes in closer to 0.25s — and the bottleneck shifts from your code to the remote API’s rate limit, which is exactly where you want it.

Race conditions and Lock

Threads share memory, and that is where the danger lives. The statement counter += 1 is not one indivisible step — it compiles to three bytecode operations (read, add, store), and the GIL can be released between any two of them. So two threads can both read the old value, both add one, and both write back the same new value — and one of the increments simply vanishes. That is a race condition:

Thread Aread 41write 42Thread Bread 41write 42counter = 42(should be 43 — one +1 lost)
from concurrent.futures import ThreadPoolExecutor
import threading

# WITHOUT a lock — a race condition.
counter = 0
def bump():
    global counter
    for _ in range(50_000):
        counter += 1               # read, add, write — NOT atomic
with ThreadPoolExecutor(max_workers=8) as pool:
    list(pool.map(lambda _: bump(), range(8)))
print("without lock:", counter)    # frequently < 400000, and varies each run

# WITH a lock — correct, every time.
counter = 0
lock = threading.Lock()
def bump_safe():
    global counter
    for _ in range(50_000):
        with lock:
            counter += 1
with ThreadPoolExecutor(max_workers=8) as pool:
    list(pool.map(lambda _: bump_safe(), range(8)))
print("with lock:", counter)
with lock: 400000

The with lock: version prints exactly 400000 on every run, because only one thread can be inside the locked block at a time. The without lock: line is the opposite of reproducible — it frequently prints a number below 400000, and a different one each run, as increments are lost to the race (which is exactly why it is not shown as a fixed value). The better lesson, though, is the one in the callout: prefer to avoid shared mutable state entirely. Have each task return a value and combine the values in the main thread — which is precisely what map already does for you.

Raw threading.Thread — when?

Almost never. threading.Thread is the older, lower-level API where you manually start, join, and track threads. It earns its place only for a long-lived background worker — a heartbeat that runs for the life of the process, say. For everything else, ThreadPoolExecutor is shorter, safer, and easier to read:

# The shape, for reference — you usually do not want this.
t = threading.Thread(target=worker, args=(q,), daemon=True)
t.start()
# ... do other work ...
t.join()

In one breath

  • Threads help I/O-bound work; reach for ThreadPoolExecutor, not raw threads.
  • pool.map returns results in input order; submit returns a Future for finer control.
  • as_completed yields each future the instant it finishes; fut.result() returns the value or re-raises.
  • counter += 1 is not atomic — concurrent writes can lose updates (a race condition).
  • Prefer returning-and-aggregating over shared state; use a Lock only when you truly must share.

Practice

Quick check

0/3
Q1What does `pool.submit(fn, x)` return?
Q2Why use `as_completed` instead of iterating the futures list in order?
Q3`counter += 1` from multiple threads can lose updates. Why?

What’s next

Threads bypass I/O wait but not the GIL. For CPU-bound work you need a different tool — separate processes, each with its own interpreter and its own GIL. That is next.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
When should you use threading versus multiprocessing in Python?

Use threading for I/O-bound work — network calls, file reads, database queries — because threads release the GIL during blocking syscalls and share memory cheaply. Use multiprocessing for CPU-bound work — number crunching, image processing — because each process gets its own GIL and can run on a separate core.

How does asyncio differ from threading, and when would you choose one over the other?

asyncio is cooperative, single-threaded concurrency: coroutines yield control explicitly at await points, so there is no GIL contention and no shared-state races. Threads are preemptive OS-level concurrency: the scheduler can switch at any bytecode boundary, which requires explicit locking. Choose asyncio for high-fan-out I/O (thousands of connections); choose threads when you need to call blocking APIs you cannot rewrite.

What is the difference between CPU-bound and I/O-bound work, and how does the choice affect concurrency strategy in Python?

CPU-bound work keeps the processor busy the whole time — matrix multiplication, compression, parsing. I/O-bound work spends most of its time waiting for a slow external resource — network, disk, database. The distinction directly determines which concurrency primitive to reach for: multiprocessing for CPU-bound (bypasses the GIL), threading or asyncio for I/O-bound (GIL released during waits).

What is Python's GIL and why does it exist?

The Global Interpreter Lock is a mutex inside CPython that allows only one thread to execute Python bytecode at a time. It exists because CPython's memory management — reference counting — is not thread-safe, so the GIL prevents race conditions on object reference counts without per-object locking overhead.

Related lessons

Explore further

Skip to content