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 3

Time 1:40–2:30

The chalkboard is wrong: race conditions and Lock

Tonight's ticket

#03 · Night three

The chalkboard is wrong

Trouble

Four cooks made 1000 dishes each and added 1 to the number on the kitchen chalkboard for every one. The chalkboard doesn't say 4000. It says 1086.

Goal

Keep the chalkboard exactly right no matter how many cooks there are, without slowing the kitchen down.

Late-night diner Dongsi

Night three, two in the morning, and service is over. A friend of Mr. Tak's came in to help for the day, so there were four cooks. The chef had set one rule: "Every time a bowl goes out, add 1 to the number on the chalkboard."

Counting the tickets, each of the four made 1000 bowls, 4000 in all. But the chalkboard says 1086.

Minho scratches his head. "I swear I added every single time." Sua and Mr. Tak say the same. The chef picks up the chalk. "Nobody's lying. The four of you just stood at the chalkboard at the same time."

3-1. 4000 becomes 1086 (8 min)

Adding 1 to the chalkboard is actually three steps: read the board, add 1 in your head, write it on the board. Let's put those three steps straight into code.

import threading
import time

board = 0                       # the number on the chalkboard

def cook(times):
    global board
    for _ in range(times):
        seen = board            # 1. read the chalkboard
        time.sleep(0)           # 2. look away for a moment (a gap where another cook can get a turn)
        board = seen + 1        # 3. write what you read, plus 1

threads = [threading.Thread(target=cook, args=(1000,)) for _ in range(4)]
for th in threads:
    th.start()
for th in threads:
    th.join()
print(board)
# e.g.: 1086  (different every run; you'll almost never see 4000)
  • time.sleep(0) means "sleep for zero seconds," but in practice it hands the current turn over to another thread. We're deliberately opening a gap between the read and the write where another cook can cut in.
  • In a real program that gap isn't a sleep. It's ordinary code: printing a log line, querying a database, calling a function. There's always a gap somewhere.
  • The number changes every run. On Python 3.14 on this PC, it came out roughly between 1000 and 1200.

When the result depends on the order in which several threads read and rewrite the same value, that's called a race condition. Try it yourself on the chalkboard experiment below. The cooks do exactly what the code above does: "read → time.sleep(0) → write."

Chalkboard experiment

Cooks

4

Times per cook

1000

Use a Lock

4 cooks each add 1 to the chalkboard 1000 times. Run it a few times with 'Use a Lock' off, then switch it on and run it again. Watch whether 'Lost orders' drops to 0.
  • With the Lock off, the board ends up far below what it should read, and it's different every time. The "Lost orders" box shows the gap.
  • Cut it down to 1 cook and the number comes out exactly right even without a Lock. A race condition only happens when two or more threads touch the same value.
  • With the Lock on, it matches the expected number no matter how many cooks or how many times. What that Lock is, is the subject of 3-3.

3-2. A race condition, step by step (8 min)

Say the chalkboard reads 41 when Minho and Sua each send out a bowl at almost the same moment. Slice time into single steps and it looks like this:

StepMinhoSuaBoard
1reads the board: 4141
2(looks away)reads the board: 4141
3writes 41 + 1 = 4242
4writes 41 + 1 = 4242

Two people sent out one bowl each, two bowls in total, so the board should read 43. It reads 42. Sua read the value from before Minho changed it, then wrote her 42 over his 42. Minho's bowl vanished.

  • The problem is another thread cutting in between "read → compute → write." Nothing guarantees those three steps run as one piece.
  • If all four read at once, three bowls can vanish in one go. If all four read 41 and all four write 42, four bowls shrink to one. That's how 4000 drops all the way to 1086.
  • When the timing happens to work out, the number can even be right. That's what makes race conditions the hardest bugs to find: they pass in testing and blow up only on the busiest nights.

3-3. There's only one piece of chalk: Lock (10 min)

The fix is simple: only one person at a time stands at the chalkboard. Keep a single piece of chalk, and only whoever holds it may write on the board. In Python, the chalk is threading.Lock.

board = 0
chalk = threading.Lock()        # only one piece of chalk

def cook(times):
    global board
    for _ in range(times):
        with chalk:             # only whoever holds the chalk stands at the board
            seen = board
            time.sleep(0)
            board = seen + 1

threads = [threading.Thread(target=cook, args=(1000,)) for _ in range(4)]
for th in threads:
    th.start()
for th in threads:
    th.join()
print(board)
# 4000
  • Entering the with chalk: block takes the lock (acquire), and leaving it lets go (release). If another thread already holds it, you wait right there until it's released.
  • It's fine to hand over the turn with time.sleep(0) inside the block. Another cook may get the turn, but without the chalk they can't touch the board.
  • Run it as often as you like: 4000. Inside the lock, "read → compute → write" has become one piece no other thread can cut into.

