Threading
ThreadPoolExecutor — the modern, sane way to do I/O-bound concurrency in Python.
Written and reviewed by Shreyash Prashu Last reviewed
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, time
# WITHOUT a lock — a race condition, written out step by step.
counter = 0
def bump():
global counter
for _ in range(1_000):
current = counter # 1. read
time.sleep(0) # (yield: let another thread run right here)
counter = current + 1 # 2. write back, possibly over someone else's update
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(lambda _: bump(), range(8)))
print("without lock:", counter) # should be 8000; lands far below, and varies
# WITH a lock — the read and the write happen as one unit.
counter = 0
lock = threading.Lock()
def bump_safe():
global counter
for _ in range(1_000):
with lock:
current = counter
time.sleep(0)
counter = current + 1
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(lambda _: bump_safe(), range(8)))
print("with lock:", counter)
without lock: 1005
with lock: 8000
Eight threads each added 1,000, so the answer should be 8000. Without the lock, nearly every update was lost: a thread read the counter, paused at time.sleep(0), and by the time it wrote back, its value was stale, so it erased everything the other threads had done meanwhile. Your exact number will differ, and differ again on the next run. That unpredictability is the signature of a race. With the lock, only one thread at a time can be between “read” and “write”, so it prints 8000 every run.
The better lesson, though, is the one in the callout below: 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
pool.submit(fn, x) return?as_completed instead of iterating the futures list in order?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.
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.