CoursesAdvanced scripting for DevSecOpsConcurrency: threads, processes, asyncio & the GIL

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 biggest decision in any Python automation tool is how it does more than one thing at a time, and it turns on a single fact about the language: the standard Python interpreter runs exactly one piece of Python code at any given instant. Pick the wrong model and you pile on locks, race conditions, and complexity for zero speedup. Pick the right one and a fleet scan that took an hour finishes in a minute. For a security or operations job, that gap decides whether a control runs on every deploy or gets quietly disabled because it is too slow.

One key for the whole interpreter

CPython (the reference version of Python you get from python.org or your package manager, written in the C programming language) guards its own internals with a single lock called the Global Interpreter Lock, or GIL. Think of a busy kitchen with one chef's knife that everyone shares. Only the cook holding the knife can chop, and there is exactly one knife. A thread (a strand of execution running inside your program) has to hold the GIL to run Python bytecode (the low-level instructions the interpreter actually executes). One knife means one chopper at a time, no matter how many cooks you put on the line.

Here is the part that makes threads worth having anyway. The moment a thread stops to wait on something outside the interpreter, a network reply, a disk read, a subprocess finishing, it drops the knife so another thread can grab it. A cook waiting on the oven puts the knife down. So for work that is mostly waiting, threads overlap and everything speeds up. For work that is mostly computing, the threads never let go of the knife, they take turns, and you gain nothing. Watch it happen.

~/secopslog — bash
$ python3 - <<'PY' import time, threading def burn(n): # pure Python arithmetic, never touches I/O x = 0 for _ in range(n): x += 1 return x N = 40_000_000 t = time.perf_counter() burn(N); burn(N) # one after the other print(f"serial {time.perf_counter()-t:5.2f}s") t = time.perf_counter() ts = [threading.Thread(target=burn, args=(N,)) for _ in range(2)] for x in ts: x.start() for x in ts: x.join() # two threads, same work print(f"threads {time.perf_counter()-t:5.2f}s") PY
serial 1.94s threads 2.06s

Two threads on a multi-core machine, and it got a hair slower. The arithmetic never releases the knife, so the threads queue behind each other and you pay a small tax for the handoffs. Now give the identical work true parallelism by running it in separate processes.

~/secopslog — bash
$ python3 - <<'PY' import time from concurrent.futures import ProcessPoolExecutor def burn(n): x = 0 for _ in range(n): x += 1 return x N = 40_000_000 t = time.perf_counter() with ProcessPoolExecutor(max_workers=2) as ex: list(ex.map(burn, [N, N])) # each worker is its own interpreter print(f"procs {time.perf_counter()-t:5.2f}s") PY
procs 1.02s

Two processes, each with its own interpreter and its own GIL, run on two cores at once and roughly halve the wall-clock time. That is the entire rule in one experiment. Threads share a knife; processes each bring their own.

Python 3.13 shipped an experimental build with the lock removed, called free-threaded or no-GIL Python, in which threads really can run bytecode in parallel. You can ask any interpreter which mode it is in.

~/secopslog — bash
$ python3.13 -c 'import sys; print("GIL on:", sys._is_gil_enabled())' python3.13t -c 'import sys; print("GIL on:", sys._is_gil_enabled())'
GIL on: True GIL on: False

The build named python3.13t (the t is for threaded) reports the lock off. Treat it as a preview, not a plan. Many C extensions you depend on are not yet safe without the GIL, and single-thread code often runs slower on that build. For anything you ship today, assume the lock is there and choose your model around it.

I/O-bound or CPU-bound, that is the fork

Every task you automate falls on one side of a line. Either it spends most of its life waiting for something else, which we call I/O-bound (I/O means input/output, the reading and writing your program does over the network, to disk, or to another process), or it spends most of its life computing, which we call CPU-bound (CPU means central processing unit, the chip that does the actual math). A clerk stuck on hold with the bank is I/O-bound. An accountant grinding through columns of figures is CPU-bound. Timing them tells you which one you have: if the wall-clock time is far longer than the processor time, you are waiting, and you are I/O-bound.

The choice follows directly. I/O-bound work wants concurrency, many operations in flight while the GIL is released on each wait, so reach for threads or asyncio. CPU-bound work wants real parallelism across cores, so reach for processes. Get this backwards and you either add threads that fight over one knife or spin up processes whose pickling overhead (the cost of freezing objects to bytes so they can cross between separate interpreters) swamps the tiny bit of computing you were doing.

