Polyglot
Languages Python concurrency
Python § concurrency

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 multiprocessing or 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 a Future.
  • executor.map(fn, iterable) — like map, 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 until expr (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 (requests vs httpx/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:

WorkloadChoice
I/O-bound, small concurrencyThreading (threading or concurrent.futures)
I/O-bound, high concurrency (>thousands)async/await (asyncio)
CPU-bound, parallelmultiprocessing or concurrent.futures.ProcessPoolExecutor
CPU-bound with NumPyNumPy operations release the GIL
Mix of CPU and I/OProcess 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

PrimitiveSync (threading)Async (asyncio)
Lock (mutex)Lock, RLockLock
EventEventEvent
ConditionConditionCondition
SemaphoreSemaphore, BoundedSemaphoreSemaphore, BoundedSemaphore
Queuequeue.Queue, LifoQueue, PriorityQueueasyncio.Queue, LifoQueue, PriorityQueue
BarrierBarrier(none directly)
Futureconcurrent.futures.Futureasyncio.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.futures for the simple cases (thread or process pool with submit/map).
  • Use asyncio.TaskGroup (3.11+) for structured concurrency.
  • Avoid mixing async and sync in the same code path; use asyncio.to_thread to 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.