Skip to content

ConcurrencyFoundations

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.

Foundation30 min core · 35 min full depth

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.

count0lockfree
A · Thread
  1. lock.lock()
  2. tmp = count
  3. count = tmp + 1
  4. lock.unlock()

ready

B · Thread
  1. lock.lock()
  2. tmp = count
  3. count = tmp + 1
  4. 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.

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.

Stock.javastock.pyStock.csstock.gostock.ts
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(); } }
}

What each language version shows:

  • Java: ReentrantLock, with unlock() in finally. synchronized blocks do the same, and unlock automatically. ReentrantLock adds tryLock, timeouts and fairness, covered below.
  • Python and C#: with lock: and lock (gate) { } release the lock however the block exits, including by an exception. Never write the lock and unlock calls by hand without a finally.
  • Go: mu.Lock(), then defer mu.Unlock() on the next line. So every return path unlocks.
  • TypeScript: there is one thread, so a method with no await is 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:

  1. The lock is private. Java’s synchronized methods and C#’s lock (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.
  2. Every access to the guarded state takes the same lock, reads included. A method that reads without it can see available already lowered and reserved not yet raised. Mark guarded fields with a comment (// guarded by lock), so the rule survives the next person’s edit.
  3. 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.
Interviewer

Make this inventory class thread safe.

What they are testing

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.

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.

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 synchronized and ReentrantLock, C#’s lock and Python’s RLock are reentrant. Go’s sync.Mutex and Python’s Lock are 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.

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.

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:

  1. Read the current value.
  2. Compute the new value from it.
  3. CAS: write the new value only if the variable still holds what you read.
  4. 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.

MaxTracker.javamax_tracker.pyMaxTracker.csmax_tracker.goexample.ts
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(); }
}
  • 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.compareExchange is 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:

  1. Thread 1 reads top = node A, and next = B. Then it gets paused.
  2. Thread 2 pops A, pops B, and pushes A back.
  3. 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.

Interviewer

A hit counter is hammered by 64 threads and the atomic increment shows up in the profiler. What now?

What they are testing

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.

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.

data0readyfalse
A · Writer
  1. ready = true // moved up: nothing forbids it
  2. data = 42

ready

B · Reader
  1. while (!ready) {}
  2. 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.

Main.javaexample.pyProgram.csmain.goexample.ts
// 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");
}
}
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 volatile
static 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.

Say this out loud

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.

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