Which concurrency model fits the task
Where does the task spend its time?
Mostly waiting on network, disk, or a subprocess
I/O-bound
Use threads (ThreadPoolExecutor). The GIL is released on every wait, so operations overlap in one interpreter.
Mostly computing in pure-Python loops or parsing
CPU-bound
Use processes (ProcessPoolExecutor). Each worker gets its own interpreter and GIL, so cores run in parallel.
Hundreds or thousands of connections at once
asyncio
One event loop multiplexes them cooperatively. Cap with a Semaphore so you do not flood the target.

The same fan-out pattern covers both bounded cases. concurrent.futures gives you two executors with an identical shape, so switching models is a one-word edit once you know which side of the fork you are on.

fanout.py
from concurrent.futures import (
ThreadPoolExecutor, ProcessPoolExecutor, as_completed)
import hashlib, requests
# I/O-bound: 200 HTTP health checks. Threads win: the GIL is
# released while the socket waits for each reply.
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: SHA-256 a directory tree in pure-Python glue.
# Processes win: real cores, separate GILs.
def digest(path):
with open(path, "rb") as f:
return path, hashlib.sha256(f.read()).hexdigest()
with ProcessPoolExecutor() as ex: # defaults to os.cpu_count()
for fut in as_completed(ex.submit(digest, p) for p in files):
print(*fut.result())

asyncio when you need thousands in flight

When a run means hundreds or thousands of network calls at once, threads stop scaling. Every operating-system thread costs memory and scheduling overhead, and a few thousand of them bury the machine. asyncio (asynchronous input/output, said out loud as A-sync-I-O) takes a different shape. One thread runs an event loop (a dispatcher that keeps a list of half-finished jobs and resumes whichever one is ready). It works like a single receptionist at a switchboard full of callers on hold, picking up each line the instant it speaks. A coroutine (a function you write with async def that can pause at every await and hand control back) is one caller. The loop juggles them all cooperatively, and because it is all one thread, there is no knife to fight over.

probe.py
import asyncio, httpx
async def probe(client, sem, url):
async with sem: # cap concurrency here
r = await client.get(url, timeout=10)
return url, r.status_code
async def main(urls):
sem = asyncio.Semaphore(50) # at most 50 in flight
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))

Two rules keep this honest. First, cap the concurrency with a Semaphore (a counter that only lets N jobs past at a time, like a bouncer holding a fixed headcount at the door), so you do not open ten thousand sockets and look like a denial-of-service attack (DoS, flooding a service with more requests than it can serve) against your own dependency. Second, everything the loop touches has to be non-blocking. One ordinary blocking call, a synchronous DNS lookup (DNS is the Domain Name System, the phone book that turns a hostname into an address), or a heavy pure-Python loop, freezes every other job on the loop, because the single receptionist is now stuck on one line. Use async-native libraries such as httpx, aiohttp, or asyncpg, or push the blocking work onto a thread or process pool with loop.run_in_executor.

Fanning out subprocesses without opening a door

A huge amount of DevSecOps automation is really one shell command run across many targets: resolve a thousand hostnames, check a TLS (Transport Layer Security, the encryption that puts the S in HTTPS) certificate on each host, grep a config on every box. Running a subprocess (a separate program your script launches and waits on) is I/O-bound from Python's point of view, so a thread pool is the natural fit. The trap is how you hand the command over. Passing one big string with shell=True is like reading a delivery address out loud to someone who will do literally anything the address says. If the address ends with 'and also burn down the house,' they do that too.

scan.py
#!/usr/bin/env python3
import subprocess, sys
from concurrent.futures import ThreadPoolExecutor, as_completed
def resolve(host):
# List of args, no shell: the host can never become a command.
r = subprocess.run(
["dig", "+short", "+time=2", host],
capture_output=True, text=True, timeout=5, # never hang forever
)
ips = r.stdout.split() or ["-"]
return host, ips[0]
hosts = [h.strip() for h in sys.stdin if h.strip()]
with ThreadPoolExecutor(max_workers=20) as ex: # bound the fan-out
futs = {ex.submit(resolve, h): h for h in hosts}
for fut in as_completed(futs):
host, ip = fut.result()
print(f"{ip:16} {host}")
~/secopslog — bash
$ printf 'example.com\nwikipedia.org\ncloudflare.com\n' | python3 scan.py
23.215.0.136 example.com 198.35.26.96 wikipedia.org 104.16.132.229 cloudflare.com

Now see the door. Swap the list of arguments for one string plus shell=True and hand it a hostname a defender might feed you from an untrusted inventory file.

