CoursesAdvanced scripting for DevSecOpsAdvanced Python engineering

Concurrency: threads, processes, asyncio & the GIL

Pick the right model — the GIL, I/O vs CPU bound, and safe subprocess fan-out.

Expert40 min · lesson 7 of 15

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.

Pick the model by where the time goes
I/O-bound (waiting)
asyncio
thousands of sockets, one thread, no GIL fight
ThreadPoolExecutor
simple fan-out for blocking libraries
CPU-bound (computing)
ProcessPoolExecutor
real parallelism across cores
multiprocessing
separate interpreters sidestep the GIL
external work
subprocess + as_completed
let other tools use the cores
asyncio.create_subprocess
many child processes, awaited
If your program is waiting on the network, more processes will not help; if it is computing, more threads will not help.
the same fan-out, two right ways
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed
# I/O-bound: 200 HTTP checks — threads are fine, GIL is released on the socket
def check(url): return url, requests.get(url, timeout=5).status_code
with 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 parallelism
def 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.

bounded async fan-out
import asyncio, httpx
async def probe(client, sem, url):
async with sem: # cap concurrency
r = await client.get(url, timeout=10)
return url, r.status_code
async 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))
Shared mutable state across threads still races
The GIL makes individual bytecode operations atomic, but it does NOT make count += 1 or "check then act" sequences atomic — those are multiple bytecodes and can interleave. Any state shared across threads needs a lock, a queue, or a thread-safe structure; the safest pattern is to share nothing and communicate results through a queue. "Python has a GIL so I do not need locks" is a myth that produces intermittent, unreproducible corruption.