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 2:30–3:20
Tonight's ticket
#04 · Night four
Trouble
A large party means six cooks tonight. But there are only three burners, and nobody may light one before the chef shouts "Open!"
Goal
Nobody lights a burner before the open signal, no more than three burners in use at once, and six orders out in under a second.
Late-night diner Dongsi
Night four, nine-forty. There's a group booking, so three more cooks have come in for the day. Six cooks, three burners.
Minho is first to a burner and lights it. "If we warm them up early, we'll be faster." Without a word, the chef shuts the gas valve. "No flames before opening. And if all six of you crowd around the burners, nobody can move."
Sua raises her hand. "What if we hang just three number tags by the burners? Only someone holding a tag gets to stand at the flame." For the first time tonight, the chef smiles. "That's tonight's first tool."
The Lock from Lesson 3 was one piece of chalk: one person at a time. A Semaphore(n) is n number tags. Up to n people can be in at once, and person n+1 waits until someone hands a tag back.
import threading
import time
stoves = threading.Semaphore(3) # three burners
start = time.perf_counter()
def cook(name):
with stoves: # wait for a free burner, then take it
print(f"{time.perf_counter() - start:.1f}s {name} on")
time.sleep(0.3)
names = [f"cook{i}" for i in range(1, 7)]
threads = [threading.Thread(target=cook, args=(n,)) for n in names]
for th in threads:
th.start()
for th in threads:
th.join()
print(f"total {time.perf_counter() - start:.2f}s")
# e.g.:
# 0.0s cook1 on
# 0.0s cook2 on
# 0.0s cook3 on
# 0.3s cook4 on
# 0.3s cook5 on
# 0.3s cook6 on
# total 0.60swith stoves: takes one tag (acquire), and leaving hands it back (release). When there are zero tags left, whoever is trying to get in waits.Semaphore(1) lets one person in at a time, like a Lock. But where releasing an already released Lock raises RuntimeError, a semaphore quietly gains an extra tag if you call release() too many times (see BoundedSemaphore below).Change the number of burners on the timeline below. Yellow bars are time spent waiting for a burner; orange bars are time actually spent cooking.
Kitchen timeline
Orders
6
Cooks
3
Burners
2
Cooking
Waiting
Cancelled
Failed
A Semaphore doesn't count how many tags come back. If a bug calls release() one time too many, there are quietly four tags. Three burners, and four people at the flames.
loose = threading.Semaphore(3)
loose.release() # released one extra time by mistake
print(sum(loose.acquire(blocking=False) for _ in range(10))) # now there are four burners
# 4
strict = threading.BoundedSemaphore(3)
strict.release()
# ValueError: Semaphore released too many timesA BoundedSemaphore raises ValueError if it gets back more than it started with. If you're using a semaphore to cap a count, BoundedSemaphore is almost always the better choice. Mistakes blow up on the spot instead of slipping by.
Chef's tip
A semaphore counts heads. It doesn't guard the chalkboard. Three people at three burners, and if all three write on the same chalkboard, that's a race condition again. Inside a room that lets three in, if you touch a shared value, you still need a Lock in there.
An Event is a single signal light on the kitchen wall. It starts off, and when someone turns it on (set()), everyone who was waiting for it (wait()) moves all at once.
opened = threading.Event() # starts off
start = time.perf_counter()
def cook(name):
opened.wait() # wait until the signal comes on
print(f"{time.perf_counter() - start:.1f}s {name} fire")
threads = [threading.Thread(target=cook, args=(n,)) for n in ["minho", "sua", "tak"]]
for th in threads:
th.start()
time.sleep(0.5)
print(opened.is_set())
opened.set() # "Open!"
for th in threads:
th.join()
print(opened.is_set())
# False
# 0.5s minho fire (the order of these three lines varies by run)
# 0.5s sua fire
# 0.5s tak fire
# TrueYou can also give wait() a time limit. Then the return value matters: True if the signal came on, False if time ran out and it's still off.
closing = threading.Event()
print(closing.wait(timeout=0.2)) # waited 0.2 s, no signal
closing.set()
print(closing.wait(timeout=0.2)) # already on, so True right away
closing.clear() # turn it off again
print(closing.is_set())
# False
# True
# False| Method | What it does |
|---|---|
set() | turns the signal on. Every waiting thread wakes up |
wait() | waits until the signal is on. If it's already on, goes straight through |
wait(timeout) | waits only that long. True if it came on, otherwise False |
clear() | turns the signal off again. Later wait() calls wait again |
is_set() | whether it's on right now |
One pattern comes up a lot: make a "closing time" signal with an Event, and have each worker thread check if closing.is_set(): break on every pass of its loop. Unlike the daemon threads from Lesson 2, which get cut off midway, this lets threads clean up and finish on their own.
The timeline below is a kitchen where the chef shouts "Open!" after 0.5 seconds.
Kitchen timeline
Cooks
3
Cooking
Waiting
Cancelled
Failed
set() wakes everyone who is waiting.An Event is just on or off. But waiting in a kitchen is usually more specific than that. Minho is waiting for eggs, Sua for tofu. Every time something lands on the shelf, they have to check "is that mine?" and go back to waiting if it isn't. That's what Condition is for.
A Condition is a lock + a "wake me up" list. It guards the shared state (the ingredient shelf) with the lock, and when the state changes, it wakes the people on the list.
pantry = [] # the ingredient shelf (shared state)
arrived = threading.Condition() # a lock guarding the shelf + a "wake me up" list
start = time.perf_counter()
def cook(name, need):
with arrived:
ok = arrived.wait_for(lambda: need in pantry, timeout=0.5)
print(f"{time.perf_counter() - start:.1f}s {name} {need if ok else 'gave up'}")
def supplier():
for item in ["tofu", "egg"]: # no fish today
time.sleep(0.1)
with arrived:
pantry.append(item)
arrived.notify_all() # wake everyone who's waiting
threads = [threading.Thread(target=cook, args=a) for a in [("minho", "egg"), ("sua", "tofu"), ("tak", "fish")]]
threads.append(threading.Thread(target=supplier))
for th in threads:
th.start()
for th in threads:
th.join()
# 0.1s sua tofu
# 0.2s minho egg
# 0.5s tak gave upwith arrived:. Same rule as Lesson 3.wait_for(condition) waits until the condition is true. While waiting, it puts the lock down for a moment, so the supplier can put things on the shelf. On waking, it takes the lock again and checks the condition again.timeout, when time runs out it returns the condition's last value. The fish never came, so after 0.5 seconds Mr. Tak got False and gave up.notify_all() wakes everyone on the list. Each one checks their own condition and goes back to sleep if it isn't met. When the tofu arrived, Minho and Mr. Tak woke up too, but it wasn't their ingredient, so they went back to waiting.notify() wakes just one waiter. It suits work where it doesn't matter who takes it, like "any server can take a finished plate."
plates = 0
counter = threading.Condition()
def cook():
global plates
time.sleep(0.1)
with counter:
plates += 1
counter.notify() # wake just one waiter
def waiter(name):
global plates
with counter:
counter.wait_for(lambda: plates > 0)
plates -= 1
print(name, "took a plate")
ws = [threading.Thread(target=waiter, args=(n,)) for n in ["w1", "w2"]]
cs = [threading.Thread(target=cook) for _ in range(2)]
for th in ws + cs:
th.start()
for th in ws + cs:
th.join()
print(plates)
# e.g.:
# w1 took a plate
# w2 took a plate
# 0Waking one person per plate means fewer cases of two people waking at once and fighting over a single plate. On the other hand, if people are waiting for different conditions, notify() may wake the wrong one, so use notify_all(). When in doubt, notify_all() is the safe choice.
Chef's tip
When you use wait() on a Condition, always recheck the condition inside a while loop. Being woken up doesn't guarantee your ingredient is here. It might be someone else's, or someone might have grabbed it first. wait_for(condition) runs that while loop for you, so that's the easiest way.
A four-top in the group section needs all four plates at the same time. If one goes out early, the other three guests sit there waiting while it gets cold. A Barrier(n) waits at the pass until all n people have arrived, then sends them out together.
start = time.perf_counter()
table = threading.Barrier(4, action=lambda: print("serve 4 plates"))
done = []
def cook(name, sec):
time.sleep(sec) # everyone works at a different speed
i = table.wait() # wait at the pass until all four arrive
done.append((round(time.perf_counter() - start, 1), i, name))
crew = [("minho", 0.1), ("sua", 0.2), ("chef", 0.3), ("tak", 0.4)]
threads = [threading.Thread(target=cook, args=c) for c in crew]
for th in threads:
th.start()
for th in threads:
th.join()
print(sorted(done))
# serve 4 plates
# [(0.4, 0, 'minho'), (0.4, 1, 'sua'), (0.4, 2, 'chef'), (0.4, 3, 'tak')]wait() returns a different number to each person, from 0 to n-1. In CPython it's 0, 1, 2, 3 in order of arrival. You can use that number to pick "a job for just one person" (e.g., if i == 0: write the log).action= runs only once, when the last person arrives. It's the signal that the plates go out.wait(timeout=) runs out of time, the Barrier breaks and everyone waiting gets a BrokenBarrierError.| Tool | In kitchen terms | When |
|---|---|---|
Lock | one piece of chalk | only one person at a time changes a shared value (Lesson 3) |
Semaphore(n), BoundedSemaphore(n) | n number tags at the burners | allow at most n users at once |
Event | the signal light on the wall | a one-off signal like "start" or "stop" for everyone |
Condition | lock + "wake me up" list | "wake me when this becomes true": waiting for shared state to change |
Barrier(n) | the pass for the group table | everyone leaves together once n have gathered |
All five live in threading, and all are built on top of the lock from Lesson 3. In fact Semaphore, Event and Barrier use a Condition internally, and a Condition holds a lock inside (an RLock unless you give it one).
Tonight's goal: nobody lights a burner before the open signal, no more than three burners at once, and six orders out in under a second. An Event sets the starting line, a Semaphore(3) guards the burners, and a Lock counts "how many are at the flames right now."
opened = threading.Event()
stoves = threading.Semaphore(3)
lock = threading.Lock()
on_fire = peak = 0
log = []
start = time.perf_counter()
def cook(n):
global on_fire, peak
opened.wait() # 1. no flames before "Open!"
with stoves: # 2. at most three burners at once
with lock:
on_fire += 1
peak = max(peak, on_fire)
time.sleep(0.3)
with lock:
on_fire -= 1
log.append((round(time.perf_counter() - start, 1), f"#{n}"))
threads = [threading.Thread(target=cook, args=(n,)) for n in range(1, 7)]
for th in threads:
th.start()
time.sleep(0.3) # getting ready for guests
opened.set()
for th in threads:
th.join()
print(sorted(log))
print(f"peak {peak}, total {time.perf_counter() - start:.2f}s")
# e.g.:
# [(0.6, '#1'), (0.6, '#2'), (0.6, '#3'), (0.9, '#4'), (0.9, '#5'), (0.9, '#6')]
# peak 3, total 0.90speak is 3 every time you run it. Which three orders come out at 0.6 seconds may vary from run to run.on_fire and peak are values all six cooks change, so they're wrapped in lock, even inside the semaphore.Near closing, a woman sitting alone at the window finishes a bowl of noodles. She checks her wristwatch once, writes something in a small notebook, pays quietly and leaves.
The chef shuts the burner valves one by one. "Everyone queued up nicely tonight. But what if, besides the burners, there's only one knife and one cutting board? If Minho holds the knife and waits for the board while Sua holds the board and waits for the knife, what happens?"
Service log — Night four. 6 cooks, 3 burners. Everyone waited for the open signal (Event); burners capped with three tags (Semaphore); 6 orders done in 0.9 seconds. One guest by the window, one bowl of noodles.
closing = threading.Event(), and have a cook thread make one bowl every 0.1 seconds, stopping once closing.is_set(). The main thread calls closing.set() after 0.35 seconds and then join()s. How many bowls came out?Barrier(4) to Barrier(5) in the 4-4 Barrier example. So it doesn't really freeze forever, change it to table.wait(timeout=1), run it, and see which exception you get.Chef's recipe card
Semaphore(n): with sem: lets up to n in at once. For capping a count, BoundedSemaphore(n) catches release mistakes as ValueError.
Event: set() turns it on and wakes everyone in wait(). wait(timeout) is True if it came on, otherwise False. clear(), is_set().
Condition: inside with cond:, cond.wait_for(condition). Whoever changes the state calls notify() (one) or notify_all() (everyone).
Barrier(n): wait() until n gather, then all pass together. Returns numbers 0 to n-1; action= runs only once.
Even inside a semaphore, changing a shared value needs a Lock. Tools get layered.
← Previous
Lesson 3 · The chalkboard is wrong: race conditions and Lock
Next →
Lesson 5 · Knives and boards: deadlock
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.