Threading
ThreadPoolExecutor — the modern, sane way to do I/O-bound concurrency in Python.
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:
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.mapreturns results in input order;submitreturns aFuturefor finer control.as_completedyields each future the instant it finishes;fut.result()returns the value or re-raises.counter += 1is not atomic — concurrent writes can lose updates (a race condition).- Prefer returning-and-aggregating over shared state; use a
Lockonly when you truly must share.
Practice
Quick check
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.
Practice this in an interview
All questionsUse 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.
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.
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).
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.