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 9:10–10:00
Tonight's ticket
#12 · Night twelve
Trouble
The guest by the window hands over a business card: food critic. Tonight she formally orders a twelve-course tasting menu. Courses 3, 7 and 11 take unusually long the first time around.
Goal
Serve all twelve courses, using no more than three burners, in under 3 seconds.
Late-night diner Dongsi
Friday, ten o'clock. As promised, the guest by the window walks in. Always alone, always one bowl of noodles, always the wristwatch and the small notebook. Tonight, as soon as she sits down, she lays a business card on the table. Food critic.
"I've been watching this kitchen for a few weeks now. Tonight I'll order properly. The twelve-course menu." The critic opens her notebook and takes off her wristwatch.
The kitchen goes quiet. Minho drops his knife, Sua straightens the ticket rail all over again, and Mr. Tak just wipes down the burners. Lou walks in with the ticket. Chef Yoon Seoyeon turns the timer around her neck back to zero. "A kitchen is a program. Tonight we run everything we've built."
Eleven nights have left us with a lot of tools. When a guest walks in, the first question is always the same: "Is this waiting work, or hands-on work?"
| Situation | Tool to reach for | In the kitchen | Lesson |
|---|---|---|---|
| A huge amount of waiting work (hundreds to tens of thousands of connections) | asyncio (gather, TaskGroup) | Lou, working the whole floor alone | 9–10 |
| Waiting work, but only synchronous libraries, a few dozen jobs | threading, ThreadPoolExecutor | several cooks, agency cooks | 2, 7 |
| Lots of computation (CPU) | ProcessPoolExecutor, multiprocessing (or threads on 3.14t) | branch kitchens | 8 |
| Old blocking code inside asyncio | asyncio.to_thread | someone else standing at the oven | 11 |
| Heavy computation inside asyncio | loop.run_in_executor(process pool, ...) | sending dough out to a branch | 11 |
| Several workers changing one value | a Lock, or a queue so there's a single owner | the chalkboard lock, the ticket rail | 3, 6 |
| No more than n at once | Semaphore(n) | three burners | 4, 11 |
| Must finish together and must not run over time | TaskGroup, asyncio.timeout | the group booking | 10 |
Choose in this order:
to_thread inside asyncio.Here is the critic's ticket. Twelve courses, 0.5 seconds each. But courses 3, 7 and 11 take three times as long (1.5 seconds) the first time, because the ingredients haven't fully thawed. On a second try they take 0.5 seconds. There are only three burners.
You need three parts: a semaphore to enforce the burner count, a time limit so no course drags on, and retries to redo a course that got cut off. All of it goes into one TaskGroup.
import asyncio, time
SLOW = {3, 7, 11} # courses that take three times as long the first time
async def plate(n, attempt):
await asyncio.sleep(0.5 * (3 if n in SLOW and attempt == 0 else 1))
async def order(n, stoves, limit_s, retries):
for attempt in range(retries + 1):
async with stoves: # cook only while holding a burner
try:
async with asyncio.timeout(limit_s):
await plate(n, attempt)
return True
except TimeoutError:
pass # put the burner down, go to the back of the line, try again
return False
async def service(stoves_n, limit_s, retries):
stoves = asyncio.Semaphore(stoves_n)
start = time.perf_counter()
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(order(n, stoves, limit_s, retries)) for n in range(1, 13)]
served = sum(t.result() for t in tasks)
print(f"stoves={stoves_n} limit={limit_s} retries={retries}: "
f"served {served}/12, {time.perf_counter() - start:.1f}s")
asyncio.run(service(3, None, 0)) # no time limit
asyncio.run(service(3, 0.6, 0)) # limit only
asyncio.run(service(3, 0.6, 1)) # limit + one retry
# stoves=3 limit=None retries=0: served 12/12, 3.5s
# stoves=3 limit=0.6 retries=0: served 9/12, 2.2s
# stoves=3 limit=0.6 retries=1: served 12/12, 2.7s
# (times vary by about 0.1 s between runs; the first line shows 3.5s or 3.6s)asyncio.timeout(None) means no limit. That's why the same function can run both "no limit" and "0.6-second limit."Two points about the design are worth noting.
async with stoves sits inside the for, a course that gets cut off releases its burner and lines up again. Retry while still holding the burner, and another course sits idle in the meantime.False, not raised as an exception. When an exception happens in a TaskGroup, all the others get cancelled (Lesson 10). That's right for a group booking, but in a tasting menu the other courses still have to go out even if one fails. For the same reason, collecting them with asyncio.gather would also work.In practice, you'll often rest a little longer between each retry, as in await asyncio.sleep(0.05 * 2 ** attempt). That keeps everyone from rushing back at the same moment (the same reason as Lesson 5's livelock).
The timeline below runs the same handler on the server. Each row is one course; an attempt cut off by the time limit is gray, and retries are tagged r1, r2. The starting settings are deliberately set so you miss the goal.
Kitchen timeline
Orders
6
Burners
2
Time limit (s)
off
Retries
0
Cooking
Waiting
Cancelled
Failed
Chef's tip
A time limit goes 'longer than a normal plate, shorter than a ruined one.' Cut off the normal plates too and no number of retries will save you. Before you pick a number, time how long it usually takes.
The critic's course has dough (computation), bread from the old oven (blocking) and burner dishes (waiting) all together. Hand each one to the right person, and Lou on the floor waits for them all. It uses processes, so save it to a file and run it.
import asyncio, threading, time
from concurrent.futures import ProcessPoolExecutor
def knead(n): # dough: hands-on work → branch kitchen (process)
return sum(i * i for i in range(n)) % 97
tally_lock = threading.Lock() # a ledger both the oven thread and the loop update
tally = {"plates": 0}
def count_plate():
with tally_lock: # no await and nothing long inside the lock
tally["plates"] += 1
def old_oven(item): # the old oven: blocking → thread
time.sleep(0.4)
count_plate()
return f"{item} baked"
async def stove_dish(n, stoves): # burner dishes: semaphore
async with stoves:
await asyncio.sleep(0.2)
count_plate()
return f"#{n}"
async def main():
loop = asyncio.get_running_loop()
stoves = asyncio.Semaphore(3)
with ProcessPoolExecutor(max_workers=2) as dough_room:
async with asyncio.timeout(5): # the deadline for the whole course
results = await asyncio.gather(
loop.run_in_executor(dough_room, knead, 3_000_000),
asyncio.to_thread(old_oven, "bread"),
*(stove_dish(n, stoves) for n in range(1, 7)),
)
print(results)
print(tally)
if __name__ == "__main__":
asyncio.run(main())
# [56, 'bread baked', '#1', '#2', '#3', '#4', '#5', '#6']
# {'plates': 7}| Job | Who handles it | Tool |
|---|---|---|
| The whole floor, all the waiting | Lou | asyncio.run, gather |
| Dough (computation) | branch kitchen | run_in_executor + ProcessPoolExecutor |
| The old oven (blocking) | a cook standing at the oven | asyncio.to_thread |
| Burner dishes | three burners | asyncio.Semaphore(3) |
| The ledger (shared state) | the chalkboard lock | threading.Lock (because a thread and the loop both use it) |
| The deadline | the chef's timer | asyncio.timeout(5) |
The ledger is updated from both the oven thread and the loop, so it uses a threading.Lock. It's fine for the loop side to take this lock because there's no await inside it: it changes one number and lets go right away. The accident from Lesson 11 happens when you yield with await while holding the lock.
So the kitchen doesn't freeze in front of the critic, check these six things before service.
| Check | Symptom | Fix | Lesson |
|---|---|---|---|
| Shared state | numbers are sometimes wrong | wrap read-to-write in a Lock, or use a queue so there's a single owner | 3, 11 |
| Lock order | it sometimes freezes forever | everyone locks in the same order, acquire(timeout=) | 5 |
| Blocking calls | asyncio is slow across the board | to_thread; find them with debug=True | 11 |
| Swallowed cancellation | time limits and TaskGroup don't work | after cleanup, raise the CancelledError again | 10 |
| Tasks with no reference | background work sometimes vanishes | collect the tasks and hold on to them, or use a TaskGroup | 9 |
| A queue that never ends | join() never returns | one task_done() per item taken, a closing signal (None, shutdown()) | 6, 11 |
Here is the fix for "tasks with no reference": keep the tasks in a set so they stay referenced, and have each one remove itself when it's done.
import asyncio
running = set() # where the tasks are held
async def wipe(table):
await asyncio.sleep(0.05)
print("wiped", table)
async def main():
for t in ("T1", "T2"):
task = asyncio.create_task(wipe(t))
running.add(task)
task.add_done_callback(running.discard) # removes itself when done
await asyncio.sleep(0.1)
print(len(running))
asyncio.run(main())
# wiped T1
# wiped T2
# 0"A queue that never ends" happens if you miss even one task_done(). When you suspect it, put a time limit on join() to check.
import asyncio
async def cook(q):
while True:
await q.get()
await asyncio.sleep(0.01)
# q.task_done() ← forgot this
async def main():
q = asyncio.Queue()
for n in range(3):
q.put_nowait(n)
worker = asyncio.create_task(cook(q))
try:
async with asyncio.timeout(0.3):
await q.join()
except TimeoutError:
print("join never finished:", q.qsize(), "in queue")
worker.cancel()
asyncio.run(main())
# join never finished: 0 in queueThe queue is empty (0 in queue), yet join() never finishes. The three tickets that were taken out never got marked "done."
The kitchens from past nights are gathered into one lab. Run the same eight orders, changing only the mode. The cooks and burners sliders only have an effect in the modes that use them.
Kitchen timeline
Orders
6
Cooks
3
Burners
2
Cooking
Waiting
Cancelled
Failed
The settings that nail the critic's course are 3 burners, a 0.6-second time limit, 1 retry. These values were checked by running them on the server's lab engine.
| Burners | Time limit | Retries | Served · Failed | Elapsed | Goal |
|---|---|---|---|---|---|
| 2 | 0.4 s | 0 | 0 · 12 | about 2.4 s | failed |
| 3 | off | 0 | 12 · 0 | about 3.5–3.6 s | over time |
| 4 | off | 0 | 12 · 0 | about 3.0 s | too many burners |
| 3 | 0.4 s | 2 | 0 · 12 | about 4.9 s | failed |
| 3 | 0.6 s | 0 | 9 · 3 | about 2.2 s | 3 courses failed |
| 3 | 0.6 s | 1 | 12 · 0 | about 2.7 s | success |
# with the service function from 12-2
asyncio.run(service(3, 0.6, 1))
# stoves=3 limit=0.6 retries=1: served 12/12, 2.7sThe twelfth course goes out at 2.7 seconds. The chef's timer stops. The critic writes a few lines in her notebook, finishes her bowl of noodles and stands up. At the counter she tears a page out of the notebook and hands it to the chef.
"All twelve courses came out without a single one going cold. There was waiting, but nobody stood around waiting. I'll be back. As a guest, this time."
The critic leaves, and the kitchen lights go off one by one. The chef takes the last recipe card from her apron pocket and hands it to the sous-chef. "That was twelve nights. Tomorrow at ten the doors open again. This time it's your turn to write the ticket. What goes on the first line?"
| Lesson | That night | The line that stays |
|---|---|---|
| 1 | The first night's queue: concurrency and parallelism | Separate waiting work from hands-on work. Concurrency takes turns; parallelism happens at the same instant. |
| 2 | Hiring more cooks: threading basics | Thread(target=), start(), join(). Bring the results back yourself. |
| 3 | The chalkboard is wrong: race conditions and Lock | Cut in between read and write and the number goes wrong. Keep with lock: short. |
| 4 | Only three burners: synchronization tools | Semaphore, Event, Condition, Barrier. |
| 5 | Knives and boards: deadlock | Lock in the same order, or put it down if you can't get the other. |
| 6 | The ticket rail: queue | Separate producers and consumers with a queue, and know when it's done with task_done() and join(). |
| 7 | Agency cooks: concurrent.futures | ThreadPoolExecutor, Future, as_completed and map. |
| 8 | Why the dough won't go faster: the GIL and parallelism | Computation goes to processes, or to 3.14t. |
| 9 | One floor manager for every table: first steps in asyncio | async def, await, gather, create_task. |
| 10 | The group booking falls through: structured concurrency | TaskGroup, except*, asyncio.timeout; re-raise cancellations. |
| 11 | The floor freezes: blocking calls and mixing tools | Blocking goes to to_thread; asyncio's tools stay in asyncio. |
Service log — Night twelve. Served all twelve of the critic's courses in about 2.7 seconds with 3 burners, a 0.6-second time limit and 1 retry. The floor on asyncio, the dough on processes, the old oven on a thread, the burners on a semaphore. Starting tomorrow, the sous-chef writes the tickets.
await asyncio.sleep(0.05 * 2 ** attempt)) to the handler from 12-2. Should the rest come before grabbing the burner, or after? Run both and compare the elapsed time.Chef's recipe card
Ask first: is it waiting work (asyncio, threads) or hands-on work (processes, 3.14t)?
Blocking inside asyncio goes to to_thread; heavy computation goes to run_in_executor (a process pool).
Resource limits: Semaphore. Time limits: asyncio.timeout. Work that ends together: TaskGroup.
Retry after putting the burner down, from the back of the line. A time limit is 'longer than normal, shorter than ruined.'
Checklist: shared state, lock order, blocking calls, swallowed cancellation, tasks with no reference, queues that never end.
A kitchen is a program. Draw the bars and you'll see it.
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.