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 6

Time 4:10–5:00

The ticket rail: queue

Tonight's ticket

#06 · Night six

The ticket rail

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

6-1. The one who clips and the one who pulls (8 min)

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 2

The 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']
  • All six orders were made exactly once. The two cooks never grab the same ticket.
  • There's no 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.

6-2. Knowing every order is done: task_done and join (8 min)

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.30s

The queue keeps a count of unfinished orders inside it.

ActionUnfinished orders
one put()goes up by 1
one task_done()goes down by 1
join()waits until it reaches 0
  • Taking a ticket out with get() doesn't mean it's done. The count only goes down when you call task_done() after the dish is finished.
  • The cook threads watch the rail forever in a 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.
  • Calling 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.

6-3. When the rail is full (10 min)

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
CallWhen 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.6s

The 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

2 floor staff, 2 cooks, a 3-slot rail, 10 orders. Shrink the rail to 1 and the yellow waiting on the floor rows grows; raise it to 8 and the floor finishes around 0.5 s. Add cooks and the total time drops.
  • With the starting values (2 cooks, a 3-slot rail) it takes about 1.6 seconds, and the rail holds at most 3 tickets. The floor staff start waiting at around 0.3 seconds.
  • Raise the rail size to 8 and the floor staff never wait, finishing at about 0.5 seconds. The total time is still about 1.6 seconds, though. A longer rail doesn't fix a slow kitchen.
  • Raise the cooks to 4 and the total time drops to about 1.0 seconds. To find the bottleneck, look at which rows grow long yellow bars.

6-4. The closing signal: sentinels and shutdown (9 min)

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 closed

When 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'] 0
shutdown()shutdown(immediate=True)
New put()queue.ShutDownqueue.ShutDown
Tickets already on the railhanded out as usualthrown away (and removed from the unfinished count)
get()queue.ShutDown once the rail is emptyqueue.ShutDown right away
Order already in a cook's handsfinishedfinished

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.

6-5. Rails of other shapes (5 min)

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
RailHands outUse it for
Queuefirst in, first outalmost everything
PriorityQueuesmallest value firstVIPs first, urgent jobs first
LifoQueuelast in, first outstacks of plates, newest first
SimpleQueuefirst in, first outwhen you need no size limit and no completion tracking

Closing time (10 min)

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.

Practice

  1. Change 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.
  2. Run the timeline with 4 floor staff, 1 cook and a 1-slot rail. Which rows get the yellow bars, and where is the bottleneck?
  3. Switch the closing-time code to a 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).

← Previous

Lesson 5 · Knives and boards: deadlock

Next →

Lesson 7 · Agency cooks: concurrent.futures

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