~/secopslog — bash
$ python3 -c 'import subprocess; subprocess.run("dig +short " + "example.com; id", shell=True)'
23.215.0.136 23.215.0.138 uid=1000(ops) gid=1000(ops) groups=1000(ops),27(sudo)

The dig ran, and then id ran, because the shell saw a semicolon and happily obeyed the second command. That is command injection, and it is the same class of bug behind countless real breaches. Whoever controls the hostname now controls your automation host. As a defender you also want the injection to be visible after the fact: this pattern is exactly why you keep list-form subprocess calls in code review and why you alert on your own tooling spawning an unexpected id, whoami, or curl.

Never build a shell string from input you did not write
Pass a list of arguments and leave shell=False (the default). The kernel then hands your program its arguments directly, so a hostname, path, or ticket ID can never turn into extra commands. Reserve shell=True for a fixed string you typed yourself, never for anything assembled from a file, an API response, or a user. Always set a timeout on subprocess.run so one hung target cannot stall the whole batch.

The other half of running commands safely is not drowning yourself. Twenty workers, not two thousand. Unbounded fan-out looks like a flood to whatever you are hitting, trips rate limits, and exhausts your own file descriptors (the small integer handles the kernel hands out for every open file, socket, or pipe). Check your soft cap before you scale up.

~/secopslog — bash
$ ulimit -n
1024

Every concurrent socket or subprocess eats one of those 1024 handles, plus a few for pipes. Blow past the cap and Python raises OSError: [Errno 24] Too many open files right in the middle of a run, usually against the most fragile target. Bounding the pool keeps you under the limit and keeps your scan from reading, on the receiving end, as an attack. A capped, predictable fan-out is both faster to reason about and quieter on the network you share with everyone else.

The GIL does not save you from data races
One lock protecting the interpreter is not one lock protecting your data. count += 1, and any check-then-act sequence, compiles to several bytecodes, and a thread can be paused between them, so a shared counter drops updates and shared state races. The GIL makes a single bytecode atomic, nothing more. Share nothing between threads and pass results through a queue.Queue, or wrap every shared structure in a threading.Lock. 'Python has a GIL so I do not need locks' is how you get intermittent, unreproducible corruption that only shows up in production.
Quick check
01You need to run a pure-Python function that walks 5,000 config files line by line, scoring each against a ruleset, on an 8-core server, as fast as possible. Which model gives real speedup?
Incorrect — pure-Python scoring holds the GIL the whole time, so the threads take turns on one core and never run in parallel.
Incorrect — asyncio overlaps waiting, and this work waits on nothing. There is no I/O to hide behind, so the loop computes one task at a time.
Correct — each process is its own interpreter with its own GIL, so all 8 cores score files at once. CPU-bound Python needs separate processes.
Incorrect — 200 threads share the same single GIL, which means more contention and context-switching, not more cores doing work.
02The lesson warns that the Global Interpreter Lock (GIL) does not protect your data. Why can two threads sharing count += 1 still lose updates?
Correct — the GIL guarantees atomicity per bytecode, not per statement, so any check-then-act sequence can be interrupted mid-way.
Incorrect — the arithmetic holds the GIL; the loss comes from the statement being several bytecodes, not from parallel execution.
Incorrect — Python integers are immutable; the race is over the shared name being rebound, not in-place mutation.
Incorrect — it raises nothing; it silently drops updates, which is what makes the bug so hard to find.
03An asyncio (asynchronous input/output) scanner runs 500 coroutines through one event loop, but they execute one after another with no overlap. Each coroutine calls requests.get(url), the synchronous library, inside async def. Why is there no concurrency?
Incorrect — gather does schedule coroutines concurrently; the problem is that this coroutine never yields control.
Correct — one blocking call freezes the entire loop because everything shares one thread and there is no await to hand control back.
Incorrect — a Semaphore caps how many tasks run at once, it does not create concurrency, and it cannot unblock a blocking call.
Incorrect — an event loop can juggle far more than 500 awaiting coroutines; the blocking call, not the count, is the issue.

Once you have picked a model, prove it instead of trusting it. Time the run both ways with time.perf_counter(), or wrap the whole thing in /usr/bin/time -v and read the numbers. A CPU-bound job done right pushes total processor use toward 100 percent times your core count and shows wall-clock time close to processor time. An I/O-bound job done right shows low processor use with many connections open at once, wall-clock far below the sum of the waits. If the numbers barely move when you add workers, you are on the wrong side of the fork, and the fix is to switch models, not to add more workers to the model that cannot help.

Try this

Work through “Fanning out subprocesses without opening a door” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: never build a shell string from input you did not write. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related