Codeskill

Learn to code, step by step

Concurrency threads processes asyncio

Concurrency in Python: threads, processes, and asyncio. The goal is not to memorise every API – it is to know which tool fits which job, and when to leave everything single-threaded because that is simpler and fast enough.

The GIL and why it matters

CPython has a Global Interpreter Lock. Only one thread runs Python bytecode at a time in a single process. Threads still help when your code waits on I/O – network calls, disk reads, sleeping – because the lock is released during those waits. Threads do not help much for CPU-heavy number crunching in pure Python; use multiple processes instead, or push hot loops into C extensions / NumPy.

Threads for I/O-bound work

Fetching several URLs is a classic I/O-bound task. A thread pool keeps the code readable without managing threads by hand.

from concurrent.futures import ThreadPoolExecutor, as_completed
import httpx

URLS = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
]

def fetch(url: str) -> int:
    response = httpx.get(url, timeout=10)
    response.raise_for_status()
    return response.status_code

with ThreadPoolExecutor(max_workers=4) as pool:
    futures = {pool.submit(fetch, url): url for url in URLS}
    for future in as_completed(futures):
        url = futures[future]
        print(url, future.result())

Keep shared mutable state out of threaded code when you can. If two threads write the same list, use a lock or a queue. Better still, give each thread its own results and merge at the end.

Processes for CPU-bound work

Each process has its own Python interpreter and memory. That costs more than threads, but CPU work can run in parallel across cores.

from concurrent.futures import ProcessPoolExecutor

def is_prime(n: int) -> bool:
    if n < 2:
        return False
    for divisor in range(2, int(n ** 0.5) + 1):
        if n % divisor == 0:
            return False
    return True

candidates = range(10_000, 10_200)
with ProcessPoolExecutor() as pool:
    primes = [n for n, ok in zip(candidates, pool.map(is_prime, candidates)) if ok]
print(len(primes), "primes found")

On Windows and macOS, guard process-spawning entry points with if __name__ == "__main__": so child processes do not re-import your module in a loop.

asyncio for many concurrent I/O waits

asyncio suits thousands of lightweight concurrent tasks – websockets, long-lived connections, services built on async libraries. One thread, cooperative multitasking: tasks yield control at await points.

import asyncio
import httpx

async def fetch(client: httpx.AsyncClient, url: str) -> int:
    response = await client.get(url, timeout=10)
    response.raise_for_status()
    return response.status_code

async def main() -> None:
    urls = ["https://httpbin.org/get"] * 5
    async with httpx.AsyncClient() as client:
        tasks = [fetch(client, url) for url in urls]
        results = await asyncio.gather(*tasks)
    print(results)

asyncio.run(main())

Do not sprinkle async def everywhere “because it is modern”. Mixing sync and async code gets messy fast. If your stack is Flask with blocking database drivers, threads or a task queue may be the honest answer until you commit to an async framework end to end.

Choosing a model

  • I/O-bound, moderate parallelism, sync libraries – thread pool
  • CPU-bound pure Python – process pool or a library that releases the GIL
  • Many concurrent network waits, async libraries availableasyncio
  • Simple script, fast enough already – leave it alone

Measure before you parallelise. Spawning processes and juggling threads adds complexity. The best concurrency strategy is often a background job queue – covered later in these tutorials.

PreviousGoing deep with Python