Concurrency in the Late-Night Kitchen
·
Course overview
1
The first night's queue: concurrency and parallelism
2
Hiring more cooks: threading basics
3
The chalkboard is wrong: race conditions and Lock
4
Only three burners: synchronization tools
5
Knives and boards: deadlock
6
The ticket rail: queue
7
Agency cooks: concurrent.futures
8
Why the dough won't go faster: the GIL and parallelism
9
One floor manager for every table: first steps in asyncio
10
The group booking falls through: structured concurrency
11
The floor freezes: blocking calls and mixing tools
12
The critic's night: putting it all together
Time 6:40–7:30
Tonight's ticket
#09 · Night nine
Trouble
First night of the new floor plan. One server, Lou, and ten tables. If she waits at the kitchen for one table's food, the other nine tables grind to a halt.
Goal
Get food out to all ten tables in under 0.5 seconds without adding staff.
Late-night diner Dongsi
From tonight the floor works differently. There's one person on it: Lou. Ten tables. The chef hasn't hired anyone else.
Lou never stops circling the dining room. She takes an order, passes it to the kitchen, and instead of waiting for the food, heads straight to the next table. When the kitchen bell rings, she picks up that plate and takes it out. One lap, then another. Because she runs the same loop over and over, reacting to bells (events), the cooks gave her a nickname on her very first day: the Event Loop.
Tying her apron strings, Lou says, "What I hate most is standing at a table, just waiting. While I wait, the other tables are waiting for me."
The chef nods. "Up to yesterday, we added people. Tonight it's about how one person uses the time spent waiting. In Python it's called asyncio."
Put async in front of a function and it becomes a coroutine function. Let's start with the strange part: calling it doesn't run it.
import asyncio
async def take_order(table):
print("order", table)
return f"T{table}"
c = take_order(1)
print(type(c).__name__)
print(c)
# coroutine
# <coroutine object take_order at 0x...>
# <sys>:0: RuntimeWarning: coroutine 'take_order' was never awaitedtake_order(1) didn't print "order 1". Not a single line of the function body ran.was never awaited at the end. When you see that warning, you've almost always forgotten an await.There are two ways to run the slip: at the top level, pass it to asyncio.run(); inside another coroutine, await it.
print(asyncio.run(take_order(1)))
# order 1
# T1
c = take_order(2)
asyncio.run(c)
asyncio.run(c)
# order 2
# RuntimeError: cannot reuse already awaited coroutineA coroutine object can only be used once. To take the same order again, call take_order(2) again to write a new slip.
await means "I'll wait right here until this is done; meanwhile the loop is free to do other things." You can only use await inside async def. Use it inside a plain def and you get SyntaxError: 'await' outside async function.
import asyncio
import time
async def table(n, wait):
print(f"T{n} order")
await asyncio.sleep(wait) # Lou is free while waiting on the kitchen
print(f"T{n} served")
return f"T{n}"
async def one_by_one():
a = await table(1, 0.3)
b = await table(2, 0.3)
return [a, b]
start = time.perf_counter()
print(asyncio.run(one_by_one()), f"{time.perf_counter() - start:.2f}s")
# T1 order
# T1 served
# T2 order
# T2 served
# ['T1', 'T2'] 0.62sasyncio.sleep(0.3) is the asyncio version of time.sleep(0.3). While it waits, it hands the turn over to the loop.await table(1, ...) has to finish before the next line runs. It's like Lou standing at table 1 until the food comes out.await doesn't mean "at the same time"; it means "wait." To wait on several things at once, you need the tools in the next section.asyncio.run(main()) is the entry point of the program.
main() coroutine until it finishes.You usually call it once in a program, at the top level only. Call it again where a loop is already running and you get RuntimeError: asyncio.run() cannot be called from a running event loop.
The event loop is a loop running in a single thread. What it does is exactly what Lou does on the floor.
await.Hand her three tables at once (with gather, coming up next) and Lou's route looks like this. Table 1's dish takes 0.3 seconds, table 2's 0.2, and table 3's 0.1.
| Time | What Lou does | Output |
|---|---|---|
| 0.0 s | puts in T1's order and steps away at await | T1 order |
| 0.0 s | puts in T2's order and steps away | T2 order |
| 0.0 s | puts in T3's order and steps away | T3 order |
| 0.0–0.1 s | nothing to do, so she waits for the earliest bell | |
| 0.1 s | T3's bell. She takes the plate out | T3 served |
| 0.2 s | T2's bell | T2 served |
| 0.3 s | T1's bell | T1 served |
Remember two things.
await. The operating system can interrupt a thread at any moment (Lesson 3), but a coroutine steps away only when it awaits. So the code between two awaits is never interrupted midway.asyncio.gather(coroutine, coroutine, ...) puts several coroutines on the loop at once and waits until all of them are done.
async def together():
return await asyncio.gather(table(1, 0.3), table(2, 0.2), table(3, 0.1))
start = time.perf_counter()
print(asyncio.run(together()), f"{time.perf_counter() - start:.2f}s")
# T1 order
# T2 order
# T3 order
# T3 served
# T2 served
# T1 served
# ['T1', 'T2', 'T3'] 0.31sexecutor.map in Lesson 7.gather(*coros).On the lab below, switch between "await table by table" and "All at once with gather." One row is one table, and every bar is in the "Waiting" color: Lou hardly spends any time actually working; nearly all of it is waiting on the kitchen.
Kitchen timeline
Orders
6
Cooking
Waiting
Cancelled
Failed
If one of the coroutines passed to gather raises an exception, gather raises it as is. To get every result back, pass return_exceptions=True.
async def table(n):
await asyncio.sleep(0.1 * n)
if n == 2:
raise ValueError(f"T{n} wrong dish")
return f"T{n}"
async def main():
results = await asyncio.gather(table(1), table(2), table(3), return_exceptions=True)
print(results)
try:
await asyncio.gather(table(1), table(2), table(3))
except ValueError as e:
print("ValueError:", e)
asyncio.run(main())
# ['T1', ValueError('T2 wrong dish'), 'T3']
# ValueError: T2 wrong dishreturn_exceptions=True, an exception takes up its own slot in the result list. Check the results one by one with isinstance(r, Exception).False), even when an exception comes up, the other tables are not cancelled and keep going. "If one fails, stop the rest" is what TaskGroup does for you in Lesson 10.Chef's tip
A 'was never awaited' warning means you wrote up the ticket and never handed it to the kitchen. If you call a coroutine function, always await it or turn it into a task. Otherwise that dish never comes out.
asyncio.create_task(coroutine) wraps the coroutine in a task and schedules it on the loop right away. The task starts when Lou has a free hand (when the current coroutine awaits). It doesn't wait for the task to finish before moving to the next line, so you can put an order in first and get on with other work.
async def table(n, wait):
print(f"T{n} order")
await asyncio.sleep(wait)
print(f"T{n} served")
return f"T{n}"
async def main():
start = time.perf_counter()
t1 = asyncio.create_task(table(1, 0.3))
t2 = asyncio.create_task(table(2, 0.2))
print("Lou: 2 tasks created")
await asyncio.sleep(0.1) # Lou does something else for a moment
print("Lou: back", t1.done(), t2.done())
print(await t1, await t2, f"{time.perf_counter() - start:.2f}s")
asyncio.run(main())
# Lou: 2 tasks created
# T1 order
# T2 order
# Lou: back False False
# T2 served
# T1 served
# T1 T2 0.31s"Lou: 2 tasks created" prints before T1 order. A task is scheduled the moment it's created, but it first runs when main steps away at an await.await t1 waits for a task that's already running to finish. The two tasks waited together, so it takes about 0.3 seconds, not 0.5.gather wraps the coroutines it receives in tasks internally. Use gather when you just want the results collected; use create_task when you want to hold on to a task, check its state, or wait for it later.The event loop holds tasks only by weak reference. A task you don't keep in a variable or collection can be garbage-collected before it finishes. Even for jobs where you don't need the result, like refilling water, keep hold of a reference.
background = set()
async def refill_water(table):
await asyncio.sleep(0.1)
print(f"T{table} water")
async def main():
for n in (1, 2, 3):
task = asyncio.create_task(refill_water(n))
background.add(task) # keep hold of a reference
task.add_done_callback(background.discard) # removes itself when done
print("pending", len(background))
await asyncio.sleep(0.2)
print("pending", len(background))
asyncio.run(main())
# pending 3
# T1 water
# T2 water
# T3 water
# pending 0A line that throws the result away, like asyncio.create_task(refill_water(n)), usually works fine, but "usually" is the problem. A task that occasionally vanishes is the hardest kind of bug to track down.
Both mean "wait a moment," but to Lou they're completely different things.
async def polite(n):
await asyncio.sleep(0.3) # hands the turn to the loop while waiting
async def stubborn(n):
time.sleep(0.3) # stops the whole loop
async def main(fn):
start = time.perf_counter()
await asyncio.gather(*(fn(n) for n in range(3)))
print(fn.__name__, f"{time.perf_counter() - start:.2f}s")
asyncio.run(main(polite))
asyncio.run(main(stubborn))
# polite 0.32s
# stubborn 0.90sawait it, asyncio.sleep tells the loop "wake me in 0.3 seconds" and steps away.time.sleep stops the thread itself. The loop runs in just one thread, so it's like Lou standing at a table, just waiting. Meanwhile nobody looks after the other tables.gather doesn't help.Not just time.sleep: long-running calculations and old libraries that don't hand over the turn while they wait stop the loop in exactly the same way. That problem and its fix are the subject of Lesson 11.
Three similar names come up in asyncio.
| Name | In kitchen terms | What it does |
|---|---|---|
| coroutine | a ticket slip | created when you call an async def. Doesn't run on its own |
| Task | an order handed to the kitchen | wraps a coroutine and puts it on the loop. done(), result(), cancel() |
| Future | a bell that will ring later | "a slot where a value will arrive someday." A Task is a kind of Future |
print(issubclass(asyncio.Task, asyncio.Future)) # True
async def main():
coro = take_order(1)
task = asyncio.create_task(coro)
print(type(coro).__name__, type(task).__name__) # coroutine TaskThe idea is the same as concurrent.futures.Future from Lesson 7. The difference is that an asyncio future can be awaited, and is used only within the same loop. You'll rarely create a future yourself; coroutines and tasks are usually all you need.
Threads are created by the operating system, so each one comes with stack memory and OS bookkeeping. A task is just one Python object.
async def table(n):
await asyncio.sleep(0.5)
return n
async def main():
return await asyncio.gather(*(table(n) for n in range(10_000)))
# 10,000 tasks: about 0.57 s (10,000 tables each waiting 0.5 s)
# 1,000 threads: about 0.62 s (1,000 threads each waiting 0.5 s)
# measured on this PCStarting 10,000 tasks costs less than starting 1,000 threads. That's why asyncio is a good fit when there are thousands or tens of thousands of waiting jobs (a chat server, sending requests to lots of URLs).
Tonight's goal was to get food out to all ten tables in under 0.5 seconds without adding staff. One Lou, one event loop, one gather, and you're done.
import asyncio
import time
async def table(n):
await asyncio.sleep(0.3) # waiting for the food to come out of the kitchen
return f"T{n}"
async def hall():
return await asyncio.gather(*(table(n) for n in range(1, 11)))
start = time.perf_counter()
served = asyncio.run(hall())
print(served)
print(f"{time.perf_counter() - start:.2f}s")
# ['T1', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'T8', 'T9', 'T10']
# 0.30sNot a single thread was created. Ten 0.3-second tables finished in about 0.3 seconds.
| Threads (Lessons 2–7) | asyncio (from tonight) | |
|---|---|---|
| Who does the work | several cooks | Lou alone |
| When turns change | whenever the OS decides | only at await |
| Cost of one | large (an OS thread) | small (a Python object) |
| Watch out for | race conditions, deadlock | blocking calls, a missing await |
Lou sets down her empty tray. "Ten tables, done in one lap." The chef looks at her timer and asks, "Good. But tomorrow there's a group booking. If one of the six tables cancels, how are you going to stop the food for the other five?"
Service log — Night nine. Lou covered all ten tables alone. Stepping away while waiting (await) and waiting on everything at once (gather) got them served in 0.3 seconds. Tomorrow: a group booking.
take_order from 9-1 without await and a line that runs it with asyncio.run(). Compare the output and the warnings.await asyncio.sleep(0.1) from main in 9-5, how does the output order change? Write down your prediction first, then run it to check.Chef's recipe card
async def makes a coroutine. Just calling it doesn't run it (the 'was never awaited' warning).
asyncio.run(main()): opens a loop, runs main to completion, closes it. Once, at the top level.
await: waits until done, handing the turn to the loop. Turns change only at await.
await asyncio.gather(*coros): wait at the same time; results come back in the order passed in. return_exceptions=True.
task = asyncio.create_task(coro): hand it off early. Keep hold of a reference (weak references).
asyncio.sleep yields; time.sleep stops the loop.
coroutine (a slip) → Task (an order handed in) ⊂ Future (a bell that rings later). Even 10,000 tasks are light.
← Previous
Lesson 8 · Why the dough won't go faster: the GIL and parallelism
Next →
Lesson 10 · The group booking falls through: structured concurrency
Get news about new lessons
We'll email you when this course gets new lessons or a follow-up course opens.
R
ReflexKR
Learn Python and Reflex by doing. Every example was run and verified on Reflex 0.10.0.
ReflexKR is free, with no banner ads. If it helped you, you can support it with a coffee. ☕ Buy us a coffee
Affiliate disclosure© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
Questions & answers
Ask anything about this lesson. Questions from every language version of this page appear together.