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
#07 · Night seven
Trouble
A company dinner party sends in 30 orders at once. We can't hire 30 cooks. And everyone is sick of writing code that creates threads one by one and collects results in a list.
Goal
Hand 30 orders to six agency cooks, get them done in under a second, and send each one out as soon as it's ready. Don't miss a single burnt plate.
Late-night diner Dongsi
Night seven, half past ten. The phone rings, and the chef sets the receiver down. "Company dinner. Thirty covers."
Minho reties his apron. "Should I make 30 threads?" The chef shakes her head. "Hire people and it's all on us: housing them, paying them, letting them go. Tonight we call a staffing agency. They manage the people. We just hand over the work."
concurrent.futures.ThreadPoolExecutor is a staffing agency that keeps a few threads on hand and gives each incoming job to whichever thread is free. It takes over everything you did by hand in Lesson 2: creating each Thread, start, join, and setting up a list for the results.
import time
from concurrent.futures import ThreadPoolExecutor
def cook(n):
time.sleep(0.2)
return f"#{n} ready"
with ThreadPoolExecutor(max_workers=3) as agency: # three agency cooks
fut = agency.submit(cook, 1) # hand over a job and get a claim ticket (Future)
print(fut.done()) # still cooking
print(fut.result()) # wait until it's done, then take the result
print(fut.done())
# False
# #1 ready
# Truesubmit(function, args...) hands over the job and immediately returns a Future. It isn't the food; it's a "claim ticket for the food." Print it with print(fut) and you'll see its state, something like <Future at 0x... state=running>.fut.result() waits until the dish is done and returns the function's return value. This solves the Lesson 2 problem where "threads don't give back a return value" and we had to stash results in a list.with block, shutdown(wait=True) is called: it waits for every job you handed over to finish and then cleans up the threads.Now let's hand over 30. There are only five cooks, so each one takes six orders in turn.
import threading
def cook(n):
time.sleep(0.1)
return threading.current_thread().name
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5, thread_name_prefix="agency") as agency:
futures = [agency.submit(cook, n) for n in range(1, 31)] # 30 orders
# once you're out of the with block, every job is done
names = {f.result() for f in futures}
print(len(futures), sorted(names), f"{time.perf_counter() - start:.2f}s")
# 30 ['agency_0', 'agency_1', 'agency_2', 'agency_3', 'agency_4'] 0.60sThirty orders, but only five threads were ever created. Thirty 0.1-second orders split among five people: 0.6 seconds. You pay the cost of creating and destroying threads only once, and the same cooks keep taking the next order. This is called a thread pool.
There are two ways to collect results. This time, let's make order #1 take the longest.
from concurrent.futures import as_completed
def cook(n):
time.sleep(0.1 * (5 - n)) # #1 takes the longest (0.4 s … 0.1 s)
return f"#{n}"
with ThreadPoolExecutor(max_workers=4) as agency:
start = time.perf_counter()
futures = [agency.submit(cook, n) for n in range(1, 5)]
for f in as_completed(futures): # finish order
print(f.result(), f"{time.perf_counter() - start:.1f}s", end=" ")
print()
start = time.perf_counter()
for dish in agency.map(cook, range(1, 5)): # submit order
print(dish, f"{time.perf_counter() - start:.1f}s", end=" ")
print()
# #4 0.1s #3 0.2s #2 0.3s #1 0.4s
# #1 0.4s #2 0.4s #3 0.4s #4 0.4sas_completed(futures) | executor.map(function, values) | |
|---|---|---|
| Gives you | finished Futures, one at a time | result values, one at a time |
| Order | the order they finish | the order you submitted |
| First result | as soon as the fastest one finishes | waits until #1 is done |
| Good for | sending things out as they finish, progress bars | using results paired with their inputs as is |
Even though #4 finished at 0.1 seconds, map holds it back until #1 finishes at 0.4 seconds. It has to keep the submit order. The total time is the same, but when the first plate goes out is different.
as_completed hands back only Futures, so to know which order a result belongs to, the usual trick is to pair them up in a dict.
with ThreadPoolExecutor(max_workers=4) as agency:
table_of = {agency.submit(cook, n): f"T{n}" for n in range(1, 5)} # Future → table
for fut in as_completed(table_of):
print(table_of[fut], fut.result())
# T4 #4
# T3 #3
# T2 #2
# T1 #1The "Agency cooks" mode in the timeline below also runs on a real ThreadPoolExecutor. In this scenario, the later the order number, the shorter the order takes.
Kitchen timeline
Orders
6
Cooks
3
Order of results
Cooking
Waiting
Cancelled
Failed
as_completed, the returned order is jumbled, for example #3 → #2 → #1 → #6 → #5 → #4 → …. Of the orders the three cooks start together, the short ones finish first.map, it's always #1 → #2 → … → #10.as_completed returning them nearly backwards, like #8 → #7 → … → #1. Eight orders start at once, and the short, later-numbered ones finish first.If an exception happens inside a job you handed over, it is quietly stored in the Future. The moment you call result(), it blows up again right there.
def cook(n):
if n == 3:
raise ValueError(f"#{n} burnt") # order #3 got burnt
return f"#{n}"
with ThreadPoolExecutor(max_workers=2) as agency:
futures = [agency.submit(cook, n) for n in range(1, 5)]
# so far, all quiet, as if nothing happened
for f in futures:
try:
print(f.result())
except ValueError as e: # it blows up when you call result()
print("error:", e)
# #1
# #2
# error: #3 burnt
# #4
print(repr(futures[2].exception()), futures[0].exception())
# ValueError('#3 burnt') Noneresult(), the exception isn't printed anywhere and just vanishes. A burnt plate never reaches the guest, and nobody even knows.exception() hands you the exception without raising it. It's None if the job succeeded.map raises the exception right where you hit it while pulling results, and you can't get any results after that. If you need the rest even when one fails, use submit with as_completed.To do something the moment a job finishes, hang a bell on it with add_done_callback. You pass a function that takes the finished Future as its one argument.
def cook(n):
time.sleep(0.1)
return f"#{n}"
def ring_bell(fut): # called when done (a callback); takes one Future
print("bell:", fut.result(), threading.current_thread().name)
with ThreadPoolExecutor(max_workers=2, thread_name_prefix="agency") as agency:
for n in (1, 2):
agency.submit(cook, n).add_done_callback(ring_bell)
# e.g.
# bell: #1 agency_0
# bell: #2 agency_1The callback runs in the thread of the agency cook who finished the job. (Attach it to a Future that's already done and it runs right away, in the thread that attached it.) So if the callback changes shared data, the locking rules from Lesson 3 apply just the same. An exception inside a callback is only logged and otherwise ignored.
Chef's tip
Code that calls submit and never calls result. That's the mistake I've seen most on the job. The exception falls asleep inside the claim ticket. Every job you hand over, either collect it with result or check it with exception.
cancel() can cancel only jobs that haven't started yet. A dish already on the heat can't be taken back.
with ThreadPoolExecutor(max_workers=1) as agency: # one cook
futures = [agency.submit(time.sleep, 0.2) for _ in range(3)]
time.sleep(0.05) # time for the first order to hit the heat
print([f.cancel() for f in futures]) # what's already started can't be taken back
print([f.cancelled() for f in futures], [f.done() for f in futures])
# [False, True, True]
# [False, True, True] [False, True, True]cancel() returns True if the cancellation worked, and False if the job is already running or finished.done() is True once a job has finished or been cancelled. The first order is still cooking, so it's False.To end the contract with the agency all at once, use shutdown(cancel_futures=True). Every job still in line is cancelled, and it waits only for the ones already started.
agency = ThreadPoolExecutor(max_workers=2)
futures = [agency.submit(time.sleep, 0.2) for _ in range(6)]
time.sleep(0.05)
agency.shutdown(cancel_futures=True) # orders in line are cancelled, orders on the heat finish
print(sum(f.cancelled() for f in futures), sum(not f.cancelled() for f in futures))
# 4 2When you want to move as soon as any one of several jobs is done, use wait.
from concurrent.futures import FIRST_COMPLETED, wait
def cook(sec):
time.sleep(sec)
return sec
with ThreadPoolExecutor(max_workers=3) as agency:
futures = [agency.submit(cook, s) for s in (0.3, 0.1, 0.2)]
done, pending = wait(futures, return_when=FIRST_COMPLETED) # as soon as any one is done
print([f.result() for f in done], len(pending))
done, pending = wait(futures, timeout=0.15) # the default is ALL_COMPLETED
print(len(done), len(pending))
# [0.1] 2
# 2 1wait returns two sets: the finished and the unfinished. return_when can be FIRST_COMPLETED, FIRST_EXCEPTION or ALL_COMPLETED (the default), and once timeout passes it returns even if not everything is done. It doesn't raise anything, so check for yourself whether pending is empty.
The nice thing about concurrent.futures is that the contract is the same. Keep submit, map, Future and as_completed as they are, and just swap the agency's name.
from concurrent.futures import InterpreterPoolExecutor, ProcessPoolExecutor, ThreadPoolExecutor
def knead(n):
return sum(range(n))
if __name__ == "__main__": # required for a process pool (Lesson 8)
for Agency in (ThreadPoolExecutor, ProcessPoolExecutor, InterpreterPoolExecutor):
with Agency(max_workers=2) as agency:
print(Agency.__name__, list(agency.map(knead, [10, 100, 1000])))
# ThreadPoolExecutor [45, 4950, 499500]
# ProcessPoolExecutor [45, 4950, 499500]
# InterpreterPoolExecutor [45, 4950, 499500]| Agency | Cooks | Good for | Lesson |
|---|---|---|---|
ThreadPoolExecutor | threads in the same kitchen | waiting work (I/O) | 7 |
ProcessPoolExecutor | branch kitchens (processes) | hands-on work (CPU) | 8 |
InterpreterPoolExecutor | partitioned kitchens in one building (subinterpreters), from 3.14 | hands-on work, lighter than processes | 8 |
Save this example as a .py file and run it, not in the interactive shell. We'll see tomorrow night why a process pool insists on if __name__ == "__main__":.
If you leave out max_workers, a thread pool calls in min(32, os.process_cpu_count() + 4) workers (os.cpu_count() up to 3.12). On this 12-core PC, that's 16. Waiting work needs more threads than there are cores, hence the +4, and the cap at 32 keeps it from getting out of hand. In a real service, it's better to set it yourself based on how much the other server can take.
Tonight's goal was to hand 30 orders to six agency cooks, send each one out as soon as it's ready, all in under a second, without missing a burnt plate. Each order takes 0.1 to 0.2 seconds, and order 13 gets burnt.
def cook(n):
time.sleep(0.1 + 0.05 * (n % 3)) # 0.1 to 0.2 s
if n == 13:
raise ValueError(f"#{n} burnt")
return f"#{n}"
served, burnt = [], []
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=6, thread_name_prefix="agency") as agency:
futures = {agency.submit(cook, n): n for n in range(1, 31)}
for fut in as_completed(futures): # send each out as soon as it's done
try:
served.append(fut.result())
except ValueError:
burnt.append(futures[fut])
print(len(served), burnt, f"{time.perf_counter() - start:.2f}s")
# 29 [13] 0.80s (timing is approximate)Twenty-nine plates went out as they finished, and the burnt number 13 went on the remake list. 0.8 seconds. No code to create threads, and no code to set up a results list in advance.
The guest by the window is here again tonight, alone, with a single bowl of noodles in front of her. While the group's plates stream out, she glances at her wristwatch once, then jots something brief in a small notebook.
As she sends the agency cooks home, the chef says, "Tomorrow we've got a mountain of dough. If I call in four agency cooks to knead it, will it go four times faster?"
Service log — Night seven. Handed a group order of 30 to six agency cooks (ThreadPoolExecutor) and finished in 0.8 seconds. Burnt number 13, but caught it right away in result() and remade it.
as_completed with agency.map(cook, range(1, 31)). What happens at number 13, and how many plates can you collect?wait(..., return_when=FIRST_COMPLETED) and shutdown(cancel_futures=True), write code that takes only the first finished plate out of 30 orders and cancels the rest. How many dishes had already started?Chef's recipe card
with ThreadPoolExecutor(max_workers=n) as ex: fut = ex.submit(f, x); fut.result()
A Future is a claim ticket: done(), result(), exception(), cancel(), add_done_callback(fn).
as_completed(futures) gives finish order; ex.map(f, xs) gives submit order.
An exception inside a job blows up when you call result(). Never call it and the exception quietly vanishes.
cancel() works only on jobs that haven't started. All at once: ex.shutdown(cancel_futures=True). Wait for just one: wait(fs, return_when=FIRST_COMPLETED).
Same contract: ProcessPoolExecutor (Lesson 8), InterpreterPoolExecutor (3.14). The default thread count is min(32, os.process_cpu_count() + 4).
← Previous
Lesson 6 · The ticket rail: queue
Next →
Lesson 8 · Why the dough won't go faster: the GIL and parallelism
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.