You can also take and release it by hand instead of using with. In that case, always wrap it in try/finally so the chalk gets put down even if an exception happens halfway.

chalk = threading.Lock()
print(chalk.locked())
chalk.acquire()                 # pick up the chalk
try:
    print(chalk.locked())
    print(chalk.acquire(blocking=False))    # someone already holds it, so False right away
    print(chalk.acquire(timeout=0.1))       # wait 0.1 s, False if you can't get it
finally:
    chalk.release()             # put it down no matter what
print(chalk.locked())
chalk.release()
# False
# True
# False
# False
# False
# RuntimeError: release unlocked lock
MethodWhat it does
acquire()takes the lock. If someone holds it, waits until it's released. True once you have it
acquire(blocking=False)doesn't wait. False right away if it can't take it
acquire(timeout=0.1)waits up to 0.1 seconds. False if it can't take it
release()releases the lock. Releasing a lock that isn't locked raises RuntimeError
locked()whether it's locked right now

Keep the locked section short: only at the chalkboard

A lock makes other people wait. So wrap only the lines that need it. What happens if you hold the chalk while you cook, too?

board = 0
chalk = threading.Lock()

def cook_wide():
    global board
    with chalk:
        time.sleep(0.1)         # holding the chalk while cooking
        board += 1

def cook_narrow():
    global board
    time.sleep(0.1)             # everyone cooks on their own
    with chalk:
        board += 1              # lock only at the chalkboard

for fn in (cook_wide, cook_narrow):
    board = 0
    threads = [threading.Thread(target=fn) for _ in range(4)]
    start = time.perf_counter()
    for th in threads:
        th.start()
    for th in threads:
        th.join()
    print(fn.__name__, board, f"{time.perf_counter() - start:.2f}s")
# cook_wide 4 0.40s
# cook_narrow 4 0.10s

Both get exactly 4. But with cook_wide, everyone else queues up until the one holding the chalk finishes cooking, so four cooks take 0.4 seconds, the same as one cook alone. Lock only the lines that touch the shared value.

Chef's tip

A lock is one piece of chalk for one chalkboard. If people use different chalk for the same board, or someone writes without chalk at all, it's useless. Every piece of code that touches the same value has to go through the same Lock. Miss one spot and the count leaks out through it.

3-4. The GIL doesn't stop race conditions (8 min)

Python (strictly speaking, CPython) has a big lock called the GIL (Global Interpreter Lock). The rule is that only one thread runs Python code at any given moment. Lesson 8 covers it in detail, but it leads to a common misunderstanding right here: "If only one thing runs at a time, how can there be a race condition?"

There can. The GIL only switches threads between bytecode instructions; it doesn't protect your "read → compute → write" as a single piece. Even the one line board += 1 turns out to be several steps when you look inside.

import dis

count = 0

def add():
    global count
    count += 1

dis.dis(add)
# part of the output (3.14):
#   LOAD_GLOBAL              0 (count)     ← read
#   LOAD_SMALL_INT           1
#   BINARY_OP               13 (+=)        ← compute
#   STORE_GLOBAL             0 (count)     ← write

If threads switch between the read and the write, exactly what happened in the 3-2 table happens. The interpreter decides when to switch threads, and you can see the interval with sys.getswitchinterval().

import sys
print(sys.getswitchinterval())
# 0.005

The default is 0.005 seconds. Even if a thread keeps running, roughly every interval it gets asked to hand the turn to another thread. When it hits waiting work like time.sleep or I/O, it hands over sooner.

What if we do nothing but count += 1, four million times, without time.sleep(0)?

count = 0

def cook(times):
    global count
    for _ in range(times):
        count += 1

threads = [threading.Thread(target=cook, args=(1_000_000,)) for _ in range(4)]
for th in threads:
    th.start()
for th in threads:
    th.join()
print(count, sys._is_gil_enabled())
# Python 3.14:   4000000 True
# Python 3.14t:  e.g. 1127695 False  (different every run)
  • On 3.14 (with the GIL) on this PC, it came out 4000000 every time we ran it. But that's only because, by luck, the thread switches never landed between those four steps. Python has never promised to run += atomically (as one piece that can't be split). A new version, or one extra line in the loop, can break it.
  • On the free-threaded 3.14t build without the GIL, threads really do run at the same time, and only a little over one million out of four million survive. Code that happened to work on the GIL build is immediately wrong on 3.14t.
  • The chalkboard code from 3-1 gave, for example, 2410 out of 4000 on 3.14t. The number differs, but it's wrong all the same.
  • There's only one conclusion. When several threads rewrite the same value, wrap it in a Lock, GIL or no GIL. All the more so for code that will move to 3.14t.

