Concurrency semantics
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.
The idea in plain words
Section titled “The idea in plain words”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 + 1looks 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
unlockis visible to whoeverlocks 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.
See it
Section titled “See it”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.
x:What to notice:
- The bug only needs one bad moment: a thread loads
xwhile 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).
When to reach for it
Section titled “When to reach for it”| 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. |
How it works
Section titled “How it works”Making a read-modify-write safe
Section titled “Making a read-modify-write safe”- Read. Copy the shared value somewhere private (a register, a local).
- Yield point. Anything can run here: another thread on another core, or another task at an
await. - Write back. If someone else wrote in step 2, this overwrites their update with a stale result.
- Fix: put steps 1–3 inside one critical section (lock, async lock, semaphore of 1), or replace them with one atomic instruction.
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")import assert from "node:assert/strict";
/** A minimal async mutex: each caller waits for the previous holder's promise. */class Mutex { private tail: Promise<void> = Promise.resolve(); async run<T>(fn: () => Promise<T>): Promise<T> { const prev = this.tail; let release!: () => void; this.tail = new Promise<void>((r) => (release = r)); // the next caller waits on us await prev; try { return await fn(); } finally { release(); } // always release, even on throw }}
const counter = { value: 0 };
async function unsafeIncrement(): Promise<void> { const v = counter.value; // 1. read await null; // 2. yield: other async functions may run here counter.value = v + 1; // 3. write back a value that may be stale}
const lock = new Mutex();function safeIncrement(): Promise<void> { return lock.run(async () => { // 4. read-modify-write is now one critical section const v = counter.value; await null; // others run, but none can enter the mutex counter.value = v + 1; });}
async function run(n: number, inc: () => Promise<void>): Promise<number> { counter.value = 0; await Promise.all(Array.from({ length: n }, inc)); return counter.value;}
// single-threaded JS still races: every call reads 0 before any call writesassert.equal(await run(100, unsafeIncrement), 1);assert.equal(await run(100, safeIncrement), 100);console.log("ok");const int N = 1000;var c = new Counter();
// Unsafe: real threads interleave the read and the write, so updates get lost.await Task.WhenAll(Enumerable.Range(0, N).Select(_ => Task.Run(c.UnsafeIncrement)));int racy = c.Value;if (racy > N) throw new Exception("can never exceed N");Console.WriteLine($"unsafe: {racy} of {N}"); // usually < N, varies run to run
c.Value = 0;await Task.WhenAll(Enumerable.Range(0, N).Select(_ => Task.Run(c.SafeIncrement)));if (c.Value != N) throw new Exception($"expected {N}, got {c.Value}");
// For a plain counter, a single atomic instruction beats a lock.int atomic = 0;Parallel.For(0, N, _ => Interlocked.Increment(ref atomic));if (atomic != N) throw new Exception("Interlocked lost an update?");Console.WriteLine("ok");
class Counter{ public int Value; private readonly SemaphoreSlim _gate = new(1, 1); // async-friendly mutex
public async Task UnsafeIncrement() { int v = Value; // 1. read await Task.Yield(); // 2. yield: other tasks (on other threads) run here Value = v + 1; // 3. write back a value that may be stale }
public async Task SafeIncrement() { await _gate.WaitAsync(); // 4. read-modify-write is now one critical section try { int v = Value; await Task.Yield(); Value = v + 1; } finally { _gate.Release(); } // always release, even on exceptions }}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.
Deadlock and lock ordering
Section titled “Deadlock and lock ordering”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:
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 |
import threadingfrom dataclasses import dataclass, field
@dataclassclass 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")import assert from "node:assert/strict";
class Mutex { private tail: Promise<void> = Promise.resolve(); async acquire(): Promise<() => void> { const prev = this.tail; let release!: () => void; this.tail = new Promise<void>((r) => (release = r)); await prev; return release; }}
interface Account { id: number; balance: number; lock: Mutex }const account = (id: number, balance: number): Account => ({ id, balance, lock: new Mutex() });
async function transfer(src: Account, dst: Account, amount: number): Promise<void> { // 1. always take locks in one global order (by id), whatever the direction const [first, second] = src.id < dst.id ? [src, dst] : [dst, src]; const r1 = await first.lock.acquire(); try { await null; // a yield between the two acquires is where opposite orders deadlock const r2 = await second.lock.acquire(); try { src.balance -= amount; dst.balance += amount; } // 2. both held finally { r2(); } } finally { r1(); }}
const a = account(1, 1_000), b = account(2, 1_000);const loop = async (s: Account, d: Account) => { for (let i = 0; i < 1_000; i++) await transfer(s, d, 1); };await Promise.all([loop(a, b), loop(b, a)]); // would hang forever with unordered locksassert.equal(a.balance + b.balance, 2_000); // 3. money is conservedassert.equal(a.balance, 1_000);console.log("ok");var a = new Account(1, 1_000);var b = new Account(2, 1_000);var t1 = Task.Run(() => { for (int i = 0; i < 10_000; i++) Bank.Transfer(a, b, 1); });var t2 = Task.Run(() => { for (int i = 0; i < 10_000; i++) Bank.Transfer(b, a, 1); });if (!Task.WaitAll([t1, t2], TimeSpan.FromSeconds(10))) throw new Exception("deadlocked");if (a.Balance + b.Balance != 2_000) throw new Exception("money not conserved"); // 3.if (a.Balance != 1_000) throw new Exception($"expected 1000, got {a.Balance}");Console.WriteLine("ok");
class Account(int id, int balance){ public int Id { get; } = id; public int Balance { get; set; } = balance; public readonly object Gate = new(); // a private lock object, never `this`}
static class Bank{ public static void Transfer(Account src, Account dst, int amount) { // 1. always take locks in one global order (by id), whatever the direction var (first, second) = src.Id < dst.Id ? (src, dst) : (dst, src); lock (first.Gate) lock (second.Gate) // 2. both held: the move is atomic to every observer { src.Balance -= amount; dst.Balance += amount; } }}Per-language primitives
Section titled “Per-language primitives”- Threads (
threading):Lock(not reentrant),RLock(reentrant),Condition,Semaphore,Event, andqueue.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 += 1is 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 anawaitinside a read-modify-write. Useasyncio.Lock,Semaphore,Event,Queue. These are not thread-safe; don’t share them across threads. - CPU-bound parallelism:
multiprocessingorconcurrent.futures.ProcessPoolExecutor. Threads help with I/O.
-
One thread, run to completion. A callback runs until it returns or hits
await; nothing preempts it. Sox++inside one synchronous block is safe, and a race needs anawaitbetween read and write. -
Two queues. After each task (timer, I/O callback), the loop drains all microtasks (Promise reactions,
queueMicrotask) before taking the next task:setTimeout(() => console.log("timeout"), 0);Promise.resolve().then(() => console.log("microtask"));console.log("sync");// sync, microtask, timeout -
No built-in mutex. Build one from a promise chain (as above), or avoid sharing: give state one owner and send it messages.
-
Real parallelism comes from Worker threads (Node
worker_threads, Web Workers). They share nothing by default;SharedArrayBufferplusAtomicsgives shared memory with atomic ops. -
Promise.allstarts everything at once and fails fast;Promise.allSettledwaits for all. Neither limits concurrency: that’s your job.
lock (obj) { … }compiles toMonitor.Enter/Exitin atry/finally. It is reentrant for the same thread. On .NET 9+, locking aSystem.Threading.Lockinstance uses the newer, faster lock type. Lock a private object, neverthisor a string.- No
awaitinsidelock: the compiler rejects it (CS1996), because the continuation may resume on a different thread. UseSemaphoreSlim(1, 1)withawait WaitAsync()andRelease()infinally. Interlocked(Increment,Add,CompareExchange) for single-variable atomics, cheaper than a lock.- Collections:
ConcurrentDictionary,ConcurrentQueue,BlockingCollection, andSystem.Threading.Channels(Channel.CreateBounded<T>) for async producer/consumer pipelines with back-pressure. asyncis not a thread.awaitfrees the thread while waiting; continuations may run on any thread-pool thread, so shared state still needs protection.
What it costs
Section titled “What it costs”| 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.
Variations you’ll meet
Section titled “Variations you’ll meet”- 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
whileloop 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.
Pitfalls
Section titled “Pitfalls”- 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, orlockblocks, 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
LockandSemaphoreSlimdeadlock on themselves if the holder tries again. ifinstead ofwhilearound a condition wait.- Assuming
asyncmeans safe. Everyawaitis a point where other code runs. - Fire-and-forget tasks (
async voidin C#, un-awaited promises): exceptions vanish and ordering is lost.
In the interview
Section titled “In the interview”- 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
Section titled “Check yourself”Practice on your own
Section titled “Practice on your own”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.
Go deeper
Section titled “Go deeper”- Allen B. Downey, The Little Book of Semaphores (free): every classic synchronization puzzle, with solutions.
- Python
threadingand asyncio synchronization primitives. - MDN: Using microtasks in JavaScript and Node.js: the event loop.
- .NET: the
lockstatement and Overview of synchronization primitives.