Locks and atomics
Mutexes, lock granularity, reentrancy, read-write locks, compare-and-swap and memory visibility (volatile), with step-through demos and code in Java, Python, C#, Go and TypeScript.
- Read first
- Threads and race conditions
The previous page ended with one rule. The steps that depend on each other must happen as one step that can’t be split. This page is about the two tools that make that happen:
- A lock makes any block of code indivisible. The price is that other threads must wait.
- An atomic operation makes one memory update indivisible, with no waiting at all. But it covers only one update.
Most of the skill is choosing between them, and sizing the lock.
Both tools also solve a quieter problem. Most engineers learn about it from a production incident. It’s whether one thread sees another thread’s writes at all.
A mutex (mutual exclusion lock) has two states, free and held. While held, it has one owner.
lock()takes it if it’s free. Otherwise it blocks the calling thread until it is free.unlock()frees it, and lets one waiting thread in.
The code between them is a critical section. While one thread is inside, every other thread that wants the same lock waits at the door.
The stepper below shows two threads adding to a counter. First they use a lock, then compare-and-swap. With the lock on, play A, B, A, B. Thread B’s first step blocks. The dashed chip in the trace marks a step that waited.
- lock.lock()
- tmp = count
- count = tmp + 1
- lock.unlock()
ready
- lock.lock()
- tmp = count
- count = tmp + 1
- lock.unlock()
ready
Both threads add 1 to count. With a lock, the second thread waits. With compare-and-swap, nobody waits: a thread that loses the race retries. Try A, B, A, B.
That wait is the whole fix, and also the whole cost. A lock turns a race into a queue.
Stage 1: one lock around the invariant
Section titled “Stage 1: one lock around the invariant”An inventory service reserves stock for orders. Two numbers must always add up to the total: units available and units reserved. Reserving checks that there’s enough available, then moves units across. That’s a check-then-act on two fields. So all of it goes inside one critical section.
import java.util.concurrent.locks.ReentrantLock;
// Eight threads reserve and release stock at once. The invariant: available + reserved == total.public class Main { public static void main(String[] args) throws InterruptedException { Stock stock = new Stock(100); Thread[] workers = new Thread[8]; for (int w = 0; w < workers.length; w++) { workers[w] = new Thread(() -> { for (int i = 0; i < 100_000; i++) { if (stock.reserve(1)) stock.release(1); stock.checkInvariant(); // another thread never sees the two fields half updated } }); workers[w].start(); } for (Thread t : workers) t.join(); stock.checkInvariant(); System.out.println("available=" + stock.available() + " reserved=" + stock.reserved()); // available=100 reserved=0 }}
class Stock { // Private and final: only this class can take it, so only this class decides who waits. // Locking `this` (synchronized methods) would let any caller holding a reference lock it too. private final ReentrantLock lock = new ReentrantLock(); private final int total; private int available; // guarded by lock private int reserved; // guarded by lock
Stock(int total) { this.total = total; this.available = total; }
// Check-then-act on two fields that must move together, so the whole method is one // critical section. Anything slow (a database write, a log call) belongs outside it. boolean reserve(int qty) { lock.lock(); try { if (available < qty) return false; available -= qty; reserved += qty; return true; } finally { lock.unlock(); // always in finally: an exception inside must not leave it locked forever } }
void release(int qty) { lock.lock(); try { reserved -= qty; available += qty; } finally { lock.unlock(); } }
// Reads take the lock too. Without it, a reader could see `available` already lowered and // `reserved` not yet raised, or a stale value cached by its core. void checkInvariant() { lock.lock(); try { if (available + reserved != total) throw new AssertionError("broken: " + available + " + " + reserved); } finally { lock.unlock(); } }
int available() { lock.lock(); try { return available; } finally { lock.unlock(); } } int reserved() { lock.lock(); try { return reserved; } finally { lock.unlock(); } }}import threading
# Reserve and release stock from many threads. Invariant: available + reserved == total.class Stock: def __init__(self, total: int) -> None: # One private lock guards both fields, because they must always change together. self._lock = threading.Lock() self._total = total self._available = total # guarded by _lock self._reserved = 0 # guarded by _lock
def reserve(self, qty: int) -> bool: # Check-then-act on two fields, so the whole method is one critical section. `with` # releases the lock even if the body raises. Slow work (a database write, a log call) # belongs outside it. with self._lock: if self._available < qty: return False self._available -= qty self._reserved += qty return True
def release(self, qty: int) -> None: with self._lock: self._reserved -= qty self._available += qty
def check_invariant(self) -> None: # Reads take the lock too, or a reader could see one field updated and not the other. with self._lock: assert self._available + self._reserved == self._total, (self._available, self._reserved)
def snapshot(self) -> tuple[int, int]: with self._lock: return self._available, self._reserved
def worker(stock: Stock) -> None: for _ in range(20_000): if stock.reserve(1): stock.release(1) stock.check_invariant()
if __name__ == "__main__": stock = Stock(100) threads = [threading.Thread(target=worker, args=(stock,)) for _ in range(8)] for t in threads: t.start() for t in threads: t.join() stock.check_invariant() print("available=%d reserved=%d" % stock.snapshot()) # available=100 reserved=0using System;using System.Threading;
// Eight threads reserve and release stock at once. The invariant: available + reserved == total.static class Program{ static void Main() { var stock = new Stock(100); var workers = new Thread[8]; for (int w = 0; w < workers.Length; w++) { workers[w] = new Thread(() => { for (int i = 0; i < 100_000; i++) { if (stock.Reserve(1)) stock.Release(1); stock.CheckInvariant(); // another thread never sees the two fields half updated } }); workers[w].Start(); } foreach (var t in workers) t.Join(); stock.CheckInvariant(); Console.WriteLine($"available={stock.Available} reserved={stock.Reserved}"); // available=100 reserved=0 }}
class Stock{ // Private: only this class can lock it. lock(this) would let any caller lock it too. // (C# 13 adds System.Threading.Lock as a dedicated type; a plain object works everywhere.) private readonly object _gate = new(); private readonly int _total; private int _available; // guarded by _gate private int _reserved; // guarded by _gate
public Stock(int total) => _total = _available = total;
// Check-then-act on two fields that must move together, so the whole method is one critical // section. lock compiles to Monitor.Enter/Exit in try/finally, so an exception still unlocks. public bool Reserve(int qty) { lock (_gate) { if (_available < qty) return false; _available -= qty; _reserved += qty; return true; } }
public void Release(int qty) { lock (_gate) { _reserved -= qty; _available += qty; } }
// Reads take the lock too, or a reader could see one field updated and not the other. public void CheckInvariant() { lock (_gate) { if (_available + _reserved != _total) throw new InvalidOperationException($"broken: {_available} + {_reserved}"); } }
public int Available { get { lock (_gate) return _available; } } public int Reserved { get { lock (_gate) return _reserved; } }}package main
import ( "fmt" "sync")
// Stock is reserved and released from many goroutines. Invariant: available+reserved == total.type Stock struct { mu sync.Mutex // unexported: only Stock's methods can lock it total int available int // guarded by mu reserved int // guarded by mu}
func NewStock(total int) *Stock { return &Stock{total: total, available: total} }
// Reserve is check-then-act on two fields that must move together, so the whole method is one// critical section. defer unlocks on every return path, including a panic.func (s *Stock) Reserve(qty int) bool { s.mu.Lock() defer s.mu.Unlock() if s.available < qty { return false } s.available -= qty s.reserved += qty return true}
func (s *Stock) Release(qty int) { s.mu.Lock() defer s.mu.Unlock() s.reserved -= qty s.available += qty}
// CheckInvariant locks too: an unlocked read is a data race, and -race would report it.func (s *Stock) CheckInvariant() { s.mu.Lock() defer s.mu.Unlock() if s.available+s.reserved != s.total { panic(fmt.Sprintf("broken: %d + %d", s.available, s.reserved)) }}
func (s *Stock) Snapshot() (int, int) { s.mu.Lock() defer s.mu.Unlock() return s.available, s.reserved}
func main() { stock := NewStock(100) var wg sync.WaitGroup for range 8 { wg.Add(1) go func() { defer wg.Done() for range 100_000 { if stock.Reserve(1) { stock.Release(1) } stock.CheckInvariant() } }() } wg.Wait() a, r := stock.Snapshot() fmt.Printf("available=%d reserved=%d\n", a, r) // available=100 reserved=0}// On one Node thread, a method with no await in it is already a critical section. The lock is// needed only when the critical section must await, here a write to the database between the// check and the update. This Mutex queues callers on a promise chain, one at a time.class Mutex { #tail: Promise<void> = Promise.resolve();
// Resolves with a release function once every earlier caller has released. lock(): Promise<() => void> { let release!: () => void; const mine = new Promise<void>((resolve) => (release = resolve)); const ready = this.#tail.then(() => release); this.#tail = this.#tail.then(() => mine); return ready; }}
// Stands in for persisting the reservation before confirming it.const saveToDb = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
class Stock { #lock = new Mutex(); #available: number; // guarded by #lock while a reservation is in flight #reserved = 0; readonly total: number;
constructor(total: number) { this.total = total; this.#available = total; }
async reserve(qty: number): Promise<boolean> { const release = await this.#lock.lock(); try { if (this.#available < qty) return false; await saveToDb(); // without the lock, other reservations would check stale stock here this.#available -= qty; this.#reserved += qty; return true; } finally { release(); // in finally, so a failed save doesn't block every later caller } }
get available(): number { return this.#available; } get reserved(): number { return this.#reserved; }}
async function main(): Promise<void> { const stock = new Stock(100); // 500 customers try to reserve 1 unit each, all at once. const results = await Promise.all(Array.from({ length: 500 }, () => stock.reserve(1))); const won = results.filter(Boolean).length; if (won !== 100 || stock.available !== 0 || stock.reserved !== 100) throw new Error("oversold"); console.log(`reserved ${won} of 500 requests, available=${stock.available}`); // reserved 100 of 500 requests, available=0}
main();What each language version shows:
- Java:
ReentrantLock, withunlock()infinally.synchronizedblocks do the same, and unlock automatically.ReentrantLockaddstryLock, timeouts and fairness, covered below. - Python and C#:
with lock:andlock (gate) { }release the lock however the block exits, including by an exception. Never write the lock and unlock calls by hand without afinally. - Go:
mu.Lock(), thendefer mu.Unlock()on the next line. So every return path unlocks. - TypeScript: there is one thread, so a method with no
awaitis already a critical section. It needs no lock. This method awaits a database write between the check and the update. So it needs an async mutex: a promise chain that lets one caller in at a time. Without it, all 500 requests check the stock before any of them reserves, and the shop oversells.
Three rules in that code matter more than which lock class you pick:
- The lock is private. Java’s
synchronizedmethods and C#’slock (this)lock the object itself. Any outside code that holds a reference can also lock it, by accident or on purpose. With a private lock object, only the class decides who waits. - Every access to the guarded state takes the same lock, reads included. A method that reads
without it can see
availablealready lowered andreservednot yet raised. Mark guarded fields with a comment (// guarded by lock), so the rule survives the next person’s edit. - Nothing slow inside. No I/O, no network call, no logging to a remote sink, and no callback into code you don’t own. Every millisecond inside the lock is a millisecond every other caller waits. And a callback might take another lock, which is how deadlocks start.
Make this inventory class thread safe.
Whether you name the invariant first (available + reserved = total), and size the critical section from it. That means one private lock, held for the check and both updates, and taken by every reader. Locking each field separately, or leaving getters unlocked, is the red flag. So is putting the database call inside the lock without saying why.
Stage 2: how big should the lock be?
Section titled “Stage 2: how big should the lock be?”One lock for the whole inventory is correct and simple. But say there are 10,000 products and 64 threads. Then a reservation for socks waits behind a reservation for tents, although they share nothing. That waiting is contention. It grows with the number of threads, not with the amount of work.
The fix is to lock less at a time. Give each product its own lock, so threads only queue when they
touch the same product. Or give one lock to each of N buckets of products, which is called lock
striping. Java’s ConcurrentHashMap works this way inside: a write locks only the one bucket it
changes.
Start with one lock. Split it when a profiler shows threads blocked on it, not before. Most interview designs never need more than a lock per entity.
Stage 3: reentrancy, tryLock and fairness
Section titled “Stage 3: reentrancy, tryLock and fairness”Three properties of a lock that interviewers ask about by name:
- Reentrant: the thread that holds a lock can take it again without blocking itself. It must
then unlock it as many times. Java’s
synchronizedandReentrantLock, C#’slockand Python’sRLockare reentrant. Go’ssync.Mutexand Python’sLockare not: a goroutine that locks twice deadlocks on itself. Reentrancy lets one locked method call another. The cost is that it hides the fact that you are already inside a critical section. - tryLock and timeouts:
tryLock()takes the lock if it’s free, and returns false otherwise, without waiting. With a timeout, it waits up to a limit. This is how code avoids deadlock: give up and retry. It’s also how a request avoids waiting longer than its own deadline. - Fairness: by default, a released lock can go to any waiting thread, or even to a thread that
just arrived. That “barging” is faster, because there’s no hand-off to a sleeping thread. But it
can starve a waiter. A fair lock (
new ReentrantLock(true)) hands the lock over in arrival order. That usually costs a lot of throughput. Leave fairness off unless you’ve measured starvation.
Read-write locks
Section titled “Read-write locks”Sometimes reads far outnumber writes, like a config map read on every request and changed once a day. A mutex then makes readers queue behind each other for no reason, since readers can’t hurt each other. A read-write lock has two modes: any number of readers at once, or one writer alone.
For data that changes rarely, a simpler pattern often beats both. Keep the data in an immutable object. When it changes, swap the whole object with one atomic reference write. This is called copy-on-write. Readers never lock at all.
Atomics and compare-and-swap
Section titled “Atomics and compare-and-swap”An atomic variable gives you single operations that the CPU performs indivisibly:
incrementAndGet, getAndSet, and the one everything else is built from, compare-and-swap
(CAS). compareAndSet(expected, new) writes new only if the variable still holds expected. It
tells you whether it did. It’s one instruction (lock cmpxchg on x86). No other core can change the
value between the compare and the swap.
That gives a different way to update shared state, without waiting:
- Read the current value.
- Compute the new value from it.
- CAS: write the new value only if the variable still holds what you read.
- If the CAS failed, another thread changed it first. Go back to step 1.
Switch the stepper above to Compare-and-swap loop, and play A, B, A, B. Both threads read 0. A’s CAS succeeds. B’s fails, because the value is no longer 0. So B re-reads 1 and retries. Nobody blocks. The thread that lost the race does a little more work, instead of waiting.
A real use: track the slowest request latency across all threads, with no lock.
import java.util.concurrent.atomic.AtomicLong;
// Track the slowest request seen so far, from many threads, without a lock.public class Main { public static void main(String[] args) throws InterruptedException { MaxTracker slowest = new MaxTracker(); Thread[] workers = new Thread[8]; for (int w = 0; w < workers.length; w++) { final long base = w * 1_000_000L; workers[w] = new Thread(() -> { for (long i = 1; i <= 100_000; i++) slowest.record(base + i); }); workers[w].start(); } for (Thread t : workers) t.join(); if (slowest.max() != 7_100_000L) throw new AssertionError("lost a maximum: " + slowest.max()); System.out.println("max = " + slowest.max() + ", failed CAS = " + slowest.retries()); // max = 7100000 }}
class MaxTracker { private final AtomicLong max = new AtomicLong(Long.MIN_VALUE); private final AtomicLong retries = new AtomicLong(); // only to show contention
// A compare-and-set loop: read, compute, and write only if nobody changed the value since // the read. If someone did, the CAS fails and we retry with the fresh value. No thread ever // waits, and no thread can be stuck holding anything. void record(long latency) { long seen = max.get(); while (latency > seen) { if (max.compareAndSet(seen, latency)) return; // won: nothing changed in between retries.incrementAndGet(); seen = max.get(); // lost the race: someone wrote first, so re-read and decide again } // latency <= seen: nothing to do. (max.accumulateAndGet(latency, Math::max) is this // same loop, written for you.) }
long max() { return max.get(); } long retries() { return retries.get(); }}import threading
# Track the slowest request seen so far, from many threads.## Python's standard library has no compare-and-set, so the portable answer is a lock. Under# the GIL an uncontended lock costs well under a microsecond, which is usually fine; when it# isn't, give each thread its own maximum and combine them at read time (confinement).class MaxTracker: def __init__(self) -> None: self._lock = threading.Lock() self._max = float("-inf") # guarded by _lock
def record(self, latency: float) -> None: # Fast path without the lock: a stale read can only make us take the lock needlessly, # never skip a real new maximum, because the check is repeated under the lock. if latency <= self._max: return with self._lock: if latency > self._max: self._max = latency
@property def max(self) -> float: with self._lock: return self._max
def worker(tracker: MaxTracker, base: int) -> None: for i in range(1, 50_001): tracker.record(base + i)
if __name__ == "__main__": slowest = MaxTracker() threads = [threading.Thread(target=worker, args=(slowest, w * 1_000_000)) for w in range(8)] for t in threads: t.start() for t in threads: t.join() assert slowest.max == 7_050_000, slowest.max print("max =", slowest.max) # max = 7050000using System;using System.Threading;
// Track the slowest request seen so far, from many threads, without a lock.static class Program{ static void Main() { var slowest = new MaxTracker(); var workers = new Thread[8]; for (int w = 0; w < workers.Length; w++) { long baseValue = w * 1_000_000L; workers[w] = new Thread(() => { for (long i = 1; i <= 100_000; i++) slowest.Record(baseValue + i); }); workers[w].Start(); } foreach (var t in workers) t.Join(); if (slowest.Max != 7_100_000L) throw new InvalidOperationException($"lost a maximum: {slowest.Max}"); Console.WriteLine($"max = {slowest.Max}, failed CAS = {slowest.Retries}"); // max = 7100000 }}
class MaxTracker{ private long _max = long.MinValue; private long _retries; // only to show contention
// A compare-and-swap loop: read, compute, and write only if nobody changed the value since // the read. CompareExchange returns what was there; if that isn't what we read, someone // wrote first, so we retry with the fresh value. No thread ever waits. public void Record(long latency) { long seen = Volatile.Read(ref _max); while (latency > seen) { long found = Interlocked.CompareExchange(ref _max, latency, seen); if (found == seen) return; // won: nothing changed in between Interlocked.Increment(ref _retries); seen = found; // lost the race: decide again against the value that won } }
public long Max => Volatile.Read(ref _max); public long Retries => Interlocked.Read(ref _retries);}package main
import ( "fmt" "math" "sync" "sync/atomic")
// MaxTracker tracks the slowest request seen so far, from many goroutines, without a lock.type MaxTracker struct { max atomic.Int64 retries atomic.Int64 // only to show contention}
func NewMaxTracker() *MaxTracker { t := &MaxTracker{} t.max.Store(math.MinInt64) return t}
// Record is a compare-and-swap loop: read, compute, and write only if nobody changed the value// since the read. If someone did, the CAS fails and we retry with the fresh value. No goroutine// ever waits, and none can be stuck holding anything.func (t *MaxTracker) Record(latency int64) { for { seen := t.max.Load() if latency <= seen { return } if t.max.CompareAndSwap(seen, latency) { return // won: nothing changed in between } t.retries.Add(1) // lost the race: loop to re-read and decide again }}
func main() { slowest := NewMaxTracker() var wg sync.WaitGroup for w := range 8 { wg.Add(1) go func() { defer wg.Done() base := int64(w) * 1_000_000 for i := int64(1); i <= 100_000; i++ { slowest.Record(base + i) } }() } wg.Wait() if slowest.max.Load() != 7_100_000 { panic(fmt.Sprint("lost a maximum: ", slowest.max.Load())) } fmt.Println("max =", slowest.max.Load(), "failed CAS =", slowest.retries.Load()) // max = 7100000}// Real parallelism in Node: worker threads sharing memory through a SharedArrayBuffer. Plain// reads and writes on shared memory race exactly as in Java; Atomics gives the CPU's atomic// instructions, including compare-and-exchange.import { Worker, isMainThread, workerData } from "node:worker_threads";
const WORKERS = 4;const PER_WORKER = 100_000;
// slot 0: the maximum, slot 1: failed CAS count (only to show contention)function record(shared: BigInt64Array, latency: bigint): void { let seen = Atomics.load(shared, 0); while (latency > seen) { // Writes `latency` only if the slot still holds `seen`; returns what was there either way. const found = Atomics.compareExchange(shared, 0, seen, latency); if (found === seen) return; // won: nothing changed in between Atomics.add(shared, 1, 1n); seen = found; // lost the race: decide again against the value that won }}
if (isMainThread) { const shared = new BigInt64Array(new SharedArrayBuffer(16)); shared[0] = -(2n ** 63n); const done = Array.from({ length: WORKERS }, (_, w) => { const worker = new Worker(new URL(import.meta.url), { workerData: { shared, base: w * 1_000_000 } }); return new Promise((resolve, reject) => worker.on("exit", resolve).on("error", reject)); }); await Promise.all(done); const max = Atomics.load(shared, 0); if (max !== BigInt((WORKERS - 1) * 1_000_000 + PER_WORKER)) throw new Error(`lost a maximum: ${max}`); console.log(`max = ${max}, failed CAS = ${Atomics.load(shared, 1)}`); // max = 3100000} else { const { shared, base } = workerData as { shared: BigInt64Array; base: number }; for (let i = 1; i <= PER_WORKER; i++) record(shared, BigInt(base + i));}- Java, C#, Go: the classic CAS loop. Java also wraps it for you as
accumulateAndGet(latency, Math::max). - Python: the standard library has no CAS, so the honest answer is a lock. It adds a cheap unlocked fast path that skips the lock for values that can’t be a new maximum.
- TypeScript: worker threads share memory through a
SharedArrayBuffer. Plain reads and writes on it race exactly as in Java.Atomics.compareExchangeis the same CAS instruction.
Go deeperThe ABA problem2 min
CAS checks that the value is the same as when you read it. It does not check that nothing happened in between. Say another thread changes A to B, and back to A. Your CAS succeeds as though nothing changed. For a counter or a maximum, that’s harmless. For a lock-free stack, it isn’t:
- Thread 1 reads top = node A, and next = B. Then it gets paused.
- Thread 2 pops A, pops B, and pushes A back.
- Thread 1’s CAS(top, A, B) succeeds. It makes the freed node B the top of the stack.
There are two fixes. One pairs the value with a version number that every write increments. Then
A-version-1 and A-version-3 compare unequal (Java’s AtomicStampedReference). The other relies on
garbage collection, so a node can’t be reused while any thread still holds a reference to it. This
is one reason lock-free data structures beyond counters are best left to library authors.
A hit counter is hammered by 64 threads and the atomic increment shows up in the profiler. What now?
Whether you know the cost is contention on one cache line, not the instruction. Every core
fights for the same 64 bytes. The expected answer stripes the counter: per-thread or per-core
cells, summed on read, as LongAdder does. Or it batches increments locally and flushes them.
The red flag is proposing a lock instead of the atomic. That’s the same contention, plus
sleeping.
Visibility and volatile
Section titled “Visibility and volatile”The third way shared state breaks has nothing to do with the order of steps. Your code writes
x = 1 on one core, and another core keeps reading x = 0. Or it sees writes in a different order
from the one you wrote them in.
Two things cause it. Compilers and JITs keep values in registers, and reorder instructions that look independent. And CPUs buffer writes in per-core store buffers before they reach the shared cache. Both are allowed, because each thread on its own can’t tell. Another thread can.
The stepper shows the effect. The writer sets data = 42, then ready = true. The reader waits for
ready, then prints data. With plain fields, nothing stops the two writes from becoming visible
in the other order. The first variant shows this. Play A, B, B, and the reader prints 0.
- ready = true // moved up: nothing forbids it
- data = 42
ready
- while (!ready) {}
- print(data)
ready
The writer (A) fills in data, then sets ready. The reader (B) waits for ready, then prints data. Without volatile, the compiler or CPU may move the writes; this variant shows the order the other core can observe. Try A, B, B.
Each language’s memory model defines exactly when a write is guaranteed to be visible to another thread. They all come down to one idea, happens-before. If action X happens-before Y, then Y sees X’s effects. You get happens-before edges from:
- Unlocking a lock happens-before the next lock of the same lock. That’s the visibility half of a lock’s job. It’s the reason the stock example’s reads take the lock.
- A volatile or atomic write happens-before a later read of the same variable that sees it. And everything written before the volatile write comes along with it.
- Starting a thread happens-before anything it runs. And everything a thread did
happens-before another thread’s
join()returns.
volatile (Java, C#) or an atomic (Go’s atomic.Bool, C++’s std::atomic) is the light tool for
one flag. Examples are a stop signal, or a “ready” flag that publishes data written before it.
// One thread publishes a result and a flag; another stops when told to. Both need visibility.public class Main { // volatile: every write is visible to every later read, on any core, and writes made before // a volatile write can't be moved after it. Delete the keyword and the worker below may spin // forever: the JIT is allowed to read a plain field once and keep it in a register. static volatile boolean running = true;
// Plain fields are fine here, because they are published through the volatile `ready`. static long result; static volatile boolean ready = false;
public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { long spins = 0; while (running) spins++; // re-reads `running` from memory on every iteration result = spins; // plain write... ready = true; // ...published by the volatile write that follows it }); worker.start(); Thread.sleep(100); running = false; // the worker sees this within nanoseconds while (!ready) Thread.onSpinWait(); // Seeing ready == true guarantees seeing the result written before it (happens-before). if (result <= 0) throw new AssertionError("result not visible"); worker.join(); System.out.println("worker stopped after a positive number of spins"); }}import threadingimport time
# CPython's GIL makes every write visible to the next thread that runs, so a plain bool flag# happens to work today. Use threading.Event anyway: it is documented as thread safe, it keeps# working on free-threaded builds, and it lets a waiting thread sleep instead of spinning.stop = threading.Event()done = threading.Event()result: list[int] = []
def worker() -> None: spins = 0 while not stop.is_set(): spins += 1 result.append(spins) # written before done.set()... done.set() # ...so a thread that sees done also sees the result
if __name__ == "__main__": t = threading.Thread(target=worker) t.start() time.sleep(0.1) stop.set() assert done.wait(timeout=2), "worker never saw the stop flag" assert result and result[0] > 0 t.join() print("worker stopped after a positive number of spins")using System;using System.Threading;
// One thread publishes a result and a flag; another stops when told to. Both need visibility.static class Program{ // volatile: reads always go to memory, and the JIT can't hoist them out of the loop or move // writes across them. Remove it and, in a Release build, the worker may spin forever. static volatile bool _running = true;
// A plain field, published through the volatile _ready that is written after it. static long _result; static volatile bool _ready;
static void Main() { var worker = new Thread(() => { long spins = 0; while (_running) spins++; _result = spins; // plain write... _ready = true; // ...published by the volatile (release) write that follows it }); worker.Start(); Thread.Sleep(100); _running = false; var wait = new SpinWait(); while (!_ready) wait.SpinOnce(); // Seeing _ready == true (an acquire read) guarantees seeing the _result written before it. if (_result <= 0) throw new InvalidOperationException("result not visible"); worker.Join(); Console.WriteLine("worker stopped after a positive number of spins"); }}package main
import ( "fmt" "sync/atomic" "time")
// One goroutine publishes a result and a flag; another stops when told to. A plain bool here// is a data race: the compiler may read it once and loop forever, and -race reports it.// atomic.Bool makes each Store visible to later Loads, and orders the writes before it.var running atomic.Boolvar ready atomic.Boolvar result int64 // plain, published through ready
func main() { running.Store(true) go func() { var spins int64 for running.Load() { spins++ } result = spins // plain write... ready.Store(true) // ...published by the atomic store that follows it }() time.Sleep(100 * time.Millisecond) running.Store(false) for !ready.Load() { time.Sleep(time.Millisecond) } // ready.Load() returning true happens after ready.Store(true), so result is visible here. if result <= 0 { panic("result not visible") } fmt.Println("worker stopped after a positive number of spins")}// Visibility only becomes a question in Node when two threads share memory: worker threads and// a SharedArrayBuffer. Atomics.load and Atomics.store are the equivalent of volatile: always// read from and write to shared memory, and keep the writes before them in order.import { Worker, isMainThread, workerData, parentPort } from "node:worker_threads";
// slot 0: running flag, slot 1: ready flag, slot 2: resultif (isMainThread) { const shared = new Int32Array(new SharedArrayBuffer(12)); Atomics.store(shared, 0, 1); const worker = new Worker(new URL(import.meta.url), { workerData: shared }); await new Promise((resolve) => worker.once("message", resolve)); // worker is spinning await new Promise((resolve) => setTimeout(resolve, 100)); Atomics.store(shared, 0, 0); // tell the worker to stop while (Atomics.load(shared, 1) === 0) await new Promise((resolve) => setTimeout(resolve, 1)); // Seeing ready = 1 through Atomics.load guarantees seeing the result stored before it. if (Atomics.load(shared, 2) <= 0) throw new Error("result not visible"); await worker.terminate(); console.log("worker stopped after a positive number of spins");} else { const shared = workerData as Int32Array; parentPort!.postMessage("started"); let spins = 0; while (Atomics.load(shared, 0) === 1) spins = (spins + 1) % 1_000_000_000; Atomics.store(shared, 2, spins + 1); // written before the flag... Atomics.store(shared, 1, 1); // ...so a reader that sees the flag sees the result}Go deeperDouble-checked locking, and why the classic version broke3 min
Lazy initialization of a shared object is check-then-act, so it needs a lock. Double-checked locking tries to skip the lock once the object exists:
private static Config instance; // BROKEN without volatilestatic Config get() { if (instance == null) { // first check, no lock synchronized (Config.class) { if (instance == null) { // second check, under the lock instance = new Config(); // allocate, construct, assign } } } return instance;}The bug is visibility. instance = new Config() is three steps: allocate memory, run the
constructor, assign the reference. The compiler or CPU may assign the reference before the
constructor’s writes are visible. A second thread passes the unlocked first check, and sees a
non-null instance. It then uses an object whose fields are still zero.
Declaring instance as volatile fixes it since Java 5, because the volatile write publishes the
constructor’s writes. Simpler still in Java is a static holder class, which the JVM initializes
lazily and safely (private static class Holder { static final Config I = new Config(); }). In C#,
use Lazy<T>. In Go, use sync.Once. In Python, use a lock or module-level initialization.
A lock gives two guarantees. Only one thread is in the critical section. And the next thread to take the lock sees everything the last one wrote. For a single flag, I only need the second, so volatile is enough. For anything that reads and then writes, I need the first too. So I’d use an atomic or a lock.
Each language, honestly
Section titled “Each language, honestly”| Language | Lock | Atomic and CAS | Visibility |
|---|---|---|---|
| Java | synchronized, ReentrantLock, ReentrantReadWriteLock, StampedLock |
AtomicInteger, AtomicReference, LongAdder, VarHandle |
volatile, plus everything a lock or atomic gives |
| Python | threading.Lock, RLock |
None in the standard library; use a lock | The GIL makes writes visible today; free-threaded builds need real synchronization |
| C# | lock, Monitor, ReaderWriterLockSlim, SemaphoreSlim(1) for async |
Interlocked, Volatile.Read/Write |
volatile, Volatile, plus locks and Interlocked |
| Go | sync.Mutex (not reentrant), sync.RWMutex |
sync/atomic types: atomic.Int64, atomic.Pointer |
Only through sync, atomics and channels; a plain shared bool is a data race |
| TypeScript | Not needed for synchronous code; an async mutex across await |
Atomics on a SharedArrayBuffer, between workers |
Atomics.load and Atomics.store between workers |
How locks fail
Section titled “How locks fail”| Failure | What happens | Prevention |
|---|---|---|
| Forgotten unlock | An exception skips unlock(); every later caller blocks forever |
finally, with, defer, using; never bare calls |
| Lock held across I/O | One slow database call makes every thread queue; latency jumps under load | Do the I/O first or after; lock only the memory update |
| Different locks, same data | Two methods guard the same field with different locks; the race is back | One lock per invariant, documented on the fields |
| Two locks, two orders | Deadlock | A global lock order; see the deadlock page |
| Too coarse | CPUs idle while threads queue on one lock | Per-entity locks, striping, or less shared state |
Interview questions
Section titled “Interview questions”Interviewer asksWhat's the difference between synchronized and ReentrantLock?
Both are reentrant mutexes with the same memory guarantees. synchronized is a language keyword.
It unlocks automatically when the block exits, so the unlock can’t be forgotten. ReentrantLock
is a class that adds more: tryLock() with or without a timeout, interruptible waiting, an
optional fair mode, and several condition variables per lock. Use synchronized by default. Use
ReentrantLock when you need one of those features.
Interviewer asksWhen would you use an atomic instead of a lock?
When the shared state is one variable, and the update fits in a single atomic operation or a CAS loop. Examples are counters, maximums, flags, and a reference to an immutable snapshot. Atomics never block and can’t deadlock, and under moderate contention they’re faster. But as soon as two values must change together, use a lock. The same goes when the update is more than a computation on the old value.
Interviewer asksIs lock-free code always faster than locks?
No. Lock-free means some thread always makes progress, not that it’s fast. Under heavy contention on one variable, CAS loops spend most of their time failing and retrying. The cache line bounces between cores, just as it would for a lock. An uncontended lock costs about 20 ns. The usual way to go faster is to share less (stripe, confine, batch), not to switch primitives.
Interviewer asksWhy do getters need the lock if they only read?
Two reasons. The first is atomicity across fields. An unlocked reader can see an update half done,
such as available already lowered and reserved not yet raised. The second is visibility.
Without a happens-before edge, the reader may see a stale value, cached by its core or kept in a
register by the compiler. Taking the same lock gives both guarantees.
Interviewer asksWhat does volatile guarantee in Java?
Visibility and ordering. Every read of a volatile field sees the most recent write to it, by any
thread. And writes made before a volatile write are visible to any thread that reads the value it
wrote. It does not make compound operations atomic: count++ on a volatile field still loses
updates. It’s the right tool for flags, and for publishing an object built before the write.
| Tool | Use it for | Watch out for |
|---|---|---|
| Mutex | Any invariant over several steps or fields | Unlock in finally; private lock; reads lock too |
| Per-entity or striped locks | Contention on one big lock | Operations over several entities need a lock order |
| Read-write lock | Read-mostly data with real work under the lock | Writer starvation, upgrade deadlock, overhead on short sections |
| Atomic, CAS loop | One counter, max, flag or reference | Doesn’t cover two fields; retry storms under contention |
volatile / atomic flag |
Stop flags, publishing an object | Not atomic for read-modify-write |
| Happens-before | Unlock to lock, volatile write to read, start, join | No edge means no guarantee of seeing the write |
Further reading
Section titled “Further reading”- Brian Goetz et al., Java Concurrency in Practice, chapters 3 (sharing objects) and 15 (atomic variables and nonblocking synchronization).
- Jeff Preshing, An Introduction to Lock-Free Programming.
- The Go Memory Model, short and readable for any language.
- Next: Condition variables and semaphores, for when a thread must wait for something rather than for a lock.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.