Stop by Stop

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.

Words to know

Stops explain each step. The film plays the whole ride.

Explain it
Route
Cook 1Remembers 0Cook 2Ready to cookWhiteboard tallyShows 0The marker penOn the tableThe knifeNot used yet
  • Writing the tally
  • Asking for a tool
  • Reading or handing over
  • Stuck for good
Stop 1 of 10

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?
Short answer

The result depends on the timing of concurrent operations on shared state.

If they dig deeper

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.

Answer that loses points

“A bug where the code runs too fast.”

Is counter += 1 thread-safe in CPython with the GIL?
Short answer

No.

If they dig deeper

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.

Answer that loses points

“Yes, the GIL makes it atomic.”

How do you fix a race?
Short answer

Protect the update with a threading.Lock, or avoid sharing.

If they dig deeper

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.

Answer that loses points

“Add time.sleep() so they do not overlap.”

What is a deadlock and how do you avoid it?
Short answer

Threads wait on each other’s locks in a cycle.

If they dig deeper

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.

Answer that loses points

“Python detects it and raises an error.”

Lock vs RLock?
Short answer

An RLock can be re-acquired by its owner. A Lock cannot.

If they dig deeper

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.

Answer that loses points

“RLock is just a faster Lock.”

Does asyncio need locks?
Short answer

Sometimes: tasks interleave at await.

If they dig deeper

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.

Answer that loses points

“No locks are needed in asyncio, ever.”

How do you reproduce a race in a test?
Short answer

Many iterations and a tiny switch interval.

If they dig deeper

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.

Answer that loses points

“The test passed once, so it is safe.”

What does with lock: guarantee on an exception?
Short answer

The lock is released.

If they dig deeper

with lock: is like acquire() then try: ... finally: release(). A bare acquire() without finally can leave the lock held forever.

Answer that loses points

“A lock is released automatically when its thread dies.”

Cheat-sheet

Commands you will actually type.

lock = threading.Lock()
with lock: counter += 1

Make 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-enter

A 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 repro

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

Sources