Concurrency: threads, processes, asyncio & the GIL
Pick the right model — the GIL, I/O vs CPU bound, and safe subprocess fan-out.
The single most consequential decision in a Python automation tool is the concurrency model, and it hinges on one fact: CPython has a Global Interpreter Lock that lets only one thread execute Python bytecode at a time. That does not make threads useless — it makes them useless for CPU work and excellent for I/O. Getting this right turns a job that takes an hour into one that takes a minute; getting it wrong adds complexity for no speedup, or worse, subtle data races.
The GIL, and I/O-bound vs CPU-bound
The rule of thumb: use concurrency for I/O-bound work (network calls, disk, subprocesses) and parallelism for CPU-bound work (hashing, parsing, compression). Threads and asyncio give you concurrency — many operations in flight, the GIL released while waiting on I/O. Multiprocessing gives you true parallelism — separate processes, separate interpreters, separate GILs — at the cost of no shared memory and pickling overhead to pass data. Match the model to the bottleneck.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed# I/O-bound: 200 HTTP checks — threads are fine, GIL is released on the socketdef check(url): return url, requests.get(url, timeout=5).status_codewith ThreadPoolExecutor(max_workers=32) as ex:for fut in as_completed(ex.submit(check, u) for u in urls):url, code = fut.result(); print(code, url)# CPU-bound: hash 10k files — processes give real parallelismdef digest(p): return p, hashlib.sha256(open(p, "rb").read()).hexdigest()with ProcessPoolExecutor() as ex:for fut in as_completed(ex.submit(digest, p) for p in files):print(*fut.result())
asyncio for high-concurrency I/O
When you need hundreds or thousands of concurrent network operations, asyncio scales where a thread-per-task does not: one event loop multiplexes them cooperatively, with a Semaphore to cap how many run at once so you do not hammer a dependency. The catch is that asyncio is all-or-nothing about blocking — one synchronous call (a blocking DNS lookup, a CPU-heavy loop) stalls the whole loop, so you must use async-native libraries or push blocking work to an executor.
import asyncio, httpxasync def probe(client, sem, url):async with sem: # cap concurrencyr = await client.get(url, timeout=10)return url, r.status_codeasync def main(urls):sem = asyncio.Semaphore(50)async with httpx.AsyncClient() as client:tasks = [probe(client, sem, u) for u in urls]return await asyncio.gather(*tasks, return_exceptions=True)results = asyncio.run(main(urls))