Concurrency and async
Do many things at once: processes, threads and the event loop.
Create a free account Try part of it here
Why it matters
AI apps spend most of their time waiting: for a model to reply, a database to answer, a web page to load. Doing those waits one after another wastes almost all of it. Concurrency lets a program start the next request while the first is still in flight. That's how a chatbot serves many users, and how an eval runs 500 prompts in a minute instead of an hour. It's also where some of the hardest bugs live.
What you'll be able to do
- Tell concurrency from parallelism, and choose between processes, threads and async
- Explain a race condition and how a lock prevents it
- Build a small event loop from generators, the machinery under async and await
- Run many model calls at once while staying under a rate limit
What it covers
- Waiting is the problem
- Race conditions
- How async works: an event loop
- async and await in real Python
- Choosing the right tool
Waiting is the problem
Picture a cook making breakfast. A slow cook puts toast in, stares at the toaster until it pops, then starts the eggs. A good cook starts the toast, cracks the eggs while it toasts, and pours coffee while the eggs cook. Same single cook, much less time. That's concurrency: making progress on several tasks by switching between them while each one waits.
Parallelism is different: two cooks working at the same moment. On a computer, that means several processor cores each running code at once.
- Work that mostly waits (network calls, disk, databases) is called I/O-bound. It needs concurrency.
- Work that keeps the processor busy (number crunching, image resizing) is CPU-bound. It needs parallelism.
In plain words: if your program is mostly waiting, juggle. If it's mostly working, get more workers.
Three tools
| tool | what it is | good for | watch out for |
|---|---|---|---|
| processes | separate programs, each with its own memory | CPU-bound work on many cores | heavier to start; sharing data means copying it |
| threads | several paths through one program, sharing memory | I/O-bound work in older code | race conditions; in standard Python only one thread runs Python code at a time |
| async | one thread that switches tasks at each await | lots of I/O at once (thousands of requests) | one slow, non-awaiting step blocks everything |
That middle-row warning is the GIL (global interpreter lock). Think of a meeting with one talking stick, where only the person holding it may speak. In the standard build of Python, it lets only one thread run Python code at a time, so threads don't speed up CPU-bound Python. Python 3.13 added an optional "free-threaded" build without it, which is still being adopted.
Show the answer
Concurrency: keep many requests in flight at once (async or threads), within the rate limit
The script spends nearly all its time waiting on the network: it's I/O-bound. Overlapping the waits cuts the total time dramatically.
Race conditions
Two people share a tally on a whiteboard. Both read 10, both add one in their heads, and both write 11. Two people counted, and the board went up by one. When two threads share data, their steps can interleave in any order in the same way. Take count += 1. It looks like one step, but it's three: read count, add 1, write it back. If two threads both read before either writes, one update is lost. That's a race condition: the result depends on timing.
This simulation plays one unlucky interleaving step by step:
count = 0 steps = [ ("A", "read"), ("B", "read"), # both read 0 before either writes ("A", "write"), ("B", "write"), ] seen = {} for thread, action in steps: if action == "read": seen[thread] = count print(f"{thread} reads {count}") else: count = seen[thread] + 1 print(f"{thread} writes {count}") print("final count:", count, "(two increments, but one was lost)")
Race bugs are nasty because they're rare. The test passes a thousand times, then production loses an order. The fix is a lock, which works like the lock on a bathroom door: only the thread holding it may run the protected steps, so the read, add and write can't be split.
Locks bring their own risk. Picture two cars meeting on a narrow bridge, each waiting for the other to back up. A deadlock happens when thread A holds lock 1 and waits for lock 2, while B holds lock 2 and waits for lock 1. Both wait forever.
The safest plan is to share less: give each task its own data, and pass results back through a queue.
count = 0 read_a = count read_b = count count = read_a + 1 count = read_b + 1 print(count)
Show the answer
1
Both workers read 0 before either wrote, so both write 0 + 1, and count ends at 1. One update is lost. That's a race condition.
Show the answer
11 or 12, depending on timing
If both read 10 before either writes, both write 11 and one update is lost. A lock makes read-add-write happen as one step.
How async works: an event loop
A single waiter can serve many tables, because most of the time each table is waiting for the kitchen. Async code runs on one thread like that waiter, so there are no surprise interleavings. Tasks switch only where the code says await. The machine that does the switching is an event loop. Its core is small enough to build here, using generators.
A generator is a function with yield, and it keeps its place like a bookmark. Each yield pauses it and hands a value to whoever is running it. Calling next() resumes it from where it stopped. So a generator can say "I'm waiting; wake me later" with a yield, and a scheduler can run something else in the meantime:
def download(name, chunks): for i in range(chunks): print(f"{name}: chunk {i + 1} of {chunks}") yield # "I'm waiting for the network, run someone else" print(f"{name}: done") ready = [download("report.pdf", 2), download("photo.jpg", 3)] while ready: task = ready.pop(0) try: next(task) # run the task until its next yield ready.append(task) # it paused: put it back in line except StopIteration: pass # it finished
The two downloads take turns. Python's asyncio is this idea, grown up: await is a pause point like yield, and the loop wakes each task when its network data actually arrives.
Watch the ready list. Each task runs to its next yield, then goes to the back of the line. The log shows them interleaving.
async and await in real Python
Here's the shape of real async code. It sends several model requests at once, with a semaphore capping how many are in flight, to stay under the API's rate limit. A semaphore works like the door staff at a busy club, letting in only so many at a time:
import asyncio import httpx LIMIT = asyncio.Semaphore(8) # at most 8 requests in flight async def ask(client, prompt): async with LIMIT: r = await client.post("https://api.anthropic.com/v1/messages", json={...}, timeout=60) return r.json() async def main(prompts): async with httpx.AsyncClient(headers={...}) as client: return await asyncio.gather(*(ask(client, p) for p in prompts)) results = asyncio.run(main(prompts))
async def makes a coroutine, a function that can pause. await pauses it until the result is ready, letting the loop run others. asyncio.gather starts them all and waits for every result. The Anthropic Python SDK has an AsyncAnthropic client for exactly this.
Note: The academy's in-browser Python can't run
asyncio.runor start threads, so that block is for reading. The exercise builds the same scheduling logic by hand.
Show the answer
They all freeze for 10 seconds, because the loop only switches at await
Async is cooperative: one task must reach an await before another can run. Move CPU-heavy work to a process or thread pool.
Choosing the right tool
Threads won't speed up heavy calculation in standard Python. The global interpreter lock lets only one thread run Python code at a time. Threads help when your program is waiting on a network or a disk. For CPU-heavy work, use processes, or a library like NumPy that does its work outside that lock.
One slow call can freeze all your async code. If the only waiter stops to chat at one table, every other table waits. An event loop runs one task at a time, and it switches only at an await. Call something slow that doesn't await, such as time.sleep or requests.get, and every other task stops too. Use the async version, like await asyncio.sleep, or hand the slow call off to a thread.
Passing once doesn't prove a race is gone. Races depend on timing, so a test can pass a thousand times and fail on the next run. Don't rely on luck: protect shared data with a lock, or don't share it.
Limit how much you run at once. It's tempting to fire off 5,000 model calls together, but you'll hit rate limits, and most of them will fail. A semaphore caps how many run at the same time. Ten or twenty at once usually gets through a big job faster than 5,000 that fail and retry.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 6 review cards for spaced repetition
Try a quiz question
What's the difference between concurrency and parallelism?