Async and concurrency
Python’s concurrency story is shaped by the Global Interpreter Lock (GIL) — a CPython implementation detail that admits only one thread to execute Python bytecode at a time. The mechanism simplifies the runtime but produces a substantial limitation: CPU-bound multithreaded code does not parallelise across CPU cores in CPython. The conventional defences are multiprocessing (separate processes, each with its own GIL), async/await (cooperative concurrency for I/O-bound code), and C extensions that release the GIL during compute-heavy work. Python 3.13 introduced an experimental free-threaded build that removes the GIL, but the conventional production environment is still the GIL-having interpreter.
This page covers threading, multiprocessing, concurrent.futures, the asyncio async/await machinery, and the conventional patterns. The principal point: choose the model based on the workload (I/O-bound vs CPU-bound) and the conventional Python idioms.
The GIL
The Global Interpreter Lock is a mutex in CPython that serialises Python bytecode execution:
- Threads exist and run, but only one thread executes Python bytecode at a time.
- I/O operations release the GIL during blocking calls — file I/O, network I/O,
time.sleep. Other threads run during the wait. - Compute-heavy Python code holds the GIL throughout; multiple threads do not parallelise.
- C extensions may explicitly release the GIL (NumPy does this for many operations).
The implication:
- For I/O-bound code (network requests, database queries, file processing), threading admits concurrency.
- For CPU-bound code (numeric computation, data processing), threading does not help; use
multiprocessingor numerically-oriented C-extension libraries.
The Python 3.13 free-threaded build (PEP 703) removes the GIL but is experimental as of 3.13. Most production code targets the GIL-having interpreter.
threading
The conventional thread library:
import threading
def worker(n):
print(f"thread {n} running")
t = threading.Thread(target=worker, args=(1,))
t.start()
t.join() # wait for completion
# With a class:
class Worker(threading.Thread):
def __init__(self, n):
super().__init__()
self.n = n
def run(self):
print(f"worker {self.n}")
w = Worker(1)
w.start()
w.join()
The Thread class is the principal abstraction; start() begins execution; join() waits for completion.
For shared state across threads, the conventional synchronisation primitives:
import threading
# Lock (mutex):
lock = threading.Lock()
with lock:
# critical section
update_shared_state()
# RLock (reentrant; the same thread can acquire multiple times):
rlock = threading.RLock()
# Event (signal between threads):
event = threading.Event()
event.set()
event.wait() # blocks until set
event.clear()
# Condition (associated with a lock; admits notify/wait):
cond = threading.Condition()
with cond:
while not condition_met:
cond.wait()
do_work()
# Semaphore (counting):
sem = threading.Semaphore(value=5)
with sem:
do_constrained_work()
# Barrier (n-thread synchronisation point):
barrier = threading.Barrier(parties=3)
barrier.wait() # waits until 3 threads arrive
For thread-local data, threading.local():
import threading
local = threading.local()
def worker():
local.x = 42 # private to this thread
For shared queues, queue.Queue:
from queue import Queue
q = Queue()
q.put(item) # thread-safe
item = q.get() # blocks if empty
q.task_done()
q.join() # wait for all puts to be processed
Queue admits multiple-producer, multiple-consumer patterns; the synchronisation is internal.
multiprocessing
For CPU-bound work, multiprocessing admits parallelism by running separate processes:
from multiprocessing import Pool
def compute(n):
return n ** 2
with Pool(4) as pool:
results = pool.map(compute, range(100))
# 4 processes, each with its own Python interpreter and GIL
Each process has its own GIL, so the work parallelises across cores. The trade-off:
- Pros: True parallelism for CPU-bound work.
- Cons: Process startup is expensive; data must be pickled (serialised) to cross process boundaries; shared state requires explicit IPC mechanisms.
The principal multiprocessing primitives:
from multiprocessing import Process, Queue, Pipe, Manager
# Spawn a process:
p = Process(target=worker, args=(1,))
p.start()
p.join()
# Inter-process queue:
q = Queue()
q.put(item)
item = q.get()
# Pipe:
parent_conn, child_conn = Pipe()
parent_conn.send(item)
item = child_conn.recv()
# Shared state via Manager:
with Manager() as manager:
shared_list = manager.list()
shared_dict = manager.dict()
# operations on these are synchronised across processes
The Manager runs a server process that proxies the data; access has IPC overhead but admits substantial sharing.
For CPU-bound parallel maps, Pool.map is the conventional pattern. For producer-consumer across processes, Queue. For substantial data sharing, the Manager or multiprocessing.shared_memory (since 3.8) admits raw shared buffers.
concurrent.futures
The concurrent.futures module unifies thread and process pools under a common interface:
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
# Thread pool (for I/O-bound):
with ThreadPoolExecutor(max_workers=10) as executor:
futures = [executor.submit(fetch, url) for url in urls]
for future in futures:
result = future.result()
# Process pool (for CPU-bound):
with ProcessPoolExecutor(max_workers=4) as executor:
results = list(executor.map(compute, range(100)))
The principal API:
executor.submit(fn, *args)— schedule the work; returns aFuture.executor.map(fn, iterable)— likemap, but parallel.future.result()— blocks until the work is done; returns the result (or re-raises the exception).future.done()— check completion without blocking.as_completed(futures)— yield futures in completion order.
The as_completed admits processing results as they finish:
from concurrent.futures import as_completed
with ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(fetch, url): url for url in urls}
for future in as_completed(futures):
url = futures[future]
try:
result = future.result()
except Exception as e:
print(f"{url} failed: {e}")
else:
process(result)
The concurrent.futures is the conventional contemporary choice for both thread and process pools; it is simpler than the lower-level threading and multiprocessing APIs.
asyncio and async/await
For I/O-bound concurrency, Python’s async/await admits cooperative concurrency on a single thread:
import asyncio
async def fetch(url):
# async operation; suspends here without blocking the thread
response = await http_client.get(url)
return response.text
async def main():
urls = ["url1", "url2", "url3"]
results = await asyncio.gather(*(fetch(u) for u in urls))
for result in results:
process(result)
asyncio.run(main())
The principal concepts:
async def— defines a coroutine function. Calling it produces a coroutine, not a result.await expr— suspends the coroutine untilexpr(an awaitable) completes.asyncio.run(coro)— runs the coroutine in the event loop.asyncio.gather(*coros)— runs multiple coroutines concurrently and returns their results.asyncio.create_task(coro)— schedule a coroutine to run; returns a Task.
The mechanism is cooperative — coroutines voluntarily suspend at await points. The event loop multiplexes; there is no implicit thread-switching.
When async helps
async/await admits substantial concurrency for I/O-bound work without the threading overhead. The benefits:
- Single thread; no GIL contention.
- Substantially lighter than threads (millions of coroutines fit in memory).
- Explicit suspension points (no race conditions on local state during synchronous code).
The trade-offs:
- The entire stack must be async (or carefully bridged); blocking calls in async code halt the event loop.
- The mental model is different from synchronous code.
- Libraries are split into sync and async variants (
requestsvshttpx/aiohttp).
The conventional uses are servers, scrapers, and any code that spends most of its time waiting on I/O.
asyncio primitives
import asyncio
# Sleep:
await asyncio.sleep(1.0)
# Gather (concurrent):
results = await asyncio.gather(coro1(), coro2(), coro3())
# Wait with timeout:
try:
result = await asyncio.wait_for(slow_op(), timeout=10.0)
except asyncio.TimeoutError:
handle_timeout()
# Tasks (similar to Futures):
task = asyncio.create_task(slow_op())
result = await task
# Locks, events, conditions, semaphores, queues — async variants:
lock = asyncio.Lock()
async with lock:
do_critical_section()
queue = asyncio.Queue()
await queue.put(item)
item = await queue.get()
The asyncio API mirrors threading substantially; the difference is the cooperative scheduling.
asyncio.TaskGroup (Python 3.11+)
For structured concurrency — managing the lifetime of related tasks:
import asyncio
async def main():
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(fetch("url1"))
task2 = tg.create_task(fetch("url2"))
task3 = tg.create_task(fetch("url3"))
# all tasks are awaited at the end of the block
print(task1.result(), task2.result(), task3.result())
The TaskGroup admits:
- Tasks are spawned within the block.
- The block exits only when all tasks complete.
- If any task fails, all others are cancelled, and the failures are gathered into an
ExceptionGroup.
The mechanism is the conventional contemporary form for managing related tasks; replaces ad-hoc cancellation patterns.
Async iteration
The async for consumes async iterators:
async def fetch_pages(url):
while url:
response = await http_client.get(url)
yield response.body
url = response.next_page_url
async def main():
async for body in fetch_pages(start_url):
process(body)
Treated in Iterators and generators.
Async context managers
The async with admits context managers with async __aenter__ / __aexit__:
async with database.transaction() as tx:
await tx.execute(...)
await tx.execute(...)
# tx is committed (or rolled back on exception)
Choosing the concurrency model
The conventional decision tree:
| Workload | Choice |
|---|---|
| I/O-bound, small concurrency | Threading (threading or concurrent.futures) |
| I/O-bound, high concurrency (>thousands) | async/await (asyncio) |
| CPU-bound, parallel | multiprocessing or concurrent.futures.ProcessPoolExecutor |
| CPU-bound with NumPy | NumPy operations release the GIL |
| Mix of CPU and I/O | Process pool with async inside, or split work |
The principal question: is the work CPU-bound or I/O-bound?
- I/O-bound — most time is waiting (network, disk, database). Threading or async helps.
- CPU-bound — most time is computing. Multiprocessing helps; threading does not.
For most Python web applications, async is the conventional contemporary choice (FastAPI, Litestar, ASGI frameworks). For CPU-heavy data processing, multiprocessing or libraries that release the GIL (NumPy, Cython, Rust extensions).
Common patterns
Producer-consumer with queue.Queue
import threading
from queue import Queue
queue = Queue()
def producer():
for item in items:
queue.put(item)
queue.put(None) # sentinel
def consumer():
while True:
item = queue.get()
if item is None:
break
process(item)
queue.task_done()
threading.Thread(target=producer).start()
threading.Thread(target=consumer).start()
queue.join() # wait for completion
The Queue is thread-safe; the conventional Python form for producer-consumer.
Async producer-consumer with asyncio.Queue
import asyncio
async def producer(queue):
for item in items:
await queue.put(item)
await queue.put(None)
async def consumer(queue):
while True:
item = await queue.get()
if item is None:
break
await process(item)
async def main():
queue = asyncio.Queue()
async with asyncio.TaskGroup() as tg:
tg.create_task(producer(queue))
tg.create_task(consumer(queue))
Parallel map
from concurrent.futures import ProcessPoolExecutor
def compute(n):
return n ** 2
with ProcessPoolExecutor(max_workers=4) as executor:
results = list(executor.map(compute, range(1000)))
The pattern admits CPU-bound parallel computation across cores.
Concurrent fetching (async)
import asyncio
import httpx
async def fetch(client, url):
response = await client.get(url)
return response.text
async def main():
urls = [...]
async with httpx.AsyncClient() as client:
results = await asyncio.gather(*(fetch(client, u) for u in urls))
return results
The conventional async pattern for concurrent HTTP requests.
Fan-out / fan-in
async def main():
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(work(i)) for i in range(100)]
# all tasks complete by here
results = [t.result() for t in tasks]
The TaskGroup admits substantial concurrent work with structured cleanup.
Graceful shutdown
import asyncio
import signal
async def main():
stop = asyncio.Event()
def shutdown():
stop.set()
loop = asyncio.get_event_loop()
loop.add_signal_handler(signal.SIGINT, shutdown)
loop.add_signal_handler(signal.SIGTERM, shutdown)
while not stop.is_set():
await do_work()
The pattern admits responding to shutdown signals.
Synchronisation primitives summary
| Primitive | Sync (threading) | Async (asyncio) |
|---|---|---|
| Lock (mutex) | Lock, RLock | Lock |
| Event | Event | Event |
| Condition | Condition | Condition |
| Semaphore | Semaphore, BoundedSemaphore | Semaphore, BoundedSemaphore |
| Queue | queue.Queue, LifoQueue, PriorityQueue | asyncio.Queue, LifoQueue, PriorityQueue |
| Barrier | Barrier | (none directly) |
| Future | concurrent.futures.Future | asyncio.Future, Task |
The async variants must be awaited; the synchronous variants block.
A note on the GIL and the future
The Python 3.13 free-threaded build (PEP 703) admits removing the GIL — multiple threads can execute Python bytecode in parallel. The build is currently experimental:
python3.13t # the free-threaded interpreter
The implications:
- True parallelism for CPU-bound multithreaded Python code.
- Substantial overhead for some operations (atomic reference counting).
- Compatibility issues with C extensions that assume the GIL.
The transition is expected to take several releases (the GIL build will remain the default for some time). For most contemporary code, the GIL-having interpreter is the production environment; free-threaded Python is a future option for CPU-bound multithreaded workloads.
A note on the discipline
The contemporary Python concurrency advice:
- Profile first — most “performance problems” are not concurrency-related.
- Use async for I/O-bound code — the conventional contemporary form.
- Use multiprocessing for CPU-bound work — threading does not parallelise in CPython.
- Use
concurrent.futuresfor the simple cases (thread or process pool withsubmit/map). - Use
asyncio.TaskGroup(3.11+) for structured concurrency. - Avoid mixing async and sync in the same code path; use
asyncio.to_threadto run blocking code in a thread. - Use NumPy or extension libraries for compute-heavy numeric work.
The Python concurrency story is more elaborate than other languages’ (because of the GIL and the multiple models), but the conventional patterns admit substantial concurrency for the common workloads.