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
#06 · Night six
Trouble
Orders are flooding in from the floor, Minho and Sua grab the same ticket at the same time, and Mr. Tak has nothing to do. Nobody decided who makes what.
Goal
Three cooks split 12 orders and make every one exactly once, no more, no less. And we know for sure when they're all done.
Late-night diner Dongsi
Night six, a Friday. Floor staff run into the kitchen clutching tickets and hand them to whoever is closest. Minho and Sua grab the same ticket at once, and two identical bowls of noodles go out. Meanwhile Mr. Tak stands there, knife in hand, waiting for something to do.
Chef Yoon Seoyeon bolts a long steel bar above the kitchen door. It's a ticket rail. "The floor only clips tickets on here. Cooks only pull them off. Don't talk to each other."
Separating the side that produces work (the floor staff) from the side that handles it (the cooks), with a line in between, is called the producer–consumer pattern. In Python, queue.Queue is that line.
import queue
rail = queue.Queue() # the ticket rail
for n in range(1, 5):
rail.put(f"#{n}") # clip a ticket on
print(rail.qsize(), rail.get(), rail.get(), rail.qsize())
# 4 #1 #2 2The first ticket clipped on is the first one off (FIFO, first in, first out). Now let's run one floor server and two cooks as threads.
import threading
import time
rail = queue.Queue()
served = []
def hall(): # producer: takes orders and clips them on
for n in range(1, 7):
time.sleep(0.05)
rail.put(f"#{n}")
def cook(name): # consumer: pulls tickets off and cooks
for _ in range(3):
order = rail.get() # if the rail is empty, wait here
time.sleep(0.1)
served.append((name, order))
ts = [threading.Thread(target=hall),
threading.Thread(target=cook, args=("minho",)),
threading.Thread(target=cook, args=("sua",))]
for t in ts: t.start()
for t in ts: t.join()
print(sorted(order for _, order in served))
# ['#1', '#2', '#3', '#4', '#5', '#6']Lock anywhere in the code. Queue already contains a lock and the Condition from Lesson 4, so put and get take care of serving one person at a time. The queue does the locking for you.get() waits until a ticket is clipped on if the rail is empty. Cooks don't need to spin around asking "any orders?" over and over.If threads need to pass data to each other, think of handing it over through a queue before reaching for a lock on a shared variable. The less code that handles locks directly, the fewer accidents like the ones in Lessons 3 and 5.
The previous example told each cook "make three, then go home." In a real kitchen you don't know how many orders will come. The cooks keep watching the rail, and the chef only wants to know whether every ticket clipped on is done.
rail = queue.Queue()
def cook(name):
while True:
order = rail.get()
time.sleep(0.1)
rail.task_done() # "this ticket is done"
for name in ("minho", "sua", "tak"):
threading.Thread(target=cook, args=(name,), daemon=True).start()
start = time.perf_counter()
for n in range(1, 10):
rail.put(f"#{n}")
rail.join() # until every ticket clipped on is done
print(f"all 9 done: {time.perf_counter() - start:.2f}s")
# all 9 done: 0.30sThe queue keeps a count of unfinished orders inside it.
| Action | Unfinished orders |
|---|---|
one put() | goes up by 1 |
one task_done() | goes down by 1 |
join() | waits until it reaches 0 |
get() doesn't mean it's done. The count only goes down when you call task_done() after the dish is finished.while True loop, so they are daemon=True. Once every order is done, join() returns, and when the program exits the cooks clock out with it.task_done() more times than you took tickets out raises ValueError: task_done() called too many times.Chef's tip
Forget task_done and join never returns. Same thing if an exception hits mid-dish. That's why I always wrap it in try/finally: 'finally: rail.task_done()'. Even if you smashed the plate, you still take the ticket down.
You can give the rail a fixed number of slots. With Queue(maxsize=2), only two tickets fit. Calling put() on a full rail makes the side putting things in wait. When the kitchen falls behind, the floor slows down taking orders.
rail = queue.Queue(maxsize=2) # room for only two tickets
rail.put("#1")
rail.put("#2")
print(rail.full())
# True
try:
rail.put("#3", timeout=0.5) # if no slot opens within 0.5 s
except queue.Full:
print("Full after 0.5s")
# Full after 0.5s
try:
rail.put_nowait("#3") # = put("#3", block=False)
except queue.Full:
print("Full right away")
# Full right away
print(rail.get_nowait(), rail.get(timeout=0.5))
# #1 #2
try:
rail.get(timeout=0.5) # the rail is empty
except queue.Empty:
print("Empty after 0.5s")
# Empty after 0.5s| Call | When the rail is full / empty |
|---|---|
put(x), get() | waits until a slot opens (until a ticket arrives) |
put(x, timeout=0.5), get(timeout=0.5) | waits up to 0.5 s, then queue.Full / queue.Empty |
put_nowait(x), get_nowait() | doesn't wait: queue.Full / queue.Empty right away |
With no maxsize, or 0, the rail grows without limit. If the floor is much faster than the kitchen, tickets pile up endlessly and fill your memory. With a fixed size, the fast side waits for the slow side. This is called backpressure.
rail = queue.Queue(maxsize=2)
def cook(): # one slow cook
while True:
rail.get()
time.sleep(0.1)
rail.task_done()
threading.Thread(target=cook, daemon=True).start()
start = time.perf_counter()
for n in range(1, 7):
rail.put(f"#{n}") # fast floor: waits in put when there's no slot
print(f"hall done at {time.perf_counter() - start:.1f}s")
rail.join()
print(f"kitchen done at {time.perf_counter() - start:.1f}s")
# hall done at 0.3s
# kitchen done at 0.6sThe floor wanted to clip all six tickets on in an instant, but the rail has only two slots, so it spent 0.3 seconds waiting in put. Remove maxsize and the floor finishes immediately while tickets pile up on the rail.
Try it yourself on the timeline below. The upper rows are floor staff and the lower rows are cooks. A floor server spends 0.1 seconds taking each order (orange) plus however long it waits for a free slot on the rail (yellow).
Kitchen timeline
Orders
6
Floor staff
1
Cooks
3
Rail size
3
Cooking
Waiting
Cancelled
Failed
Instead of just abandoning the cooks with daemon=True, sometimes you want to announce "we're closed for the night" and send them home cleanly. The traditional way is a sentinel: a special ticket that says "closed."
STOP = None # sentinel: the "closed" ticket
rail = queue.Queue()
served = []
def cook(name):
while True:
order = rail.get()
if order is STOP:
break # got the sentinel: clock out
time.sleep(0.05)
served.append(order)
cooks = [threading.Thread(target=cook, args=(n,)) for n in ("minho", "sua")]
for t in cooks: t.start()
for n in range(1, 7):
rail.put(f"#{n}")
for _ in cooks:
rail.put(STOP) # one per cook
for t in cooks: t.join()
print(len(served), [t.is_alive() for t in cooks])
# 6 [False, False]You have to clip on as many sentinels as there are cooks. Clip on just one and only one cook goes home; the rest wait in front of an empty rail forever. Because the rail is FIFO, the sentinels come off only after every order clipped on before them.
Starting with Python 3.13, you can close the rail itself. After you call Queue.shutdown(), any further put() raises queue.ShutDown. get() hands out the remaining tickets first and then raises queue.ShutDown. No need to count cooks.
rail = queue.Queue()
served = []
def cook(name):
while True:
try:
order = rail.get()
except queue.ShutDown: # the rail is closed and no tickets are left
return
time.sleep(0.05)
served.append(order)
rail.task_done()
cooks = [threading.Thread(target=cook, args=(n,)) for n in ("minho", "sua")]
for t in cooks: t.start()
for n in range(1, 7):
rail.put(f"#{n}")
rail.shutdown() # take no more; finish the tickets already on the rail
for t in cooks: t.join()
print(len(served))
# 6
try:
rail.put("#7")
except queue.ShutDown:
print("rail is closed")
# rail is closedWhen you have to close in a hurry, use shutdown(immediate=True). It throws away every ticket left on the rail, and any get() that is waiting raises queue.ShutDown right away.
rail = queue.Queue()
served = []
def cook(name):
while True:
try:
order = rail.get()
except queue.ShutDown:
return
time.sleep(0.1)
served.append(order)
rail.task_done()
cooks = [threading.Thread(target=cook, args=(n,)) for n in ("minho", "sua")]
for t in cooks: t.start()
for n in range(1, 7):
rail.put(f"#{n}")
time.sleep(0.15) # the two are making #3 and #4
rail.shutdown(immediate=True) # drop #5 and #6 still on the rail, close now
rail.join() # returns once #3 and #4 in hand are done
for t in cooks: t.join()
print(sorted(served), rail.qsize())
# ['#1', '#2', '#3', '#4'] 0shutdown() | shutdown(immediate=True) | |
|---|---|---|
New put() | queue.ShutDown | queue.ShutDown |
| Tickets already on the rail | handed out as usual | thrown away (and removed from the unfinished count) |
get() | queue.ShutDown once the rail is empty | queue.ShutDown right away |
| Order already in a cook's hands | finished | finished |
Even immediate=True doesn't snatch the orders already in a cook's hands. That's why join() returns only after task_done() has been called for those orders.
The queue module has a few more rails that hand things out in a different order.
PriorityQueue: hands out the smallest value first. Use it to make VIP tables' orders first. By convention you put in (priority, number, order) tuples.
import itertools
vip_first = queue.PriorityQueue()
ticket = itertools.count(1) # same priority: first come, first served
for prio, dish in [(2, "noodle"), (1, "VIP soup"), (2, "rice"), (1, "VIP noodle")]:
vip_first.put((prio, next(ticket), dish))
while not vip_first.empty():
print(vip_first.get())
# (1, 2, 'VIP soup')
# (1, 4, 'VIP noodle')
# (2, 1, 'noodle')
# (2, 3, 'rice')Without the number in the middle, two items with the same priority get compared by their third value. If the order is something that can't be compared, like a dict, it blows up.
pq = queue.PriorityQueue()
pq.put((1, {"dish": "soup"}))
try:
pq.put((1, {"dish": "rice"})) # same priority, so it tries to compare two dicts
except TypeError as e:
print(e)
# '<' not supported between instances of 'dict' and 'dict'There's also LifoQueue, which hands out the last item first like a stack of plates, and the bare-bones SimpleQueue.
plates = queue.LifoQueue() # a stack of plates: last one on comes off first
for p in ("p1", "p2", "p3"):
plates.put(p)
print(plates.get(), plates.get(), plates.get())
# p3 p2 p1
simple = queue.SimpleQueue() # the simplest rail: no maxsize, task_done or join
simple.put("#1")
print(simple.get(), hasattr(simple, "task_done"))
# #1 False| Rail | Hands out | Use it for |
|---|---|---|
Queue | first in, first out | almost everything |
PriorityQueue | smallest value first | VIPs first, urgent jobs first |
LifoQueue | last in, first out | stacks of plates, newest first |
SimpleQueue | first in, first out | when you need no size limit and no completion tracking |
Tonight's goal was for three cooks to make 12 orders, each exactly once with none missing, and to know for sure when they were all done. Two floor servers take orders in turns, and the rail has four slots.
rail = queue.Queue(maxsize=4) # a four-slot rail
served = []
def hall(orders): # floor staff: take orders and clip them on
for n in orders:
time.sleep(0.02)
rail.put(f"#{n}")
def cook(name):
while True:
try:
order = rail.get()
except queue.ShutDown:
return
time.sleep(0.1)
served.append((name, order))
rail.task_done()
start = time.perf_counter()
halls = [threading.Thread(target=hall, args=(range(k, 13, 2),)) for k in (1, 2)]
cooks = [threading.Thread(target=cook, args=(n,)) for n in ("minho", "sua", "tak")]
for t in halls + cooks: t.start()
for t in halls: t.join() # once order taking is over
rail.shutdown() # close the rail
rail.join() # and wait until every ticket on it is done
for t in cooks: t.join()
orders = [o for _, o in served]
print(len(orders), len(set(orders)), f"{time.perf_counter() - start:.2f}s")
# 12 12 0.44s (timing is approximate)Twelve dishes made, twelve distinct orders. No order went out twice and none went missing. Mr. Tak pulled his weight too. And there isn't a single line of Lock in this code.
The chef taps the rail. "Now the floor and the kitchen don't have to talk. But what if a group order of 30 comes in next Friday? You going to hire 30 cooks?"
Service log — Night six. With a ticket rail (Queue) in place, no order got made twice. Closed the rail (shutdown), confirmed with join that every order was done, then called it a night.
cook in 6-2 so that only order #5 raises ValueError. Does rail.join() return? So you don't wait forever, launch it with waiter = threading.Thread(target=rail.join, daemon=True), print waiter.is_alive() after waiter.join(timeout=1) to check, and then fix it with try/finally.PriorityQueue so even-numbered orders are VIP and get made first. shutdown() works the same way on a PriorityQueue.Chef's recipe card
Producer–consumer: the clipping side calls rail.put(x), the pulling side calls rail.get(). The queue does the locking.
All done? Call rail.task_done() each time a job finishes; the waiting side calls rail.join().
Queue(maxsize=n): when it's full, the side putting things in waits (backpressure). timeout=, put_nowait/get_nowait, queue.Full/queue.Empty.
Shutting down: one None sentinel per cook, or from 3.13 rail.shutdown() → queue.ShutDown. immediate=True throws away the tickets left on the rail.
PriorityQueue((priority, number, order)), LifoQueue (a stack of plates), SimpleQueue (the simplest).
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.