Readable Python Chapter 8 · Concurrency and the GPUContents

Concurrency and the GPU

Few threads, one owner per thread, overlap only what overlaps.

Concurrency is where readable code goes to die. Every extra thread, task or stream multiplies the orders in which things can happen. Use as little as the problem allows, and give each concurrent actor one clear job.

RP11

Each piece of mutable state belongs to one thread. Hand off with queues.

If only one thread ever touches the scheduler's state, it needs no locks and can be read like sequential code. Threads talk by passing immutable messages.

RP12

Only overlap work that uses different hardware, and prove it with a profile.

Concurrency helps when two things can really run at once: CPU next to GPU, a copy engine next to the SMs. Two compute kernels fighting over the same saturated SMs gain nothing and add jitter.

Shared state behind a lock

The usual first version of a generation server: HTTP handler threads and a generation loop share one dict, and a global lock keeps them from colliding.

Before
SEQUENCES: dict[str, dict] = {}
LOCK = threading.Lock()

def handle_generate(body: dict) -> str:          # runs on many HTTP threads
    with LOCK:
        SEQUENCES[body["id"]] = {"prompt": body["prompt"], "out": [], "done": False}
    while True:
        with LOCK:
            if SEQUENCES[body["id"]]["done"]:
                return "".join(SEQUENCES.pop(body["id"])["out"])
        time.sleep(0.01)

def generation_loop(model):                       # runs on one background thread
    while True:
        with LOCK:
            batch = [s for s in SEQUENCES.values() if not s["done"]]
            step(model, batch)                    # GPU call while holding the lock
After
def start(model: Model, tokenizer: Tokenizer, max_batch: int) -> Server:
    requests, results, stop = RequestQueue(), ResultQueue(), Event()
    scheduler = Scheduler(model, tokenizer, max_batch)
    threading.Thread(
        target=scheduler.run, args=(requests, results, stop), name="scheduler", daemon=True
    ).start()
    return Server(requests, results, stop)

async def handle_generate(server: Server, body: dict) -> str:
    request = Request.parse(body, server.tokenizer)
    server.requests.put(request)
    return (await server.results.wait_for(request.id)).text

In the "before" version, every reader has to check that every access holds the lock. The GPU call holds it too, so new requests block for a whole step, and the handlers busy-wait. In the "after" version, the scheduler thread is the only owner of sequence state. Its code (chapter 2) contains no locks at all. Handlers only put a request on one queue and await a result from the other, and the only thread-safety lives in those two queue classes, which are easy to test on their own.

The basic shape for a serving process

GPU streams

A CUDA stream is an ordering queue, not extra compute. Work on a second stream runs concurrently only when the first leaves SMs idle. A well-written forward pass (large fused kernels, ideally captured in a CUDA graph) keeps every SM busy, so:

Guidelines

Never block the event loop

Anything that takes more than about a millisecond (model calls, tokenizing a long prompt, big serialization) doesn't run on the event loop. Send it to the scheduler thread, or to a dedicated thread for that kind of work.

Threads have names and owners

Every thread you start is named (name="scheduler"), and it's obvious from the code which object it runs and what state it owns. No anonymous pools for long-lived work.

Stop with an event, not cancellation

A blocking GPU call can't be interrupted. Pass a threading.Event and check it at the top of the loop, so shutdown finishes the current step and exits cleanly.

Keep the profile in the PR

Any change that adds a thread, stream, pipeline stage or cache includes before/after numbers: a timeline screenshot (Nsight Systems, torch.profiler) and the step time. No numbers, no concurrency.