Skip to content

Concurrency semantics

intermediateCommon atAnthropicGoogleStripeMetaJane StreetRead firstEvent-driven simulation and timersQueues and dequesPractise in the Sandbox →

A race happens when the result depends on how steps from different threads or tasks interleave. The classic case is a read-modify-write like x = x + 1 split by a context switch. Fix it by making the sequence atomic: a lock around a critical section, an atomic instruction, or a single owner of the state. Locks bring their own failure, deadlock, which a single global lock order prevents.

Two people share one whiteboard tally. Each walks up, reads the number, does the sum in their head, and writes the new number. If both read “41” before either writes, both write “42”. Two visits, one increment. Nobody made a mistake; the interleaving did.

Three words carry the whole topic:

  • Atomic: a group of steps that others see as all-or-nothing. x = x + 1 looks like one step but is three: load, add, store.
  • Critical section: code that touches shared state and must not interleave with other code touching that same state.
  • Happens-before: a guarantee that one action’s effects are visible to another. Locks give you this: everything before unlock is visible to whoever locks next.

This isn’t only about threads. A single-threaded JavaScript or asyncio program races too, at every await: your function pauses, others run, and the value you read may be stale when you resume.

You are the scheduler. Click step A and step B in any order. Try A, B, A, B, A, B and watch an increment vanish. Then switch on wrap in a lock and try to break it. The panel at the bottom runs every possible schedule and counts outcomes.

You are the scheduler: two threads, one counter
step · auto · explore
Thread A
▸ rA = x
rA = rA + 1
x = rA
rA = 0
shared memory
0
x
Thread B
▸ rB = x
rB = rB + 1
x = rB
rB = 0
schedule:(empty)
Both threads want to run x = x + 1 once. To the CPU that is three steps: load into a register, add, store back. Pick who runs next.
Every possible schedule of this program (20 of them), by final value of x:
x = 1
18
x = 2
2
Only 2 of 20 schedules are correct. Tests that run once and pass prove almost nothing.
x0
expected2
rA0
rB0
steps0
next instructionexecutedblocked on a lock

What to notice:

  • The bug only needs one bad moment: a thread loads x while the other has loaded but not yet stored.
  • With one increment each, only 2 of 20 schedules are correct. A test that runs once and passes proves almost nothing.
  • The lock doesn’t make the steps faster or fewer. It removes the bad interleavings by making the other thread wait at lock(L).
The problem says… What to think about
“multiple workers / threads / requests at once” What state is shared? Who may read and write it, and when?
“check then act”: if absent, insert; if balance ≥ x, debit Check and act must be one atomic step, or another actor slips in between.
a counter, total, or map updated from many places Lock, atomic op, or a concurrent collection.
“must happen in order”, “exactly once”, “no lost updates” You need a happens-before edge: a lock, a queue hand-off, or a version check.
two resources locked together (accounts, files, rows) Deadlock risk. Pick a global lock order.
async code that reads, awaits, then writes Single-threaded doesn’t mean race-free.
  1. Read. Copy the shared value somewhere private (a register, a local).
  2. Yield point. Anything can run here: another thread on another core, or another task at an await.
  3. Write back. If someone else wrote in step 2, this overwrites their update with a stale result.
  4. Fix: put steps 1–3 inside one critical section (lock, async lock, semaphore of 1), or replace them with one atomic instruction.
lost update.py
import asyncio
class Counter:
def __init__(self) -> None:
self.value = 0
self.lock = asyncio.Lock()
async def unsafe_increment(c: Counter) -> None:
v = c.value # 1. read
await asyncio.sleep(0) # 2. yield: any other task may run here
c.value = v + 1 # 3. write back a value that may be stale
async def safe_increment(c: Counter) -> None:
async with c.lock: # 4. read-modify-write is now one critical section
v = c.value
await asyncio.sleep(0) # other tasks run, but none can enter the lock
c.value = v + 1
async def run(n: int, inc) -> int:
c = Counter()
await asyncio.gather(*(inc(c) for _ in range(n)))
return c.value
if __name__ == "__main__":
# every task reads 0 before any task writes: 99 updates are lost
assert asyncio.run(run(100, unsafe_increment)) == 1
assert asyncio.run(run(100, safe_increment)) == 100
print("ok")

The Python and TypeScript versions are deterministic: every task reads 0 before any writes, so the unsafe result is exactly 1. The C# version uses real thread-pool threads, so the unsafe result varies from run to run. That variance is the reason concurrency bugs survive testing.

Locks fix races and create a new failure. If T1 holds A and waits for B while T2 holds B and waits for A, neither can ever move. Step T1 once, then T2 once:

Two locks, two threads: deadlock and lock ordering
step · auto
T1
▸ lock(A)
lock(B)
critical work
unlock(B)
unlock(A)
T1T2AB
T2
▸ lock(B)
lock(A)
critical work
unlock(A)
unlock(B)
2 of 6 possible schedules deadlock
T1 takes A then B; T2 takes B then A. Step T1 once, then T2 once, and see what happens.
lock → thread: held bythread → lock: waiting forstuck foreverrunnable

Deadlock needs all four Coffman conditions at once. Break any one and it can’t happen:

