Index Ai Academy
Phase 7 · Software engineering · How computers run code

Concurrency and async

Do many things at once: processes, threads and the event loop.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Waiting is the problem
  2. Race conditions
  3. How async works: an event loop
  4. async and await in real Python
  5. 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

toolwhat it isgood forwatch out for
processesseparate programs, each with its own memoryCPU-bound work on many coresheavier to start; sharing data means copying it
threadsseveral paths through one program, sharing memoryI/O-bound work in older coderace conditions; in standard Python only one thread runs Python code at a time
asyncone thread that switches tasks at each awaitlots 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.

Quick check
An eval script sends 500 prompts to a model API, one after another, and takes 40 minutes. What helps most?
  1. Rewriting the loop with a list comprehension
  2. More CPU cores, because the work is CPU-bound
  3. A faster laptop
  4. Concurrency: keep many requests in flight at once (async or threads), within the rate limit
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)")
It runs right here in your browser, with nothing to install.

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.

Quick check
What does this print?
count = 0
read_a = count
read_b = count
count = read_a + 1
count = read_b + 1
print(count)
  1. 2
  2. 1
  3. 0
  4. 3
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.

Quick check
Two threads each read a counter of 10, add 1, and write the result back, with no lock. What can the counter end up as?
  1. Always 11
  2. Always 12
  3. 11 or 12, depending on timing
  4. An error is raised
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.

Try it now

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.run or start threads, so that block is for reading. The exercise builds the same scheduling logic by hand.

Quick check
Inside an async task, you call a slow function that never awaits, such as a 10-second CPU loop. What happens to the other tasks?
  1. The slow function is skipped
  2. They all freeze for 10 seconds, because the loop only switches at await
  3. They keep running on other threads
  4. Python moves the slow function to another core
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

Create a free account

Try a quiz question

What's the difference between concurrency and parallelism?

  1. Concurrency switches between tasks while they wait; parallelism runs tasks at the same instant on several cores
  2. They're two words for the same thing: running more than one task at once, on however many cores you have
  3. Parallelism is only for databases, which split queries across cores; concurrency is what everything else uses
  4. Concurrency needs a GPU to switch between tasks, while parallelism runs them in turn on one ordinary processor

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson