AI SWE Prep
0/9in Python Foundations
Python Foundations

Concurrency, Async & the GIL

Threads vs. processes vs. asyncio, the GIL, and where each one actually helps when you are serving models.

~15 minLesson 9 of 60
Your progressNot started

“Make it faster” usually means one of two very different things: the program is waiting (on the network, disk, or a model API) or it is computing (crunching numbers). Python gives you different tools for each, and picking the wrong one is a classic mistake. The key that unlocks the whole topic is the GIL.

The GIL, briefly

CPython has a Global Interpreter Lock — only one thread executes Python bytecode at a time. The consequence is stark:

  • For CPU-bound work (parsing, math, image processing), threads do not give you real parallelism. You need multiple processes.
  • For I/O-bound work (HTTP calls, DB queries, LLM requests), threads and async work beautifully, because a thread waiting on the network releases the GIL and lets others run.

Most AI-engineering workloads are I/O-bound — you spend your time waiting on model APIs — which makes async the tool you’ll reach for most.

Three models of concurrency

Approach Best for Mechanism
threading I/O-bound, blocking libraries OS threads, share memory
asyncio I/O-bound, high concurrency single thread, cooperative await
multiprocessing CPU-bound separate processes, true parallelism
Concurrency models on one timeline4 tasks · 4 cores
Sequential20 units · baseline
threading8 units · waits overlap — fast
asyncio8 units · one thread, awaits overlap — fast
multiprocessing6 units · parallel, but overhead rarely worth it
compute (needs the GIL)waiting on I/O (GIL released)process startup

asyncio: doing nothing, efficiently

asyncio runs many I/O operations concurrently on a single thread. While one coroutine is awaiting a response, the event loop runs another. There are no threads to manage and no locks for shared state — but the rule is strict: never call a blocking function inside async code, or you freeze the entire loop.

import asyncio, httpx

async def fetch(client, url):
    r = await client.get(url)          # yields control while waiting
    return r.json()

async def main(urls):
    async with httpx.AsyncClient() as client:
        # fire all requests concurrently, gather when all complete
        return await asyncio.gather(*(fetch(client, u) for u in urls))

results = asyncio.run(main(urls))

Calling ten APIs sequentially takes the sum of their latencies; gather-ing them takes the max. When you’re fanning out to embedding and LLM endpoints, this is the difference between a snappy request and a sluggish one.

multiprocessing: real parallelism for real computation

When the work is genuinely CPU-bound, sidestep the GIL with processes:

from concurrent.futures import ProcessPoolExecutor

with ProcessPoolExecutor() as pool:
    results = list(pool.map(expensive_pure_function, chunks))

Each process has its own interpreter and GIL, so they run truly in parallel. The cost is that data must be serialized to cross the process boundary, so it pays off only when each task does substantial work.

A decision checklist

  1. Is the bottleneck waiting or computing? Profile before you guess.
  2. Waitingasyncio (or threads if you must use a blocking library).
  3. Computingmultiprocessing, or push the hot loop into a vectorized library like NumPy that releases the GIL under the hood.
  4. Neither is enough? The fastest code is the code you don’t run — cache results, batch requests, and remove redundant work first.

Interview-ready summary: “Threads for I/O, processes for CPU, async for lots of I/O — because the GIL means Python threads don’t parallelize computation.”

When to reach for this

Variations

Try it

These exercises are about choosing the right execution model, not sprinkling async everywhere.

ExerciseEasy
  • asyncio
  • gather

Convert sequential awaits to gather

This code waits for each URL before starting the next one. Rewrite it so all requests are in flight at the same time.

async def fetch_all(client, urls):
    results = []
    for url in urls:
        results.append(await fetch_json(client, url))
    return results

Approach. Build one awaitable per independent request, then await them as a group. Keep ordering by using gather, which returns results in input order.

Show solution
import asyncio


async def fetch_all(client, urls):
    tasks = [fetch_json(client, url) for url in urls]
    return await asyncio.gather(*tasks)

This helps only because the work is I/O-bound and independent. If the service has rate limits, wrap fetch_json with a semaphore so concurrency stays bounded.

GotchaMedium
  • GIL
  • ProcessPoolExecutor

Fix CPU-bound work inside async code

An async endpoint calls score_document(doc) for hundreds of documents. The function is pure Python and CPU-heavy. Why does making the wrapper async not help, and what should you use instead?

Approach. async helps while waiting. CPU-bound Python holds the GIL and blocks the event loop. Put substantial CPU work in a process pool or move it to a native library that releases the GIL.

Show solution
import asyncio
from concurrent.futures import ProcessPoolExecutor


def score_document(doc: str) -> float:
    return expensive_python_scoring(doc)


async def score_documents(docs: list[str]) -> list[float]:
    loop = asyncio.get_running_loop()
    with ProcessPoolExecutor() as pool:
        tasks = [loop.run_in_executor(pool, score_document, doc) for doc in docs]
        return await asyncio.gather(*tasks)

Processes have overhead because arguments and results must cross process boundaries. Use this for chunky CPU work, not tiny functions called millions of times.

ExerciseMedium
  • Threads
  • Processes

Choose threads or processes

You have two tasks: calling a blocking vendor SDK for 200 prompts, and computing image hashes for 200 large images in pure Python. Pick the concurrency tool for each.

Approach. Blocking I/O can use threads because waiting releases the GIL. Pure Python CPU work needs processes for real parallelism.

Show solution
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor


def call_vendor(prompt: str) -> dict:
    return blocking_sdk.generate(prompt)


def hash_image(path: str) -> str:
    return pure_python_image_hash(path)


with ThreadPoolExecutor(max_workers=20) as threads:
    responses = list(threads.map(call_vendor, prompts))

with ProcessPoolExecutor() as processes:
    hashes = list(processes.map(hash_image, image_paths))

Threads are the pragmatic adapter for blocking I/O libraries. Processes are the GIL escape hatch for CPU-bound Python. If hashing can move to a vectorized/native library, measure that before adding process orchestration.

Your progressNot started