R

ReflexKR

Reflex Docs

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

Concurrency in the Late-Night Kitchen
Lesson 12

Time 9:10–10:00

The critic's night: putting it all together

Tonight's ticket

#12 · Night twelve

The critic's night

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."

12-1. Which tool to reach for (8 min)

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?"

SituationTool to reach forIn the kitchenLesson
A huge amount of waiting work (hundreds to tens of thousands of connections)asyncio (gather, TaskGroup)Lou, working the whole floor alone9–10
Waiting work, but only synchronous libraries, a few dozen jobsthreading, ThreadPoolExecutorseveral cooks, agency cooks2, 7
Lots of computation (CPU)ProcessPoolExecutor, multiprocessing (or threads on 3.14t)branch kitchens8
Old blocking code inside asyncioasyncio.to_threadsomeone else standing at the oven11
Heavy computation inside asyncioloop.run_in_executor(process pool, ...)sending dough out to a branch11
Several workers changing one valuea Lock, or a queue so there's a single ownerthe chalkboard lock, the ticket rail3, 6
No more than n at onceSemaphore(n)three burners4, 11
Must finish together and must not run over timeTaskGroup, asyncio.timeoutthe group booking10

Choose in this order:

  1. Do you really need concurrency? If doing things one by one is fast enough, leave it. As Lesson 1 showed, concurrency isn't free.
  2. Waiting work or computation? Waiting work means asyncio or threads; computation means processes (or 3.14t). On a build with the GIL, splitting computation across threads doesn't make it faster (Lesson 8).
  3. Do the libraries you already use support async? If they do, go with asyncio. If not, start with threads, or wrap them with to_thread inside asyncio.
  4. What is shared? If there's shared state, add a lock or a queue; if a resource is limited, a semaphore; if things must finish together, a TaskGroup.

12-2. The critic's course handler (14 min)

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."
  • With no limit, the three slow courses each hold a burner for 1.5 seconds, so even though all twelve go out, it takes a little over 3.5 seconds.
  • With only a limit, the slow courses are cut off at 0.6 seconds and the burners free up sooner. But those three courses never go out at all.
  • With a limit and a retry together, a course that gets cut off puts its burner down and gets back in line. The second time it's done in 0.5 seconds, and all twelve courses go out.

Two points about the design are worth noting.

  • A retry puts the burner down first. Because 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.
  • Failure is returned as 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

The critic's twelve courses. Change the burners, time limit and retries to hit "Served 12 · Failed 0, 3 burners or fewer, under 3 seconds." With the starting settings, not a single course goes out.
  • With the starting settings (2 burners, 0.4-second time limit, 0 retries), after about 2.4 seconds you get Served 0 · Failed 12. Even an ordinary course takes 0.5 seconds, but the limit is 0.4, so every course gets cut off.
  • Raise the burners to 3 and only the retries to 2, and it gets worse. With the same 0.4-second limit, each course just gets cut off three times, so it's still Served 0, and the time stretches to about 4.9 seconds. Retries don't fix a time limit that's wrong. Even setting the limit to exactly 0.5 seconds, the same as an ordinary course, cuts them off by a hair and they all fail.
  • Turn the time limit off and everything is served, but even with 3 burners it takes a little over 3.5 seconds. Four burners bring it to about 3.0 seconds, which is cutting it close, and anyway, there are only three burners.
  • The page waits only a little over 5 seconds for the experiment. A slow setup like 2 burners with 2 retries may look cut off partway, so change the settings and run it again.

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.

12-3. Everything in one kitchen (8 min)

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}
JobWho handles itTool
The whole floor, all the waitingLouasyncio.run, gather
Dough (computation)branch kitchenrun_in_executor + ProcessPoolExecutor
The old oven (blocking)a cook standing at the ovenasyncio.to_thread
Burner dishesthree burnersasyncio.Semaphore(3)
The ledger (shared state)the chalkboard lockthreading.Lock (because a thread and the loop both use it)
The deadlinethe chef's timerasyncio.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.

12-4. The concurrency bug checklist (6 min)

So the kitchen doesn't freeze in front of the critic, check these six things before service.

