Concurrency line
Race conditions and locks
Two cooks updating one whiteboard number can erase each other’s work, but one marker pen, held by one cook at a time, fixes it.
Stops explain each step. The film plays the whole ride.
- Writing the tally
- Asking for a tool
- Reading or handing over
- Stuck for good
Cook 1 reads the tally
Two cooks share one whiteboard tally, now at 0. Cook 1 reads it and remembers 0.
Next stop: Cook 2 reads the same 0
All stops
The everyday version
Two cooks share one whiteboard tally. The marker pen is the lock: a cook may read or change the number only while holding the pen. Everyone else waits.
- Cook 1is the Thread 1
Reads the tally, adds one in their head, writes it back.
- Cook 2is the Thread 2
Does the same job at the same time, on the same whiteboard.
- The whiteboard tallyis the Shared counter
One shared number that both cooks read and change.
- The marker penis the Lock A
Only the cook holding it may read or change the tally. Everyone else waits.
- The knifeis the Lock B
A second tool that some jobs need as well as the pen.
- Cook 1 has the pen, Cook 2 the knifeis a deadlock
Cook 1 holds the pen and wants the knife. Cook 2 holds the knife and wants the pen. Both wait forever. Fix: always take the pen first.
Interview questions
Say the short answer first. Go deeper only if they ask.
What is a race condition?
The result depends on the timing of concurrent operations on shared state.
The classic form is read-modify-write: two threads read the same value, and the second write overwrites the first (a lost update). Check-then-act and ordering bugs are variants.
“A bug where the code runs too fast.”
Is counter += 1 thread-safe in CPython with the GIL?
No.
It compiles to separate load, add and store bytecodes, and threads can switch between them. The docs FAQ lists i = i+1 as not atomic, while L.append(x) and D[x] = y are atomic.
“Yes, the GIL makes it atomic.”
How do you fix a race?
Protect the update with a threading.Lock, or avoid sharing.
with lock: counter += 1. Or use a queue.Queue, per-thread counters summed at the end, or multiprocessing.Value (only as with v.get_lock(): v.value += 1). Across processes, use atomic operations in a database or Redis.
“Add time.sleep() so they do not overlap.”
What is a deadlock and how do you avoid it?
Threads wait on each other’s locks in a cycle.
It needs hold-and-wait plus circular wait. Use one consistent lock order, hold fewer locks, use acquire(timeout=), or one coarse lock. Python does not detect deadlocks.
“Python detects it and raises an error.”
Lock vs RLock?
An RLock can be re-acquired by its owner. A Lock cannot.
An RLock tracks its owner and depth, and only the owner may release it. A plain Lock has no owner, so any thread can release it. RLock costs a little more and can hide design problems.
“RLock is just a faster Lock.”
Does asyncio need locks?
Sometimes: tasks interleave at await.
Code without an await is effectively atomic on one loop, but a read, await, write sequence is not. asyncio.Lock is FIFO, not thread-safe, and has no timeout argument.
“No locks are needed in asyncio, ever.”
How do you reproduce a race in a test?
Many iterations and a tiny switch interval.
Try sys.setswitchinterval(1e-6) and a loop of about 100k increments on several threads. With few iterations a race can stay hidden on modern CPython.
“The test passed once, so it is safe.”
What does with lock: guarantee on an exception?
The lock is released.
with lock: is like acquire() then try: ... finally: release(). A bare acquire() without finally can leave the lock held forever.
“A lock is released automatically when its thread dies.”
Cheat-sheet
Commands you will actually type.
lock = threading.Lock()
with lock: counter += 1Make the read-modify-write one critical section.
if lock.acquire(timeout=2):
try: ...
finally: lock.release()
else: log("could not get lock")Use a timeout so you never hang forever.
rlock = threading.RLock()
with rlock:
with rlock: ... # same thread may re-enterA re-entrant lock for recursive call paths.
with ExitStack() as st:
for lk in sorted((a, b), key=id):
st.enter_context(lk)Enforce one global lock order with a fixed rank (here, each lock’s id).
import faulthandler; faulthandler.dump_traceback_later(10)Dump every thread’s stack after 10 s. Handy for a hang.
import sys; sys.setswitchinterval(1e-6) # amplify races in a reproA teaching and test aid only, not for production.
alock = asyncio.Lock()
async with alock: await update()Serialise tasks across `await` points. Not thread-safe.
import dis; dis.dis("counter += 1")Show that the statement is several bytecodes.