Topic 12.1
Concurrency vs Parallelism: Threads, Processes, Async
In one line
Concurrency means several tasks are in progress at once, even if only one runs at any instant; parallelism means several really run at the same instant. Python gives you three tools (threads, processes and asyncio), and the right one depends on whether your program mostly waits (I/O-bound) or mostly calculates (CPU-bound).
Think of it like this
One cook in a kitchen. She puts rice on the stove, and while it cooks she chops vegetables, and while the oven heats she washes the pans. She never does two things with her hands at the same instant, yet three dishes are in progress together because she does useful work during the waiting. That is concurrency. Now add a second cook with a second stove: two things really happen at the same instant. That is parallelism.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Concurrency
- Several tasks are in progress at the same time, though maybe only one runs at any instant.
- Parallelism
- Several tasks really run at the same instant on different CPU cores.
- I/O-bound
- Spends most of its time waiting for the network, a disk or a database.
- CPU-bound
- Spends most of its time calculating, so the CPU is the bottleneck.
- Thread
- A flow of code inside a process. Threads in one process share the same memory.
- Process
- A running program with its own memory. Processes do not share variables.
- Event loop
- The asyncio loop that runs tasks and resumes each one when what it awaited is ready.
- LLM API
- A web service you send text to and get a language model's answer from. The call mostly waits for the reply.
- Coroutine
- A function made with
async def. It can pause at anawaitand be resumed later. - Cooperative switching
- A task keeps running until it chooses to wait (at an
await). - Preemptive switching
- The operating system pauses a thread at any moment to let another run.
Step by step
01Waiting is the thing to overlap
Run a slow job twice, one after the other, and the total is the sum of both waits. If the waits can overlap, the total is only the longest wait. Nothing got faster: the program just stopped wasting the waiting time.
02The same two jobs in code
Below, boil is a coroutine: a function written with async def that can pause and let others run. Topic 12.6 teaches async and await properly. For now read await asyncio.sleep(...) as "wait here without blocking anyone else". It stands in for waiting on a network.
Awaiting the jobs one by one runs them in order. asyncio.gather starts both and waits for both, so the short job finishes first.
Look at the order of the printed lines, not at any clock. In the second half both start lines come first, because the kettle gives way while it waits and the toaster gets its turn.
async def main():
results = await asyncio.gather(boil("kettle", 0.1), boil("toaster", 0.05))
print(results)03CPU-bound work cannot be overlapped by waiting
If a task never waits, there is no waiting time to reuse. Adding up ten million numbers in pure Python keeps one core 100% busy. Splitting it across threads does not add cores for Python code, and asyncio cannot help at all, because there is no await to switch at.
To use more cores you need more processes (Topic 12.5), or a library whose heavy loop is written in C (NumPy), or the free-threaded build (Topic 12.2).
04The three models side by side
Each model answers a different question: threads and asyncio overlap waiting; processes add cores. Many real systems mix them: an async web server that hands heavy maths to a process pool.
05Cooperative vs preemptive, in one picture
With threads, the operating system can stop a thread between any two steps, so two threads can interleave inside one line like x += 1 (Topic 12.3 shows exactly how this breaks). With asyncio, switching happens only at await, so between two awaits your code runs alone. That single rule removes a whole class of bugs, and creates one new one: code that blocks, such as time.sleep(1), blocks every task.
06What to expect from this phase
Topics 12.2 to 12.5 cover the thread and process side; Topics 12.6 to 12.8 cover asyncio in depth; Topic 12.9 is the decision guide. You will meet this again in Phase 16, where async LLM clients, retries and streaming responses are built from exactly these pieces.
On this page the Run button runs Python inside your browser. The browser sandbox cannot start threads or processes, so those topics mark which examples are live and which were run on a normal Python.
Try it yourself
- 1
Make the waits longer
In the first example change the kettle's wait to
0.2and the toaster's to0.01. Predict whichdoneline comes first in the "together" half, then run it. The order depends only on the waits, never on the order you wrote the calls. - 2
Add a third job
Add a third
boil("oven", 0.07)to thegathercall. Where does itsdoneline appear? How manystartlines come before the firstdone? - 3
Classify your own programs
In the second example add three jobs you know from your own life of coding (downloading a file, sorting a million numbers, asking a chatbot a question) and decide the three flags for each. If you cannot decide, ask: "if I gave this a faster internet connection, would it speed up? If I gave it a faster CPU?"
Code & diagrams
Watch the order of the lines. In the second half both jobs start before either finishes.
Expected output
--- one after another ---
kettle: start
kettle: done
toaster: start
toaster: done
--- together ---
kettle: start
toaster: start
toaster: done
kettle: done
results: ['kettle', 'toaster']A toy version of the decision table. Change a job's flags and predict the answer first.
Expected output
1000 LLM API calls -> asyncio
resize 500 photos with pure Python -> processes (or NumPy)
read 20 files -> threads (or asyncio)
add up a list of 100 numbers -> plain code: no concurrency neededA realistic shape: one page needs data from several services. The sleeps stand in for network replies. Read the order of the lines; the page is ready when the slowest service answers.
Expected output
weather: request sent
orders: request sent
prices: request sent
prices: reply received
weather: reply received
orders: reply received
sections: ['orders', 'prices', 'weather']
orders: {'open': 4, 'late': 1}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Block the loop with time.sleep
In boil, replace await asyncio.sleep(seconds) with time.sleep(seconds) (and add import time). The program is still correct, but look at the order.
import asyncio
import time
async def boil(name, seconds):
print(f"{name}: start")
time.sleep(seconds) # blocking sleep: nobody else can run meanwhile
print(f"{name}: done")
async def main():
await asyncio.gather(boil("kettle", 0.1), boil("toaster", 0.05))
asyncio.run(main())Myth vs fact
Myth
Concurrency and parallelism are the same thing.
Fact
Concurrency is about structure (many tasks in progress); parallelism is about execution (many running at the same instant). asyncio is concurrent but not parallel.
Myth
More threads always make a program faster.
Fact
Only if the program spends time waiting. For pure-Python calculation, extra threads add switching cost and no speed (Topic 12.2).
Myth
asyncio makes code run on several cores.
Fact
asyncio uses exactly one thread, so one core. It wins by using the waiting time, not by adding cores.
Myth
Async is only for web servers.
Fact
Any program that waits on many things can use it: scripts that call APIs, scrapers, data loaders, agents that call tools.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Rule of thumb for the cost per unit: a coroutine costs a few kilobytes and is created in microseconds, so tens of thousands are normal. A thread costs a stack (megabytes of address space) and an operating-system object, so hundreds are normal. A process costs a whole interpreter, so you create about as many as you have cores.
- ▸
Real systems stack the models: an async server for the many waiting requests, a thread pool (
asyncio.to_thread) for the one blocking library, a process pool for heavy maths, and native libraries underneath. Choosing is per bottleneck, not per application. - ▸
Concurrency adds non-determinism: the order of events can change from run to run, which is why this phase's examples print order, not timings, and why concurrent code needs deliberate tests (Topic 12.9).
- ▸
A useful sizing rule is Little's law: the number of calls in flight is roughly the request rate multiplied by the latency of one call. To sustain 50 requests per second against an API that takes 0.8 s per call you need about 40 calls in flight. That number, not the core count, sets the size of a semaphore or a thread pool for I/O-bound work.
- ▸
asyncio removes races between
awaits, not races across them. A check-then-act sequence with anawaitin the middle (if key not in cache: cache[key] = await load(key)) can still run twice, because another task runs during theawait. Topic 12.7 shows anasyncio.Lockfor exactly this.
Remember this
- 1
A program is I/O-bound when most of its time is spent waiting for something outside the CPU: a network reply, a disk read, a database answer, an AI model generating text. It is CPU-bound when most of its time is spent calculating: resizing images, training a model in pure Python, hashing files. This one question decides almost everything in this phase, so ask it first about any slow program.
- 2
Concurrency helps I/O-bound programs. While one request waits for a reply, another can be sent. You do not need more CPU for that, because the CPU was idle anyway. Calling an LLM API takes about a second of waiting for a few milliseconds of your own work, so sending 100 calls together finishes in about the time of one call instead of 100.
- 3
Parallelism helps CPU-bound programs, but only if the work runs on several CPU cores at the same time. Python's threads cannot do this for pure-Python code (the GIL, Topic 12.2); separate processes can, because each process has its own interpreter and its own memory. Libraries like NumPy also run in parallel inside their C code. That is why vectorised maths (one command that works on a whole array of numbers at once, Topic 14.3) is often the best answer.
- 4
Python has three tools. Threads (
threading, Topic 12.3) run several flows of code inside one process, sharing memory; the operating system switches between them at any moment. Processes (multiprocessing, Topic 12.5) are separate programs with separate memory; they use all cores but cost more and must send data by copying. asyncio (Topics 12.6 to 12.8) runs many tasks on one thread, and a task only gives way at anawait, a point where it says "I am waiting, someone else can go". - 5
The three tools differ in who decides when to switch. Threads use preemptive switching: the operating system can interrupt a thread anywhere, even in the middle of
counter += 1, which is why threads need locks. asyncio uses cooperative switching: a task is never interrupted, it hands control back only atawait, which is why asyncio needs far fewer locks but one task that never awaits freezes everything. - 6
In the AI era most Python concurrency is I/O-bound: many model calls, many web pages, many database rows. That is why asyncio is the default for LLM applications, and why it is the longest part of this phase. CPU-bound work (embeddings, matrix maths) usually runs in NumPy, PyTorch or another native library that already uses many cores for you.
Explain it without notes
What is the difference between concurrency and parallelism? Which does asyncio give you?
What is the difference between I/O-bound and CPU-bound work? Give one AI-related example of each.
Why is asyncio called cooperative, and what is the danger of that?
A script calls an LLM API 500 times, one after another, and takes ten minutes. What would you change, and why does it work?
Practice
Classify each as I/O-bound or CPU-bound: (a) downloading 50 PDFs, (b) computing the 35th Fibonacci number with recursion, (c) reading 10,000 rows from a database over the network.
Using asyncio.gather, run three coroutines a, b, c that sleep 0.03, 0.01 and 0.02 seconds, and print their names in the order they finish.
Write a function choose(job) that returns "asyncio" when job is "many api calls", "processes" when it is "heavy maths", and "no concurrency" otherwise.
Trade-offs
- ↔
Threads: easy to start and work with blocking libraries, but they share memory, so you need locks, and they do not speed up pure-Python calculation.
- ↔
Processes: real parallelism and strong isolation, but heavy to start, and every argument and result must be copied between them.
- ↔
asyncio: very cheap tasks and few race conditions, but every library in the chain must be async-aware, and one blocking call stalls everything.
Done when you can
Done when you can explain concurrency vs parallelism with the kitchen analogy.
Done when you can tell whether a program is I/O-bound or CPU-bound with the faster-network or faster-CPU question.
Done when you can name the three Python models and say which kind of work each one helps.
Done when you can read the printed order of two overlapping coroutines and say why it came out that way.
Done when you can explain why
time.sleepinside a coroutine defeatsgather.