Condition Meaning How to break it
Mutual exclusion a lock has one holder use lock-free or immutable data (rarely possible)
Hold and wait hold one lock while waiting for another acquire all locks at once, or none
No preemption nobody can take a lock away try_lock with a timeout, then back off and retry
Circular wait a cycle in the wait-for graph one global lock order: the usual answer
transfer with ordered locks.py
import threading
from dataclasses import dataclass, field
@dataclass
class Account:
id: int
balance: int
lock: threading.Lock = field(default_factory=threading.Lock)
def transfer(src: Account, dst: Account, amount: int) -> None:
# 1. always take locks in one global order (by id), whatever the direction,
# so two opposite transfers can never each hold the lock the other wants
first, second = (src, dst) if src.id < dst.id else (dst, src)
with first.lock:
with second.lock: # 2. both held: the move is atomic to every observer
src.balance -= amount
dst.balance += amount
if __name__ == "__main__":
a, b = Account(1, 1_000), Account(2, 1_000)
t1 = threading.Thread(target=lambda: [transfer(a, b, 1) for _ in range(10_000)])
t2 = threading.Thread(target=lambda: [transfer(b, a, 1) for _ in range(10_000)])
t1.start(); t2.start()
t1.join(timeout=10); t2.join(timeout=10)
assert not t1.is_alive() and not t2.is_alive(), "deadlocked"
assert a.balance + b.balance == 2_000 # 3. money is conserved
assert a.balance == 1_000
print("ok")
  • Threads (threading): Lock (not reentrant), RLock (reentrant), Condition, Semaphore, Event, and queue.Queue, which is already thread-safe.
  • The GIL lets only one thread run Python bytecode at a time, but it can switch between any two bytecodes. x += 1 is several bytecodes, so it is not atomic. Python 3.13 also ships an optional free-threaded build without the GIL (PEP 703), where races show up even more readily.
  • asyncio is single-threaded and cooperative. Code between two awaits runs without interruption, so a race needs an await inside a read-modify-write. Use asyncio.Lock, Semaphore, Event, Queue. These are not thread-safe; don’t share them across threads.
  • CPU-bound parallelism: multiprocessing or concurrent.futures.ProcessPoolExecutor. Threads help with I/O.
Approach Cost Good for Watch out
Mutex / lock ~tens of ns uncontended; contention serializes callers multi-step invariants holding it across I/O, deadlock
Atomic instruction (Interlocked, Atomics) a few ns one counter or flag doesn’t compose: two atomics aren’t one atomic
Compare-and-swap retry loop cheap until heavily contended, then retries burn CPU lock-free structures, optimistic updates ABA problem, starvation
Single owner + queue (actor, channel) one hand-off per message state with many writers the queue can grow without back-pressure
Immutable snapshots / copy-on-write a copy per write read-heavy data write cost, memory

Amdahl’s law sets the ceiling: if a fraction s of the work runs inside one global lock, the speedup with any number of cores is at most 1 / s. With 10% of time in the lock, 8 cores give at most 4.7×, and no number of cores beats 10×. That’s why you shrink critical sections and shard locks (one per key or per bucket) instead of adding threads.

  • Reader-writer lock: many readers or one writer. Wins when reads dominate; can starve writers.
  • Semaphore(n): up to n holders. The standard way to cap concurrency (“at most 5 requests in flight”).
  • Condition variable: wait until a predicate becomes true, releasing the lock while you wait. Always wait in a while loop that re-checks the predicate; wakeups can be spurious or stolen.
  • Optimistic concurrency: read a version, compute, write only if the version is unchanged (CAS in memory, UPDATE … WHERE version = ? in SQL). Retry on conflict.
  • Lock striping: N locks, key hashed to one. Concurrent writes to different keys don’t contend.
  • Deterministic models: represent threads as explicit steps and let the test pick the schedule (like the visualizer above). This is how you test concurrent logic reproducibly.
  • Check-then-act: if key not in cache: cache[key] = load(). Two callers both see “absent” and both load. Make it one atomic operation (setdefault, GetOrAdd, or a lock).
  • Unlocked reads. Guarding writes but not reads still lets a reader see a half-updated invariant (debited one account, not yet credited the other).
  • Forgetting to release on error. Use with, try/finally, using, or lock blocks, never bare acquire/release.
  • Holding a lock while doing I/O or calling unknown code (callbacks). It stalls everyone and invites deadlock.
  • Re-entering a non-reentrant lock. Python’s Lock and SemaphoreSlim deadlock on themselves if the holder tries again.
  • if instead of while around a condition wait.
  • Assuming async means safe. Every await is a point where other code runs.
  • Fire-and-forget tasks (async void in C#, un-awaited promises): exceptions vanish and ordering is lost.
  • Ask: real threads or async on one thread? What is shared? What ordering do callers need (FIFO, fairness)? What happens on timeout or cancellation?
  • Name the invariant your lock protects: “Under lock, the sum of all balances is constant and no balance is negative.” A lock without a stated invariant is a smell.
  • Walk through one bad interleaving out loud. Interviewers want to see you find the race, not just add a lock.
  • Say how you’d test it: a deterministic scheduler or step model, stress tests with many threads, and invariant checks.
  • Follow-ups to expect: “now make it async”, “cap concurrency at k”, “two resources at once” (deadlock), “what if a worker dies holding the lock” (leases and timeouts), “scale to many machines” (distributed locks are a different, harder problem).
Check yourself
pick one
A Node.js handler does: const n = await db.get('count'); await db.set('count', n + 1). Node is single-threaded. Can two concurrent requests lose an update?
Check yourself
pick one
Thread 1 locks accounts in the order (from, to). Thread 2 transfers in the opposite direction at the same time. Which change removes the deadlock with the least effect on throughput?
Check yourself
pick one
In C#, why can't you write await inside a lock block, and what do you use instead?

Classic exercises, not gym questions:

  • Dining philosophers: show the deadlock, then fix it two ways (lock order, and a waiter semaphore).
  • Print “foo” and “bar” alternately from two threads, n times, using only semaphores.
  • Implement a reader-writer lock that doesn’t starve writers.
  • Implement a countdown latch and a reusable barrier.
  • Cap an async crawler-style workload at k concurrent operations with a semaphore and measure throughput as k changes.