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
Tonight's ticket
#05 · Night five
Trouble
Minho holds the knife and waits for the board. Sua holds the board and waits for the knife. Neither will let go first. The kitchen has stopped.
Goal
Three cooks who each need a knife and a board cook 100 times apiece without the kitchen ever freezing.
Late-night diner Dongsi
Night five, eleven o'clock. The kitchen is strangely quiet. Minho is gripping the knife and staring at the cutting board. Sua is hugging the cutting board and staring at the knife. Both keep saying the same thing: "I'll hand it over once I'm done."
Mr. Tak stops halfway through a scallion and mutters, "If you both just stand there, nobody finishes."
Chef Yoon Seoyeon stops the timer around her neck. "Both of you, put them down. This didn't happen because someone was slow. It happened because there was no rule."
Like last night's burner rule, the knife and the cutting board are locks: only one person can use each at a time. Minho grabs knife → board. Sua grabs board → knife.
import threading
import time
knife = threading.Lock()
board = threading.Lock()
def minho():
with knife: # pick up the knife
time.sleep(0.1)
with board: # wait for the board
print("minho done")
def sua():
with board: # pick up the board
time.sleep(0.1)
with knife: # wait for the knife
print("sua done")
a = threading.Thread(target=minho, daemon=True)
b = threading.Thread(target=sua, daemon=True)
a.start(); b.start()
a.join(timeout=1); b.join(timeout=1)
print("alive:", a.is_alive(), b.is_alive())
# alive: True Truejoin(timeout=1) waits at most one second and then returns. Because the threads are daemon=True, the program can still exit. Whenever you experiment with deadlocks, always leave yourself a way out like this.time.sleep(0.1) is there to make both cooks grab their first tool at almost the same moment. In real programs that gap opens up only rarely, which turns deadlock into an even nastier bug: one that happens "only sometimes."To notice from inside the program that you're stuck, use acquire(timeout=...). It returns True if it got the lock and False if time ran out. You can't give with lock: a time limit, so here you call acquire and release yourself.
knife = threading.Lock() # a fresh knife and a fresh board
board = threading.Lock()
def cook(name, first, second, patience):
with first:
time.sleep(0.1)
if second.acquire(timeout=patience): # wait at most `patience` seconds
second.release()
print(name, "done")
else:
print(name, f"gave up after {patience}s")
a = threading.Thread(target=cook, args=("minho", knife, board, 1.0))
b = threading.Thread(target=cook, args=("sua", board, knife, 0.5))
a.start(); b.start()
a.join(); b.join()
# sua gave up after 0.5s
# minho doneSua gives up after 0.5 seconds and puts the board down, so Minho, still waiting, grabs it and finishes his dish. One person backing off is enough to break the deadlock. The four conditions in the next section explain why.
A deadlock happens only when all four of these conditions hold at the same time. Turn that around: break any one of them and deadlock can't happen.
| Condition | Meaning | In tonight's kitchen | How to break it |
|---|---|---|---|
| Mutual exclusion | Only one person can use a resource at a time | A knife is in one person's hand only | Buy more knives, or design so nothing needs sharing |
| Hold and wait | Holding one thing while waiting for another | Holding the knife while waiting for the board | Grab both at once, or put down what you hold if you can't |
| No preemption | You can't take a resource out of someone else's hand | Nobody can yank the knife from Minho | Use a time limit so people let go on their own (fix 2) |
| Circular wait | The waiting forms a loop | Minho → Sua → Minho | Everyone grabs in the same order (fix 1) |
Chef's tip
A deadlock uses no CPU. It throws no error. It leaves no log. It just sits there, quietly stuck. So when someone reports "it got slow," deadlock is the first thing I suspect.
The chef writes a rule on the chalkboard: "Every tool gets a number. Knife is 1, board is 2. Always grab the lowest number first."
If Minho and Sua both start with the knife, only the person holding the knife ever waits for the board. Whoever didn't get the knife waits empty-handed, so no loop can form. Don't leave the rule to people's memory; put it in a single function.
from contextlib import contextmanager
KNIFE = (1, threading.Lock()) # (number, lock)
BOARD = (2, threading.Lock())
@contextmanager
def hold(*tools):
ordered = sorted(tools, key=lambda t: t[0]) # lowest number first
for _, lock in ordered:
lock.acquire()
try:
yield
finally:
for _, lock in reversed(ordered): # release in reverse
lock.release()
done = []
def minho():
for _ in range(100):
with hold(KNIFE, BOARD):
time.sleep(0)
done.append("minho")
def sua():
for _ in range(100):
with hold(BOARD, KNIFE): # written in a different order, grabbed in the same order
time.sleep(0)
done.append("sua")
ts = [threading.Thread(target=f, daemon=True) for f in (minho, sua)]
for t in ts: t.start()
for t in ts: t.join(timeout=2)
print(sorted(done), [t.is_alive() for t in ts])
# ['minho', 'sua'] [False, False]hold(BOARD, KNIFE), hold sorts by number, so she really grabs knife → board.time.sleep(0) giving the other thread a chance to cut in.id(lock). What matters is that all the code uses the same ordering.As soon as some piece of code needs more than one lock, write this rule down in a comment at the top of the file or in the docs. The moment a newcomer who doesn't know the order grabs them backwards, the deadlock is back.
Sometimes a fixed order is hard to arrange, for example when you can't know in advance which locks you'll need. Then you use a different rule: "If you still can't get it after waiting a while, put down what you're holding too, and start over."
import random
tries = {}
def polite_cook(name, first, second):
tries[name] = 0
while True:
tries[name] += 1
with first:
time.sleep(0.05)
if second.acquire(timeout=0.1): # if not grabbed within 0.1 s
try:
return # got both: dish done
finally:
second.release()
# leaving the with block put `first` down
time.sleep(random.uniform(0, 0.1)) # rest a random moment, then retry
a = threading.Thread(target=polite_cook, args=("minho", knife, board))
b = threading.Thread(target=polite_cook, args=("sua", board, knife))
a.start(); b.start()
a.join(); b.join()
print(tries)
# e.g. {'minho': 2, 'sua': 3} (different on every run)They bump into each other a few times, but both finish in the end. Take out random.uniform(0, 0.1), though, and a new problem can show up: livelock.
| Moment | Minho | Sua |
|---|---|---|
| 1 | picks up the knife | picks up the board |
| 2 | no board → puts the knife down | no knife → puts the board down |
| 3 | rests exactly 0.1 s, picks up the knife again | rests exactly 0.1 s, picks up the board again |
| … | repeats 1–3 forever | repeats 1–3 forever |
It's like two people meeting in a hallway who keep stepping aside in the same direction. Unlike a deadlock, both are busy moving, yet nobody ever finishes cooking. Mix in a random rest and sooner or later someone moves first, which breaks the cycle.
Real threads wobble a little in their timing, so a short example rarely reproduces livelock every time. That makes it more dangerous: it shows up only on heavy-load days. Whenever you use fix 2, always add the random rest.
Computer science has a famous puzzle called the dining philosophers problem. Five people sit at a round table with one fork between each pair. To eat, you need both forks next to you. Move it into the kitchen and you get five cooks around a round counter with five knives.
N = 5
def service(ordered):
knives = [threading.Lock() for _ in range(N)] # five knives
meals = [0] * N
def cook(i):
left, right = i, (i + 1) % N
if ordered:
first, second = min(left, right), max(left, right)
else:
first, second = left, right
for _ in range(3):
with knives[first]:
time.sleep(0.05) # pause holding the first knife
with knives[second]:
meals[i] += 1
ts = [threading.Thread(target=cook, args=(i,), daemon=True) for i in range(N)]
for t in ts: t.start()
for t in ts: t.join(timeout=1)
return sum(t.is_alive() for t in ts), meals
print("left first:", service(ordered=False))
print("small first:", service(ordered=True))
# left first: (5, [0, 0, 0, 0, 0])
# small first: (0, [3, 3, 3, 3, 3])The lab below plays this out one step at a time. If we pushed real threads on the server into a deadlock, there would be no way to free them, so this one lab runs as a simulation. The number on a knife is the cook holding it.
Knives and boards
1
2
3
4
5
·
·
·
·
·
Steps 0
Dishes done: 1:0 2:0 3:0 4:0 5:0
When a program has frozen and you don't know where, the standard library's faulthandler can help. It prints the line every thread is currently on. Save the code below as stuck.py and run it.
import faulthandler
import threading
import time
knife = threading.Lock()
board = threading.Lock()
def minho():
with knife:
time.sleep(0.1)
with board:
pass
def sua():
with board:
time.sleep(0.1)
with knife:
pass
faulthandler.dump_traceback_later(1) # if still here after 1 s, print every thread
crew = [threading.Thread(target=f, name=f.__name__, daemon=True) for f in (minho, sua)]
for t in crew: t.start()
for t in crew: t.join(timeout=2)
faulthandler.cancel_dump_traceback_later() # finished in time: cancel the scheduled dumpTimeout (0:00:01)!
Thread 0x00009560 [sua] (most recent call first):
File "stuck.py", line 17 in sua
...
Thread 0x0000a670 [minho] (most recent call first):
File "stuck.py", line 11 in minho
...The output is trimmed (addresses and paths differ on every run). Sua is stuck at line 17, with knife:, and Minho at line 11, with board:. You can see at a glance who is waiting for what.
faulthandler.dump_traceback_later(timeout): schedules a dump of every thread's position to standard error after timeout seconds. With repeat=True it keeps repeating; with exit=True it exits the program after the dump.faulthandler.dump_traceback(all_threads=True): dumps right now, no scheduling. Handy for snapshotting the state in the middle of a long job.dump_traceback_later at the start of the test. On the day it hangs, the culprit leaves a trace.Chef's tip
Be careful calling another function while you're holding a lock. You don't know what other lock that function grabs. Inside a lock, do only short, obvious stuff, like writing a number on the chalkboard. That comes before any ordering rule.
Tonight's goal was for three cooks who each need a knife and a board to cook 100 times apiece without ever freezing. Mr. Tak uses three tools, a pan included. Give every tool a number and have everyone grab through hold, and it no longer matters what order anyone writes them in.
PAN = (3, threading.Lock())
meals = {}
def cook(name, *tools):
for _ in range(100):
with hold(*tools): # always the lowest-numbered tool first
time.sleep(0)
meals[name] = meals.get(name, 0) + 1
crew = [threading.Thread(target=cook, args=a, daemon=True) for a in [
("minho", KNIFE, BOARD), ("sua", BOARD, KNIFE), ("tak", PAN, BOARD, KNIFE)]]
for t in crew: t.start()
for t in crew: t.join(timeout=3)
print([meals[n] for n in ("minho", "sua", "tak")], sum(t.is_alive() for t in crew))
# [100, 100, 100] 0All three cooked 100 times, and zero are stuck. Next to the rule on the chalkboard, the chef adds one more line: "Knife 1, board 2, pan 3. Lowest number first."
"Tonight the tools froze us. Tomorrow it'll be the people. When orders pour in, who decides which cook makes which order?"
Service log — Night five. The kitchen froze when the knife and board were grabbed in opposite orders. We numbered the tools and agreed to grab the lowest number first; three cooks then made 100 dishes each without a single freeze.
time.sleep(0.1) and run it several times. Does the deadlock happen every time? In one sentence, write down why a bug that "only freezes sometimes" is more dangerous.service from 5-5, use print to confirm that with ordered=True only cook 4 grabs in a different order. Using the four conditions, explain why changing the order for just one cook is enough to break the deadlock.Chef's recipe card
The four conditions for deadlock: mutual exclusion, hold and wait, no preemption, circular wait. Break any one and it's gone.
Fix 1: number your locks and have everyone grab the lowest number first. Put it in one function with sorted(tools).
Fix 2: if not lock.acquire(timeout=0.1): put down what you're holding, rest a random moment, try again.
Livelock: everyone yields on the same beat and nobody finishes. Break it with a random rest.
When experimenting, leave a way out: daemon=True + join(timeout=1) + is_alive().
Find where it froze: faulthandler.dump_traceback_later(1), dump_traceback(all_threads=True).
← Previous
Lesson 4 · Only three burners: synchronization tools
Next →
Lesson 6 · The ticket rail: queue
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.