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 7:30–8:20
Tonight's ticket
#10 · Night ten
Trouble
A group booking for six tables. Table 3 cancels its course over an allergy, and the whole group decides to come back another day. Meanwhile the pots for the other five tables are still boiling.
Goal
When one table fails, stop all the other dishes right away, and never let any dish run past its time limit.
Late-night diner Dongsi
Half past ten, and a group fills six tables at once. Lou takes the orders and passes them to the kitchen, and the kitchen starts all six courses together. Ten minutes later, a hand goes up at table 3. Someone forgot to mention a peanut allergy. The head of the group says, "In that case, we'll all call it a night."
Minho doesn't hear and keeps the stew for table 5 bubbling. Sua turns off his burner. "Nobody ever made a rule that if one table drops out, everything stops."
Chef Yoon Seoyeon presses her timer. "Work that starts together has to end together. Tonight's job is making that promise in code."
Let's handle the group booking with Lesson 9's asyncio.gather. Table 2 fails after 0.1 seconds.
import asyncio
async def table(n, sec, fail=False):
await asyncio.sleep(sec)
if fail:
raise ValueError(f"T{n} allergy")
print(f"T{n} served")
async def main():
try:
await asyncio.gather(table(1, 0.3), table(2, 0.1, fail=True), table(3, 0.5))
except ValueError as e:
print("caught:", e)
await asyncio.sleep(0.6) # keep the place open a little longer
asyncio.run(main())
# caught: T2 allergy
# T1 served
# T3 servedgather passes the first exception straight up. That's why caught: prints first.served.gather(return_exceptions=True) just puts the exceptions in the result list; it doesn't stop the rest either. If you want "one fails, everything stops," you need a different tool.
asyncio.TaskGroup (3.11) handles tasks as one bundle. Managing tasks in bundles like this is called structured concurrency.
import asyncio
async def table(n, sec):
await asyncio.sleep(sec)
return f"T{n}"
async def main():
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(table(n, 0.1 * n)) for n in range(1, 4)]
# once you're here, all three tasks are done
print([t.result() for t in tasks])
asyncio.run(main())
# ['T1', 'T2', 'T3']A TaskGroup makes three promises.
async with block, every task created inside it is already finished. No separate await needed.ExceptionGroup and raises that.Since tg.create_task keeps hold of each task, you don't have to worry about Lesson 9's "task that vanishes because nobody kept a reference," either. Now, the group booking where table 3 fails.
import asyncio
async def table(n, sec, fail=False):
try:
await asyncio.sleep(sec)
if fail:
raise ValueError(f"T{n} allergy")
print(f"T{n} served")
except asyncio.CancelledError:
print(f"T{n} cancelled")
raise # re-raise the cancellation you received
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(table(1, 0.05))
tg.create_task(table(2, 0.3))
tg.create_task(table(3, 0.1, fail=True))
tg.create_task(table(4, 0.5))
except* ValueError as eg:
print("group failed:", eg.exceptions)
await asyncio.sleep(0.6)
asyncio.run(main())
# T1 served
# T2 cancelled (T2 and T4 can swap order from run to run)
# T4 cancelled
# group failed: (ValueError('T3 allergy'),)served lines appear. Unlike gather, no pots are secretly boiling in the background.asyncio.CancelledError is raised at the await. That's why you can catch it with except and clean up. Always re-raise a cancellation you catch (10-5).The timeline below runs a real TaskGroup on the server. The higher the table number, the slightly longer the dish takes (T1 0.3 s, T2 0.345 s, … T6 about 0.53 s). The table you pick as "Table that fails" fails right around when its own dish would have finished.
Kitchen timeline
Orders
6
Table that fails
None
Time limit (s)
off
Cooking
Waiting
Cancelled
Failed
Several tasks can also fail at the same time. That's why a TaskGroup raises a bundle of exceptions rather than a single one.
import asyncio
async def boom(exc, sec):
await asyncio.sleep(sec)
raise exc
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(boom(ValueError("T2 allergy"), 0.1))
tg.create_task(boom(KeyError("T5"), 0.1))
except Exception as e:
print(type(e).__name__, "|", e)
print(e.exceptions)
asyncio.run(main())
# ExceptionGroup | unhandled errors in a TaskGroup (2 sub-exceptions)
# (ValueError('T2 allergy'), KeyError('T5'))An ExceptionGroup (3.11) is an exception that holds a list of exceptions in .exceptions. To pick them out of the bundle by type, use except*.
eg = ExceptionGroup("kitchen", [ValueError("T2 allergy"), KeyError("T5"), ValueError("T6 salt")])
try:
raise eg
except* ValueError as g:
print("ValueError:", [str(e) for e in g.exceptions])
except* KeyError as g:
print("KeyError:", g.exceptions)
# ValueError: ['T2 allergy', 'T6 salt']
# KeyError: (KeyError('T5'),)except, only one block runs. With except*, every matching block runs. Above, both blocks run.g is always an ExceptionGroup: a smaller bundle holding just the matching exceptions.except* matches is raised again as a leftover bundle.eg = ExceptionGroup("kitchen", [ValueError("T2"), KeyError("T5")])
try:
try:
raise eg
except* ValueError:
print("handled ValueError")
except ExceptionGroup as rest:
print("left:", rest.exceptions)
print(isinstance(eg, Exception), eg.subgroup(ValueError).exceptions)
# handled ValueError
# left: (KeyError('T5'),)
# True (ValueError('T2'),)except* comes with two rules. In 3.14, breaking either one is a syntax error, caught before anything runs.
src1 = '''
def f():
try:
pass
except* ValueError:
return 1
'''
src2 = '''
try:
pass
except ValueError:
pass
except* KeyError:
pass
'''
for src in (src1, src2):
try:
compile(src, "<menu>", "exec")
except SyntaxError as e:
print("SyntaxError:", e.msg)
# SyntaxError: 'break', 'continue' and 'return' cannot appear in an except* block
# SyntaxError: cannot have both 'except' and 'except*' on the same 'try'return, break or continue inside an except* block. Several blocks have to run one after another, and jumping out halfway would break that. Store the result in a variable and return it outside the try.except and except* on one try. If you need both, split it into two nested trys. The engine behind this course's labs does exactly that.Chef's tip
If you feel like returning inside except*, write the result to a variable and return it outside the try. Same in the kitchen. You collect every incident report before you clock out. You don't hear the first one and walk out the door.
Tonight's second rule is "no dish runs past its time limit." asyncio.timeout() (3.11) puts a time limit on a whole block.
import asyncio, time
async def stew(sec):
await asyncio.sleep(sec)
return "stew"
async def main():
start = time.perf_counter()
try:
async with asyncio.timeout(0.2):
await stew(1.0)
except TimeoutError:
print(f"timeout after {time.perf_counter() - start:.2f}s")
try:
print(await asyncio.wait_for(stew(0.1), timeout=0.2))
print(await asyncio.wait_for(stew(1.0), timeout=0.2))
except TimeoutError:
print("wait_for: timeout")
print(asyncio.TimeoutError is TimeoutError)
asyncio.run(main())
# timeout after 0.20s
# stew
# wait_for: timeout
# TrueTimeoutError comes out of the block. Since 3.11, asyncio.TimeoutError is the same thing as the built-in TimeoutError.asyncio.wait_for(coroutine, timeout=) is the older way to put a limit on a single coroutine. It's handy when you want the result back directly.asyncio.timeout can wrap several awaits in one block, as in "20 minutes total, from taking the order to serving it."You can also set the limit as a point in time ("at 10:45") instead of "in so many seconds," and you can change it after entering the block.
import asyncio, time
async def main():
loop = asyncio.get_running_loop()
start = time.perf_counter()
deadline = loop.time() + 0.3 # a point in time: 0.3 s from now
try:
async with asyncio.timeout_at(deadline):
await asyncio.sleep(1)
except TimeoutError:
print(f"timeout_at: {time.perf_counter() - start:.1f}s")
start = time.perf_counter()
try:
async with asyncio.timeout(None) as cm: # no limit at first
cm.reschedule(loop.time() + 0.2) # set it to 0.2 s later on
print(cm.when() is not None)
await asyncio.sleep(1)
except TimeoutError:
print(f"reschedule: {time.perf_counter() - start:.1f}s, expired={cm.expired()}")
asyncio.run(main())
# timeout_at: 0.3s
# True
# reschedule: 0.2s, expired=Truetimeout_at(when) takes an absolute time on the loop.time() clock. It's good when several steps share a single deadline.timeout(None) starts with no limit. With cm.reschedule(when) you can set the limit later or push it back.TaskGroup and time limits end up doing the same thing: sending a cancellation to a task. To send one yourself, use task.cancel().
import asyncio
async def braise():
print("stove on")
try:
await asyncio.sleep(10)
finally:
print("stove off") # even if cancelled, turn the burner off
async def main():
task = asyncio.create_task(braise())
await asyncio.sleep(0.1)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("cancelled:", task.cancelled())
asyncio.run(main())
# stove on
# stove off
# cancelled: Truecancel() doesn't kill the task on the spot. It only makes CancelledError go off at the next await where the task is paused.try/finally. It always runs, whether the task was cancelled, succeeded or failed.CancelledError inherits from BaseException, not Exception. That's by design, so an except Exception: doesn't swallow a cancellation by accident.What happens if you swallow the cancellation?
import asyncio, time
async def stubborn():
try:
await asyncio.sleep(0.5)
except asyncio.CancelledError:
pass # swallowed the cancellation
await asyncio.sleep(0.5) # and kept on cooking
return "late"
async def main():
start = time.perf_counter()
try:
async with asyncio.timeout(0.1):
print(await stubborn())
except TimeoutError:
print("timeout")
print(f"{time.perf_counter() - start:.1f}s")
asyncio.run(main())
# late
# 0.6sWe set a 0.1-second limit, yet it took 0.6 seconds and no TimeoutError was raised. A time limit works through cancellation, and the coroutine ate that cancellation. A TaskGroup's "cancel the rest" falls apart in exactly the same way. So if you catch CancelledError, do your cleanup and raise it again (raise).
Chef's tip
When you get a cancel, turn off the burner, wipe the board, and put your hands up. The moment you think 'just a bit more' and keep it boiling, every rule in this kitchen falls apart. Minho, I'm talking to you.
Some work, like saving a receipt, must not be cut off halfway. asyncio.shield() keeps a cancellation from outside from reaching the task inside.
import asyncio
async def save_receipt():
await asyncio.sleep(0.3)
print("receipt saved")
return "ok"
async def main():
inner = asyncio.create_task(save_receipt())
try:
async with asyncio.timeout(0.1):
await asyncio.shield(inner)
except TimeoutError:
print("waiter gave up")
print(await inner) # the inner task kept running
asyncio.run(main())
# waiter gave up
# receipt saved
# okThe side that was waiting gave up at 0.1 seconds, but the inner task ran to the end. You have to keep a reference to the inner task to collect its result later.
Task.cancelling() returns the number of cancellation requests the task has received, and Task.uncancel() lowers that number by one. asyncio.timeout and TaskGroup use them internally to tell "is this cancellation one I sent?" You'll rarely need them yourself.
import asyncio
async def worker():
try:
await asyncio.sleep(1)
except asyncio.CancelledError:
me = asyncio.current_task()
print("cancelling:", me.cancelling())
me.uncancel()
print("after uncancel:", me.cancelling())
return "rescued"
async def main():
t = asyncio.create_task(worker())
await asyncio.sleep(0.05)
t.cancel()
print(await t)
asyncio.run(main())
# cancelling: 1
# after uncancel: 0
# rescuedNow both of tonight's rules go into one kitchen. Each table gets a 0.6-second limit, and all six tables are bundled into one TaskGroup.
import asyncio, time
async def table(n, sec, allergy, stopped):
try:
async with asyncio.timeout(0.6): # no dish runs past 0.6 s
await asyncio.sleep(sec)
if n == allergy:
raise ValueError(f"T{n} allergy")
return f"T{n}"
except asyncio.CancelledError:
stopped.append(f"T{n}") # burner off, hands up
raise
async def service(menu, allergy=None):
start, stopped = time.perf_counter(), []
try:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(table(n, s, allergy, stopped)) for n, s in menu.items()]
print("served:", [t.result() for t in tasks])
except* ValueError as eg:
print("cancelled by:", [str(e) for e in eg.exceptions], "stopped:", sorted(stopped))
except* TimeoutError as eg:
print("too slow:", len(eg.exceptions))
print(f"{time.perf_counter() - start:.1f}s")
MENU = {1: 0.30, 2: 0.35, 3: 0.20, 4: 0.40, 5: 0.45, 6: 0.50}
asyncio.run(service(MENU))
asyncio.run(service(MENU, allergy=3))
asyncio.run(service({**MENU, 6: 0.9}))
# served: ['T1', 'T2', 'T3', 'T4', 'T5', 'T6']
# 0.5s
# cancelled by: ['T3 allergy'] stopped: ['T1', 'T2', 'T4', 'T5', 'T6']
# 0.2s
# too slow: 1
# 0.6sTimeoutError comes up in the bundle, and again the rest stop.Near closing, the guest by the window finishes her bowl of noodles and closes her small notebook. As she pays, she says a few words to the chef: "I'll be back next Friday."
The chef nods and turns to the sous-chef. "Cancellations and time limits both happen at an await. But the old oven in the corner doesn't know what await is. If Lou stands in front of it and waits, what happens to the floor?"
Service log — Night ten. Bundled the group's six tables into one TaskGroup; when table 3 dropped out, the other pots went off at the same moment. Put a time limit on every table, and pinned the rule "never swallow a cancellation" to the kitchen wall.
gather example from 10-1 with a TaskGroup so that when table 2 fails, tables 1 and 3 print cancelled.table from the closing-time code, delete raise and put await asyncio.sleep(0.3) in its place (ignoring the cancellation and boiling on), then run it with allergy=3. See how the elapsed time changes, and write down what went wrong.Chef's recipe card
gather doesn't stop the rest when one fails. Bundle work that must end together in a TaskGroup (3.11).
async with asyncio.TaskGroup() as tg: tg.create_task(...) — when the block ends everything is done, and if one fails the rest are cancelled.
Failures arrive together as an ExceptionGroup. Catch them by type with except* ValueError (no return, no mixing with except).
Time limits: async with asyncio.timeout(seconds) or wait_for(coroutine, timeout=). Going over raises the built-in TimeoutError.
Cancellation arrives as CancelledError at an await. Clean up with finally, and raise a caught cancellation again.
Work that must not be cut off: asyncio.shield(task). cancelling()/uncancel() are for people building tools.
← Previous
Lesson 9 · One floor manager for every table: first steps in asyncio
Next →
Lesson 11 · The floor freezes: blocking calls and mixing tools
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.