3-5. When the same person locks again: RLock (6 min)

A Lock doesn't care who took it. So if the person already holding the chalk reaches for it again, they end up waiting for themselves to put it down, and freeze forever. A real freeze would be a problem here, so let's wait just 0.5 seconds.

def probe(name, lock):
    with lock:                              # lock once
        start = time.perf_counter()
        ok = lock.acquire(timeout=0.5)      # the same thread, once more
        waited = time.perf_counter() - start
        print(name, ok, f"{waited:.1f}s")
        if ok:
            lock.release()

probe("Lock", threading.Lock())
probe("RLock", threading.RLock())
# Lock False 0.5s
# RLock True 0.0s
  • Lock waits the full 0.5 seconds and gives False. Without the timeout, this thread would have frozen here forever.
  • RLock (a reentrant lock) remembers which thread took it. The same thread can take it again as many times as it likes, and has to release it the same number of times before it's fully released. Other threads wait as usual.

This often happens when one locking method calls another locking method.

class Board:
    def __init__(self):
        self.lock = threading.RLock()
        self.count = 0

    def add(self):
        with self.lock:
            self.count += 1

    def add_table(self, dishes):
        with self.lock:             # a whole table in one go
            for _ in range(dishes):
                self.add()          # takes the same lock again inside

board = Board()
threads = [threading.Thread(target=board.add_table, args=(250,)) for _ in range(4)]
for th in threads:
    th.start()
for th in threads:
    th.join()
print(board.count)
# 1000

If self.lock were a Lock, add_table would freeze the moment it called add. RLock is a little heavier, and it can make it hard to tell how many layers deep the locking goes. Changing the design to have a separate "internal method that doesn't lock" (_add) is also a good option.

Closing time (10 min)

Tonight's goal was a chalkboard that's exactly right however many cooks there are, without slowing the kitchen down. Turn the chalkboard into a class and lock only the line that changes it. The cooking (time.sleep) happens outside the lock, each cook on their own.

class Chalkboard:
    def __init__(self):
        self._lock = threading.Lock()
        self.count = 0

    def add(self, n=1):
        with self._lock:            # only at the chalkboard
            self.count += n

board = Chalkboard()

def cook(dishes):
    for _ in range(dishes):
        time.sleep(0.001)           # cooking happens outside the lock
        board.add()

threads = [threading.Thread(target=cook, args=(100,)) for _ in range(4)]
start = time.perf_counter()
for th in threads:
    th.start()
for th in threads:
    th.join()
print(board.count, f"{time.perf_counter() - start:.2f}s")
# e.g.: 400 0.15s  (the count is always 400; the time is approximate)
  • Four cooks, 100 bowls each, and the chalkboard always reads 400. Run it on 3.14t and it still reads 400.
  • The time is only a little longer than one person making 100 bowls (about 0.11 seconds on this PC). The four cook together and only queue briefly at the chalkboard.
  • The only way to change the board (count) is through add(). Hiding the lock inside the class makes it less likely someone writes without the chalk by mistake.

The chef wipes the chalkboard and writes 400 again. "One at a time is fine for the chalkboard. But tomorrow there are three burners and six cooks. Let them in one at a time and two burners sit idle. Isn't there a chalk that lets up to three people in?"

Service log — Night three. The chalkboard shrank from 4000 to 1086. Cause: other cooks cutting in between the read and the write. Fixed with a single piece of chalk (Lock) at the board, locking only the line that updates it.

Exercises

  1. In the chalkboard experiment, turn the Lock off and compare 2 cooks × 2000 times with 8 cooks × 500 times. Both total 4000, but which loses more orders? Why?
  2. Delete time.sleep(0) from the code in 3-1 and run it again. Even if the number comes out right, explain, using 3-4, why you can't call it safe.
  3. Change the Board in 3-5 to use a Lock, and add an internal method _add() that doesn't lock, so that neither add() nor add_table() freezes.

Chef's recipe card

  • Race condition: another thread cuts in between read → compute → write and values vanish. Results differ from run to run.

  • lock = threading.Lock() / with lock: wrap only the lines that change the shared value. By hand: acquire() … finally: release()

  • acquire(blocking=False) and acquire(timeout=seconds) return False if they can't take it. Check the state with locked().

  • RLock: the same thread can lock it again. Take a Lock twice and you freeze yourself.

  • Even with the GIL, count += 1 is not atomic. On 3.14t it goes wrong even more. Shared values always get a Lock.

  • sys.getswitchinterval() = 0.005: threads may switch roughly this often.

← Previous

Lesson 2 · Hiring more cooks: threading basics

Next →

Lesson 4 · Only three burners: synchronization tools

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