CheckSymptomFixLesson
Shared statenumbers are sometimes wrongwrap read-to-write in a Lock, or use a queue so there's a single owner3, 11
Lock orderit sometimes freezes forevereveryone locks in the same order, acquire(timeout=)5
Blocking callsasyncio is slow across the boardto_thread; find them with debug=True11
Swallowed cancellationtime limits and TaskGroup don't workafter cleanup, raise the CancelledError again10
Tasks with no referencebackground work sometimes vanishescollect the tasks and hold on to them, or use a TaskGroup9
A queue that never endsjoin() never returnsone 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 queue

The queue is empty (0 in queue), yet join() never finishes. The three tickets that were taken out never got marked "done."

12-5. Running eleven nights' kitchens again (4 min)

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

8 orders, 0.3 s each. Run "Alone, one by one," "Several cooks," "Limited burners," "Agency cooks" and "All at once with gather" in turn, and compare the shape of the bars and the elapsed time.
  • Alone, one by one: a single row, about 2.4 seconds (Lesson 1).
  • 4 cooks: four overlapping rows, about 0.6 seconds (Lesson 2). Limit the burners to 2 and yellow waiting appears, about 1.2 seconds (Lesson 4).
  • Agency cooks: the later the order number, the shorter the order, so the returned order gets jumbled; about 0.4 seconds (Lesson 7).
  • gather: all eight tables overlap, about 0.3 seconds (Lesson 9). Whatever the number of cooks, with Lou alone.

Closing time (10 min)

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.

BurnersTime limitRetriesServed · FailedElapsedGoal
20.4 s00 · 12about 2.4 sfailed
3off012 · 0about 3.5–3.6 sover time
4off012 · 0about 3.0 stoo many burners
30.4 s20 · 12about 4.9 sfailed
30.6 s09 · 3about 2.2 s3 courses failed
30.6 s112 · 0about 2.7 ssuccess
# 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.7s
  • A 0.6-second limit lets ordinary courses (0.5 seconds) through and cuts off only the courses that drag the first time (1.5 seconds), at 0.6 seconds.
  • The three courses that got cut off put their burners down, try once more from the back of the line, and finish in 0.5 seconds.
  • Tighten the limit a little to 0.55 seconds and you still hit the goal (about 2.65 seconds). Loosen it to 0.7 and you're at about 2.9 seconds, right up against 3; at 0.8 you go over.

The 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?"

Looking back on twelve nights

LessonThat nightThe line that stays
1The first night's queue: concurrency and parallelismSeparate waiting work from hands-on work. Concurrency takes turns; parallelism happens at the same instant.
2Hiring more cooks: threading basicsThread(target=), start(), join(). Bring the results back yourself.
3The chalkboard is wrong: race conditions and LockCut in between read and write and the number goes wrong. Keep with lock: short.
4Only three burners: synchronization toolsSemaphore, Event, Condition, Barrier.
5Knives and boards: deadlockLock in the same order, or put it down if you can't get the other.
6The ticket rail: queueSeparate producers and consumers with a queue, and know when it's done with task_done() and join().
7Agency cooks: concurrent.futuresThreadPoolExecutor, Future, as_completed and map.
8Why the dough won't go faster: the GIL and parallelismComputation goes to processes, or to 3.14t.
9One floor manager for every table: first steps in asyncioasync def, await, gather, create_task.
10The group booking falls through: structured concurrencyTaskGroup, except*, asyncio.timeout; re-raise cancellations.
11The floor freezes: blocking calls and mixing toolsBlocking 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.

Practice

  1. Once you've hit the goal in the lab, cut the orders to 8 and, by changing the time limit and retries, find out whether 2 burners are enough to finish in under 3 seconds.
  2. Add a rest between retries (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.
  3. Pick a program you'd like to build and design it with the table from 12-1 and the checklist from 12-4. Just write one line per tool: which tool, and why.

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.

← Previous

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.

We collect: your email address · Purpose: news about new lessons or the launch of this course · Kept until: you ask us to remove it. We never use it for anything else or share it. Reply to any notice email and we'll delete it right away.

Questions & answers

Ask anything about this lesson. Questions from every language version of this page appear together.

Shown as plain text, without links or formatting.

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
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex