Thread safety basics
Race conditions, locks, deadlock, atomics and compare-and-set, built up on one bank account in stages, with interactive demos and code in five languages.
- Read first
- Object modeling
A joint account holds 100. Ana and Raj each withdraw 100, at two cash machines, in the same millisecond. Both requests check the balance. Both see 100, and both pay out. The account is now at minus 100. No exception was thrown. Every log line shows a valid withdrawal. And the same code passed every test, because tests call it one request at a time.
That is a race condition. The result depends on how two threads’ steps happen to interleave. A class is thread safe when it keeps its promises however many threads call it, and however their steps interleave. Here the promise is “the balance never goes below zero”. This page covers the three ways shared state breaks, the tool that fixes each, and its cost. It also shows where in-process locks stop helping. It uses one bank, which grows from the broken version to a lock-free one.
What goes wrong
Section titled “What goes wrong”Almost every concurrency bug in an interview design is one of three shapes:
| Shape | The code looks like | In an LLD round | What you see |
|---|---|---|---|
| Check-then-act | if (seat.isFree()) seat.take() |
Parking spots, cinema seats, hotel rooms, last SUV | Double booking, overdraft, two winners |
| Read-modify-write | count = count + 1 |
Counters, balances, a Splitwise ledger, rate limits | Lost updates: totals drift low with no error |
| Visibility | One thread sets a flag, another reads it | A running flag, a lazily built singleton |
A loop that never stops, a half-built object |
Check-then-act decides on a reading that is already out of date by the time it acts. Between
isFree() and take(), another thread can run the same check and get the same answer.
Read-modify-write looks like one step and is three: load the value, compute the new one,
store it. count++ compiles to exactly that. Two threads that both load 100 both store 101, and
one increment disappears. Nothing fails, so the only symptom is a number that is slightly wrong,
which is why lost updates survive in production for months.
You are the scheduler below. Each press of Step runs one line of that thread. Run the withdrawal A, B, A, B and watch the account pay out twice. Then turn the lock on and press the same buttons in the same order:
ready
ready
With the lock on, thread B’s first step just waits for the lock A holds. That wait is the whole fix. Without it, the code is correct for A, A, B, B and for B, B, A, A. It’s wrong for the other four orders. The scheduler picks the order, and with enough traffic, it picks a bad one every day.
Visibility is the third shape. One thread’s write may reach another thread late, or never. That
happens because CPUs buffer writes per core, and compilers reorder reads and move them out of
loops. Take a worker loop, while (running) { ... }. The JIT may read running once, keep it in a
register, and loop forever after another thread sets it to false.
Each language’s memory model says when a write is guaranteed to be visible. In Java, C# and Go,
that’s when both threads go through the same lock, or the same atomic (or volatile in Java and
C#), or a thread start or join. So a lock does two jobs. It lets only one thread into the critical
section. And it makes the previous holder’s writes visible to the next holder. That’s why reads
need the lock too.
Critical sections and atomicity
Section titled “Critical sections and atomicity”An operation is atomic when no other thread can see it half done. A critical section is code that must run atomically with respect to other code touching the same state. Find the invariant, then put every read and write it depends on inside one critical section:
- “Balance never goes below zero” depends on the check and the debit, so both go in one section.
- “Total money is constant” depends on the debit from one account and the credit to the other. So a transfer must change both inside one section.
- The guarantee only holds if every path that touches the state uses the same lock. One
forgotten method that writes without it breaks the rest. The code on this page marks such
fields
// guarded by lock.
Here is a hit counter:
hits++on a shared field. Is it thread safe?
Whether you know ++ is three steps (read, add, write) and can name the lost update. Then
whether you pick the right size of fix: an atomic integer for one counter, not a lock around the
whole class. “It’s one line, so it’s atomic” is the red flag.
Worked examples
Section titled “Worked examples”One bank, three stages. Each level adds a requirement that breaks the previous design. The code in each tab shows the race, and then the fix. The “before” files hold the race window open with a barrier. So the bug shows on every run, not once in a million. Remove the barrier, and the code is exactly as wrong, only harder to catch.
One account: the race and one lock
- Seeing the race
- A critical section
- Locks on reads too
A joint account has two methods, withdraw and deposit, and one rule: the balance never goes
below zero. Requests arrive on many threads at once: two cash machines, the app, payroll.
Without thread safety
Section titled “Without thread safety”import java.util.concurrent.CyclicBarrier;
// A joint account. Correct while one caller at a time uses it, wrong as soon as two do.class Account { // The window between reading the balance and writing it. A no-op in real use; main sets it // to a barrier so both threads are inside the window at once, on every run. static Runnable gap = () -> {};
private int balance;
Account(int opening) { balance = opening; }
// Check-then-act: by the time the debit runs, the check may describe a balance that is gone. boolean withdraw(int amount) { if (balance < amount) return false; gap.run(); balance -= amount; return true; }
// Read-modify-write: two deposits read the same balance, and the later write erases the other. void deposit(int amount) { int read = balance; gap.run(); balance = read + amount; }
int balance() { return balance; }}
class Main { // Runs a and b on two threads and holds the gap open until both have reached it. static void race(Runnable a, Runnable b) throws InterruptedException { CyclicBarrier bothInside = new CyclicBarrier(2); Account.gap = () -> { try { bothInside.await(); } catch (Exception e) { throw new RuntimeException(e); } }; Thread t1 = new Thread(a); Thread t2 = new Thread(b); t1.start(); t2.start(); t1.join(); t2.join(); Account.gap = () -> {}; }
public static void main(String[] args) throws InterruptedException { Account joint = new Account(100); boolean[] paid = new boolean[2]; race(() -> paid[0] = joint.withdraw(100), () -> paid[1] = joint.withdraw(100)); // Both checks saw 100, so both withdrawals paid out. System.out.println(paid[0] + " " + paid[1] + " balance=" + joint.balance()); // true true balance=-100
Account savings = new Account(100); race(() -> savings.deposit(50), () -> savings.deposit(50)); // Both read 100 and both wrote 150: one deposit vanished. System.out.println("balance=" + savings.balance()); // balance=150, not 200 }}import threadingfrom typing import Callable
# A joint account. Correct while one caller at a time uses it, wrong as soon as two do.class Account: # The window between reading the balance and writing it. A no-op in real use; main sets it # to a barrier so both threads are inside the window at once, on every run. gap: Callable[[], object] = lambda: None
def __init__(self, opening: int) -> None: self.balance = opening
# Check-then-act: by the time the debit runs, the check may describe a balance that is gone. def withdraw(self, amount: int) -> bool: if self.balance < amount: return False Account.gap() self.balance -= amount return True
# Read-modify-write: two deposits read the same balance, and the later write erases the other. # The GIL does not prevent this: a thread can be switched out between any two bytecodes. def deposit(self, amount: int) -> None: read = self.balance Account.gap() self.balance = read + amount
# Runs a and b on two threads and holds the gap open until both have reached it.def race(a: Callable[[], object], b: Callable[[], object]) -> None: both_inside = threading.Barrier(2) Account.gap = both_inside.wait threads = [threading.Thread(target=a), threading.Thread(target=b)] for t in threads: t.start() for t in threads: t.join() Account.gap = lambda: None
if __name__ == "__main__": joint = Account(100) paid: list[bool] = [] race(lambda: paid.append(joint.withdraw(100)), lambda: paid.append(joint.withdraw(100))) # Both checks saw 100, so both withdrawals paid out. print(paid, "balance =", joint.balance) # [True, True] balance = -100
savings = Account(100) race(lambda: savings.deposit(50), lambda: savings.deposit(50)) # Both read 100 and both wrote 150: one deposit vanished. print("balance =", savings.balance) # balance = 150, not 200using System;using System.Threading;
// A joint account. Correct while one caller at a time uses it, wrong as soon as two do.class Account{ // The window between reading the balance and writing it. A no-op in real use; Main sets it // to a barrier so both threads are inside the window at once, on every run. public static Action Gap = () => { };
private int _balance;
public Account(int opening) => _balance = opening;
// Check-then-act: by the time the debit runs, the check may describe a balance that is gone. public bool Withdraw(int amount) { if (_balance < amount) return false; Gap(); _balance -= amount; return true; }
// Read-modify-write: two deposits read the same balance, and the later write erases the other. public void Deposit(int amount) { int read = _balance; Gap(); _balance = read + amount; }
public int Balance => _balance;}
static class Program{ // Runs a and b on two threads and holds the gap open until both have reached it. static void Race(Action a, Action b) { using var bothInside = new Barrier(2); Account.Gap = () => bothInside.SignalAndWait(); var t1 = new Thread(() => a()); var t2 = new Thread(() => b()); t1.Start(); t2.Start(); t1.Join(); t2.Join(); Account.Gap = () => { }; }
static void Main() { var joint = new Account(100); var paid = new bool[2]; Race(() => paid[0] = joint.Withdraw(100), () => paid[1] = joint.Withdraw(100)); // Both checks saw 100, so both withdrawals paid out. Console.WriteLine($"{paid[0]} {paid[1]} balance={joint.Balance}"); // True True balance=-100
var savings = new Account(100); Race(() => savings.Deposit(50), () => savings.Deposit(50)); // Both read 100 and both wrote 150: one deposit vanished. Console.WriteLine($"balance={savings.Balance}"); // balance=150, not 200 }}package main
import ( "fmt" "sync")
// gap is the window between reading the balance and writing it. A no-op in real use; main sets// it to a barrier so both goroutines are inside the window at once, on every run.var gap = func() {}
// Account is a joint account. Correct while one caller at a time uses it, wrong as soon as two do.type Account struct { balance int}
// Withdraw is check-then-act: by the time the debit runs, the check may describe a balance that// is gone.func (a *Account) Withdraw(amount int) bool { if a.balance < amount { return false } gap() a.balance -= amount return true}
// Deposit is read-modify-write: two deposits read the same balance, and the later write erases// the other. go run -race reports this as a DATA RACE.func (a *Account) Deposit(amount int) { read := a.balance gap() a.balance = read + amount}
// race runs a and b on two goroutines and holds the gap open until both have reached it.func race(a, b func()) { var arrived, done sync.WaitGroup arrived.Add(2) gap = func() { arrived.Done(); arrived.Wait() } done.Add(2) go func() { defer done.Done(); a() }() go func() { defer done.Done(); b() }() done.Wait() gap = func() {}}
func main() { joint := &Account{balance: 100} var paid [2]bool race(func() { paid[0] = joint.Withdraw(100) }, func() { paid[1] = joint.Withdraw(100) }) // Both checks saw 100, so both withdrawals paid out. fmt.Println(paid[0], paid[1], "balance =", joint.balance) // true true balance = -100
savings := &Account{balance: 100} race(func() { savings.Deposit(50) }, func() { savings.Deposit(50) }) // Both read 100 and both wrote 150: one deposit vanished. fmt.Println("balance =", savings.balance) // balance = 150, not 200}// The window between reading the balance and writing it. In Node it is any await: a fraud check,// a database read. Even an already-resolved promise hands control to the next waiting callback,// so the race below happens on every run, on one thread.const gap = (): Promise<void> => Promise.resolve();
// A joint account. Correct while one caller at a time uses it, wrong as soon as two calls overlap.class Account { #balance: number;
constructor(opening: number) { this.#balance = opening; }
// Check-then-act: by the time the debit runs, the check may describe a balance that is gone. async withdraw(amount: number): Promise<boolean> { if (this.#balance < amount) return false; await gap(); this.#balance -= amount; return true; }
// Read-modify-write: two deposits read the same balance, and the later write erases the other. async deposit(amount: number): Promise<void> { const read = this.#balance; await gap(); this.#balance = read + amount; }
get balance(): number { return this.#balance; }}
async function main(): Promise<void> { const joint = new Account(100); // Two requests in flight at once, as a server handles them. No threads needed. const paid = await Promise.all([joint.withdraw(100), joint.withdraw(100)]); // Both checks saw 100, so both withdrawals paid out. console.log(paid, "balance =", joint.balance); // [ true, true ] balance = -100
const savings = new Account(100); await Promise.all([savings.deposit(50), savings.deposit(50)]); // Both read 100 and both wrote 150: one deposit vanished. console.log("balance =", savings.balance); // balance = 150, not 200}
main();Both bugs from the table appear. withdraw is check-then-act. Two withdrawals of 100, from an
account holding 100, both succeed. deposit is read-modify-write. Two deposits of 50 leave the
balance at 150. The TypeScript tab needs no threads or barrier at all. An await between the check
and the debit lets the second request run in the middle, on a single thread, every time.
With one lock
Section titled “With one lock”Give the account one private lock and hold it around every read and write of the balance.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
import java.util.ArrayList;import java.util.List;import java.util.concurrent.atomic.AtomicInteger;
// The same account with one lock. Every read and write of balance happens while holding it.class Account { // A private lock object, not synchronized(this): code outside the class can't take it, // so nobody can hold our lock by accident or block us on purpose. private final Object lock = new Object(); private int balance; // guarded by lock
Account(int opening) { balance = opening; }
// The check and the debit are one critical section: no other thread can change the balance // between them, so the check is still true when the debit runs. boolean withdraw(int amount) { synchronized (lock) { if (balance < amount) return false; balance -= amount; return true; } }
void deposit(int amount) { synchronized (lock) { balance += amount; } }
// Reads take the lock too. Without it, nothing guarantees this thread sees the latest write. int balance() { synchronized (lock) { return balance; } }}
class Main { public static void main(String[] args) throws InterruptedException { Account joint = new Account(100); AtomicInteger paid = new AtomicInteger(); // two threads report here; atomics come later on the page List<Thread> threads = new ArrayList<>(); for (int i = 0; i < 2; i++) { threads.add(new Thread(() -> { if (joint.withdraw(100)) paid.incrementAndGet(); })); } // Eight threads, 10,000 deposits of 1 each. Without the lock the total comes up short. Account savings = new Account(0); for (int i = 0; i < 8; i++) { threads.add(new Thread(() -> { for (int k = 0; k < 10_000; k++) savings.deposit(1); })); } for (Thread t : threads) t.start(); for (Thread t : threads) t.join();
System.out.println("paid=" + paid.get() + " balance=" + joint.balance()); // paid=1 balance=0 System.out.println("savings=" + savings.balance()); // savings=80000, every run }}import threading
# The same account with one lock. Every read and write of balance happens while holding it.class Account: def __init__(self, opening: int) -> None: # A private lock, one per account. Callers never see it, so they can't hold it by accident. self._lock = threading.Lock() self._balance = opening # guarded by _lock
# The check and the debit are one critical section: no other thread can change the balance # between them, so the check is still true when the debit runs. def withdraw(self, amount: int) -> bool: with self._lock: if self._balance < amount: return False self._balance -= amount return True
def deposit(self, amount: int) -> None: with self._lock: self._balance += amount
# Reads take the lock too. It costs little, and the code stays correct on free-threaded # Python, where nothing else orders this read after another thread's write. @property def balance(self) -> int: with self._lock: return self._balance
if __name__ == "__main__": joint = Account(100) paid: list[bool] = [] threads = [threading.Thread(target=lambda: paid.append(joint.withdraw(100))) for _ in range(2)]
# Eight threads, 10,000 deposits of 1 each. Without the lock the total can come up short. savings = Account(0)
def deposit_many() -> None: for _ in range(10_000): savings.deposit(1)
threads += [threading.Thread(target=deposit_many) for _ in range(8)] for t in threads: t.start() for t in threads: t.join()
print("paid =", paid.count(True), "balance =", joint.balance) # paid = 1 balance = 0 print("savings =", savings.balance) # savings = 80000, every runusing System;using System.Collections.Generic;using System.Threading;
// The same account with one lock. Every read and write of _balance happens while holding it.class Account{ // A private lock object, not lock(this): code outside the class can't take it, so nobody can // hold our lock by accident. System.Threading.Lock (.NET 9+) is the dedicated type for this; // on older versions a private readonly object works the same way. private readonly Lock _lock = new(); private int _balance; // guarded by _lock
public Account(int opening) => _balance = opening;
// The check and the debit are one critical section: no other thread can change the balance // between them, so the check is still true when the debit runs. public bool Withdraw(int amount) { lock (_lock) { if (_balance < amount) return false; _balance -= amount; return true; } }
public void Deposit(int amount) { lock (_lock) { _balance += amount; } }
// Reads take the lock too. Without it, nothing guarantees this thread sees the latest write. public int Balance { get { lock (_lock) return _balance; } }}
static class Program{ static void Main() { var joint = new Account(100); int paid = 0; // two threads report here, so it uses Interlocked; atomics come later on the page var threads = new List<Thread>(); for (int i = 0; i < 2; i++) { threads.Add(new Thread(() => { if (joint.Withdraw(100)) Interlocked.Increment(ref paid); })); } // Eight threads, 10,000 deposits of 1 each. Without the lock the total comes up short. var savings = new Account(0); for (int i = 0; i < 8; i++) { threads.Add(new Thread(() => { for (int k = 0; k < 10_000; k++) savings.Deposit(1); })); } threads.ForEach(t => t.Start()); threads.ForEach(t => t.Join());
Console.WriteLine($"paid={paid} balance={joint.Balance}"); // paid=1 balance=0 Console.WriteLine($"savings={savings.Balance}"); // savings=80000, every run }}package main
import ( "fmt" "sync" "sync/atomic")
// Account is the same account with one mutex. Every read and write of balance happens while// holding it. The mutex is unexported, so code outside the package can't lock it by accident.type Account struct { mu sync.Mutex balance int // guarded by mu}
// Withdraw makes the check and the debit one critical section: no other goroutine can change the// balance between them, so the check is still true when the debit runs.func (a *Account) Withdraw(amount int) bool { a.mu.Lock() defer a.mu.Unlock() // released on every return path, including the early one if a.balance < amount { return false } a.balance -= amount return true}
func (a *Account) Deposit(amount int) { a.mu.Lock() defer a.mu.Unlock() a.balance += amount}
// Balance takes the lock too. Without it, go run -race reports the read, and nothing guarantees// this goroutine sees the latest write.func (a *Account) Balance() int { a.mu.Lock() defer a.mu.Unlock() return a.balance}
func main() { joint := &Account{balance: 100} var paid atomic.Int32 // two goroutines report here; atomics come later on the page var wg sync.WaitGroup for range 2 { wg.Add(1) go func() { defer wg.Done() if joint.Withdraw(100) { paid.Add(1) } }() } // Eight goroutines, 10,000 deposits of 1 each. Without the lock the total comes up short. savings := &Account{} for range 8 { wg.Add(1) go func() { defer wg.Done() for range 10_000 { savings.Deposit(1) } }() } wg.Wait()
fmt.Println("paid =", paid.Load(), "balance =", joint.Balance()) // paid = 1 balance = 0 fmt.Println("savings =", savings.Balance()) // savings = 80000, every run}// The slow part of a withdrawal: a fraud check, a database read. Anything awaited.const gap = (): Promise<void> => Promise.resolve();
// One Node process runs one callback at a time, so code with no await in it can't be// interrupted. The fix is to keep the check and the debit in one synchronous run.class Account { #balance: number;
constructor(opening: number) { this.#balance = opening; }
async withdraw(amount: number): Promise<boolean> { // Slow work goes first, before the check. await gap(); // From here to the return there is no await, so no other callback can change the balance // between the check and the debit. This is the critical section, enforced by the event loop. if (this.#balance < amount) return false; this.#balance -= amount; return true; }
async deposit(amount: number): Promise<void> { await gap(); this.#balance += amount; // read and write in one synchronous step }
get balance(): number { return this.#balance; }}
async function main(): Promise<void> { const joint = new Account(100); const paid = await Promise.all([joint.withdraw(100), joint.withdraw(100)]); console.log(paid, "balance =", joint.balance); // [ true, false ] balance = 0
// 80,000 deposits in flight at once. The total is exact on every run. const savings = new Account(0); await Promise.all(Array.from({ length: 80_000 }, () => savings.deposit(1))); console.log("savings =", savings.balance); // savings = 80000
// If the slow step must sit between the check and the debit (it needs the checked balance), // the event loop no longer protects you. Then you need an async lock per account; the transfer // example later on this page builds one.}
main();What to notice
Section titled “What to notice”- The lock is private. Java’s
synchronized(this)and C#’slock(this)lock an object that any caller can also lock, by accident or on purpose. A private lock object means only the account decides who waits for it. The class is now the unit of safety: callers don’t need to know there is a lock at all. - The critical section covers the whole invariant, and no more. Two separate locked blocks, one for the check and one for the debit, is the original bug again. But slow work stays outside: a fraud check, a database call, a log write to a network disk. Every microsecond inside the lock is a microsecond every other caller waits.
- Reads lock too. An unlocked read in Java, C# or Go may return a stale value, and Go’s race detector reports it. Locking a read costs about 20 ns when nobody else holds the lock.
- TypeScript gets the same guarantee by keeping the check and the debit in one synchronous run.
Node runs one callback at a time, so code with no
awaitin it can’t be interrupted. The fix is to move slow work before the check. If the slow step needs the checked balance, you need an async lock. The hard level builds one. - Never call out while holding a lock. A listener, a callback or a virtual method you don’t own might block. It might call back into this object, or take another lock. That’s why the Observer page and the approach page both release the lock before notifying listeners.
Why not just make
balancevolatile, or anAtomicInteger?
Whether you can tell visibility from atomicity. volatile makes every thread see the latest
write. But two threads can still both read 100, and both subtract. An atomic makes one operation
atomic, so deposit becomes addAndGet. But withdraw needs the check and the subtraction
together. That takes a compare-and-set loop, or a lock. The red flag is saying “volatile fixes
it”.
The rule is that the balance never goes negative, and it depends on the check and the debit together, so both go under one lock. Every read and write of balance takes the same private lock, and nothing slow happens while it’s held.
Many accounts: a lock each
- Lock granularity
- Concurrent maps and compound actions
- Atomics for counters
The bank now has 2 million accounts, opened on first use, and an operations counter for a dashboard. At peak it handles 5,000 requests a second across many threads.
How big should a lock be?
Section titled “How big should a lock be?”The easy level put a lock on one account. Scaled up, there are three choices:
| Option | How | Who waits for whom | Use when |
|---|---|---|---|
| One lock for the bank | Every method holds bank.lock |
Everyone waits for everyone | A first answer, low traffic, or operations across many accounts |
| A lock per account | Each Account owns its lock |
Only requests for the same account | Most interview designs: seats, spots, rooms, accounts |
| Striped locks | N locks (say 64); account id hashes to one of them |
Requests whose accounts share a stripe | Millions of entities where a lock each costs too much memory, or entities that don’t exist yet |
Start with one lock, when it is obviously correct and traffic is unknown. Then move to a lock per entity, with a reason: requests for different entities should never wait for each other.
A thread-safe map, and still wrong
Section titled “A thread-safe map, and still wrong”Per-account locks need a map from id to account, shared by every thread. A thread-safe map seems to settle it:
import java.util.Map;import java.util.concurrent.ConcurrentHashMap;import java.util.concurrent.CyclicBarrier;
// Each account is safe on its own: one lock, as in the easy example.class Account { private final Object lock = new Object(); private int balance; // guarded by lock
void deposit(int amount) { synchronized (lock) { balance += amount; } }
int balance() { synchronized (lock) { return balance; } }}
// Many accounts, opened on first use. The map is a thread-safe collection, and the bank is// still wrong.class Bank { // The window between "not there" and "put it there". main holds it open. static Runnable gap = () -> {};
private final Map<String, Account> accounts = new ConcurrentHashMap<>();
// Each map call is atomic. The pair is not: two threads can both see "missing", both create // an account, and the second put replaces the first. Deposits into the first are lost. Account open(String id) { Account existing = accounts.get(id); if (existing != null) return existing; gap.run(); Account created = new Account(); accounts.put(id, created); return created; }
void deposit(String id, int amount) { open(id).deposit(amount); }
int balance(String id) { return accounts.get(id).balance(); }}
class Main { public static void main(String[] args) throws Exception { Bank bank = new Bank(); CyclicBarrier bothChecked = new CyclicBarrier(2); Bank.gap = () -> { try { bothChecked.await(); } catch (Exception e) { throw new RuntimeException(e); } }; // Ana's first two deposits arrive together, from the app and from payroll. Thread app = new Thread(() -> bank.deposit("ana", 50)); Thread payroll = new Thread(() -> bank.deposit("ana", 50)); app.start(); payroll.start(); app.join(); payroll.join(); System.out.println("ana=" + bank.balance("ana")); // ana=50, not 100: one account was replaced }}import threadingfrom typing import Callable
# Each account is safe on its own: one lock, as in the easy example.class Account: def __init__(self) -> None: self._lock = threading.Lock() self._balance = 0 # guarded by _lock
def deposit(self, amount: int) -> None: with self._lock: self._balance += amount
@property def balance(self) -> int: with self._lock: return self._balance
# Many accounts, opened on first use. Each dict call is atomic, and the bank is still wrong.class Bank: # The window between "not there" and "put it there". main holds it open. gap: Callable[[], object] = lambda: None
def __init__(self) -> None: self._accounts: dict[str, Account] = {}
# Each dict call is atomic. The pair is not: two threads can both see "missing", both create # an account, and the second assignment replaces the first. Deposits into the first are lost. def open(self, account_id: str) -> Account: existing = self._accounts.get(account_id) if existing is not None: return existing Bank.gap() created = Account() self._accounts[account_id] = created return created
def deposit(self, account_id: str, amount: int) -> None: self.open(account_id).deposit(amount)
def balance(self, account_id: str) -> int: return self._accounts[account_id].balance
if __name__ == "__main__": bank = Bank() Bank.gap = threading.Barrier(2).wait # Ana's first two deposits arrive together, from the app and from payroll. app = threading.Thread(target=lambda: bank.deposit("ana", 50)) payroll = threading.Thread(target=lambda: bank.deposit("ana", 50)) app.start() payroll.start() app.join() payroll.join() print("ana =", bank.balance("ana")) # ana = 50, not 100: one account was replacedusing System;using System.Collections.Concurrent;using System.Threading;
// Each account is safe on its own: one lock, as in the easy example.class Account{ private readonly Lock _lock = new(); private int _balance; // guarded by _lock
public void Deposit(int amount) { lock (_lock) _balance += amount; }
public int Balance { get { lock (_lock) return _balance; } }}
// Many accounts, opened on first use. The dictionary is a thread-safe collection, and the bank// is still wrong.class Bank{ // The window between "not there" and "put it there". Main holds it open. public static Action Gap = () => { };
private readonly ConcurrentDictionary<string, Account> _accounts = new();
// Each dictionary call is atomic. The pair is not: two threads can both see "missing", both // create an account, and the second write replaces the first. Deposits into the first are lost. public Account Open(string id) { if (_accounts.TryGetValue(id, out var existing)) return existing; Gap(); var created = new Account(); _accounts[id] = created; return created; }
public void Deposit(string id, int amount) => Open(id).Deposit(amount);
public int Balance(string id) => _accounts[id].Balance;}
static class Program{ static void Main() { var bank = new Bank(); using var bothChecked = new Barrier(2); Bank.Gap = () => bothChecked.SignalAndWait(); // Ana's first two deposits arrive together, from the app and from payroll. var app = new Thread(() => bank.Deposit("ana", 50)); var payroll = new Thread(() => bank.Deposit("ana", 50)); app.Start(); payroll.Start(); app.Join(); payroll.Join(); Console.WriteLine($"ana={bank.Balance("ana")}"); // ana=50, not 100: one account was replaced }}package main
import ( "fmt" "sync")
// Account is safe on its own: one mutex, as in the easy example.type Account struct { mu sync.Mutex balance int // guarded by mu}
func (a *Account) Deposit(amount int) { a.mu.Lock() defer a.mu.Unlock() a.balance += amount}
func (a *Account) Balance() int { a.mu.Lock() defer a.mu.Unlock() return a.balance}
// gap is the window between "not there" and "put it there". main holds it open.var gap = func() {}
// Bank opens accounts on first use. sync.Map is safe for concurrent use, and the bank is still// wrong. (A plain map would be worse: concurrent writes to it crash the program.)type Bank struct { accounts sync.Map // string -> *Account}
// Open: each map call is atomic. The pair is not: two goroutines can both see "missing", both// create an account, and the second Store replaces the first. Deposits into the first are lost.func (b *Bank) Open(id string) *Account { if existing, ok := b.accounts.Load(id); ok { return existing.(*Account) } gap() created := &Account{} b.accounts.Store(id, created) return created}
func (b *Bank) Deposit(id string, amount int) { b.Open(id).Deposit(amount) }
func (b *Bank) Balance(id string) int { a, _ := b.accounts.Load(id) return a.(*Account).Balance()}
func main() { bank := &Bank{} var arrived, done sync.WaitGroup arrived.Add(2) gap = func() { arrived.Done(); arrived.Wait() } // Ana's first two deposits arrive together, from the app and from payroll. done.Add(2) go func() { defer done.Done(); bank.Deposit("ana", 50) }() go func() { defer done.Done(); bank.Deposit("ana", 50) }() done.Wait() fmt.Println("ana =", bank.Balance("ana")) // ana = 50, not 100: one account was replaced}// Loading an account row from the database. Anything awaited is a window for other callbacks.const loadRow = (): Promise<number> => Promise.resolve(0);
class Account { #balance: number;
constructor(opening: number) { this.#balance = opening; }
deposit(amount: number): void { this.#balance += amount; // synchronous, so safe on its own }
get balance(): number { return this.#balance; }}
// Many accounts, loaded on first use. Every Map call is atomic in Node, and the bank is still// wrong, because open awaits between "not there" and "put it there".class Bank { #accounts = new Map<string, Account>();
// Two calls can both see "missing", both await the load, and the second set replaces the // first. Deposits into the first are lost. async open(id: string): Promise<Account> { const existing = this.#accounts.get(id); if (existing) return existing; const created = new Account(await loadRow()); this.#accounts.set(id, created); return created; }
async deposit(id: string, amount: number): Promise<void> { (await this.open(id)).deposit(amount); }
balance(id: string): number { return this.#accounts.get(id)!.balance; }}
async function main(): Promise<void> { const bank = new Bank(); // Ana's first two deposits arrive together, from the app and from payroll. await Promise.all([bank.deposit("ana", 50), bank.deposit("ana", 50)]); console.log("ana =", bank.balance("ana")); // ana = 50, not 100: one account was replaced}
main();Ana’s first two deposits arrive together, both find no account, both create one, and the second
put replaces the first. Her balance is 50, and the other 50 sits in an account nothing points
to. Every individual map call was thread safe. The sequence “get, and if missing, put” was not,
which is the check-then-act race again, one level up. Go’s race detector reports nothing for the
Go version: there is no data race, only a logic race, and no tool finds those for you.
With a lock per account
Section titled “With a lock per account”- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
import java.util.ArrayList;import java.util.List;import java.util.concurrent.ConcurrentHashMap;import java.util.concurrent.atomic.AtomicLong;
// One lock per account. Threads working on different accounts never wait for each other.class Account { private final Object lock = new Object(); private int balance; // guarded by lock
boolean withdraw(int amount) { synchronized (lock) { if (balance < amount) return false; balance -= amount; return true; } }
void deposit(int amount) { synchronized (lock) { balance += amount; } }
int balance() { synchronized (lock) { return balance; } }}
class Bank { private final ConcurrentHashMap<String, Account> accounts = new ConcurrentHashMap<>(); // A single number shared by every thread needs no lock: an atomic increments it in one // hardware instruction. private final AtomicLong operations = new AtomicLong();
// computeIfAbsent makes "check, create, insert" one atomic step for that key. Exactly one // Account is ever created for "ana", and every caller gets that one. Account open(String id) { return accounts.computeIfAbsent(id, k -> new Account()); }
// No bank-wide lock: the map finds the account, then only that account's lock is held. // Ana's deposit never waits for Raj's. void deposit(String id, int amount) { open(id).deposit(amount); operations.incrementAndGet(); }
boolean withdraw(String id, int amount) { operations.incrementAndGet(); return open(id).withdraw(amount); }
int balance(String id) { return open(id).balance(); }
long operations() { return operations.get(); }}
class Main { public static void main(String[] args) throws InterruptedException { Bank bank = new Bank(); List<Thread> threads = new ArrayList<>(); // Eight threads share four new accounts, so every account is opened by two threads at once. for (int i = 0; i < 8; i++) { String id = "acct-" + (i % 4); threads.add(new Thread(() -> { for (int k = 0; k < 10_000; k++) bank.deposit(id, 1); })); } for (Thread t : threads) t.start(); for (Thread t : threads) t.join();
for (int i = 0; i < 4; i++) System.out.print(bank.balance("acct-" + i) + " "); // 20000 x 4 System.out.println("operations=" + bank.operations()); // operations=80000 }}import threading
# One lock per account. Threads working on different accounts never wait for each other.class Account: def __init__(self) -> None: self._lock = threading.Lock() self._balance = 0 # guarded by _lock
def withdraw(self, amount: int) -> bool: with self._lock: if self._balance < amount: return False self._balance -= amount return True
def deposit(self, amount: int) -> None: with self._lock: self._balance += amount
@property def balance(self) -> int: with self._lock: return self._balance
class Bank: def __init__(self) -> None: # Python has no ConcurrentHashMap or AtomicLong. The idiom is a small lock held only for # the map lookup, never while money moves. self._accounts: dict[str, Account] = {} self._accounts_lock = threading.Lock() self._operations = 0 self._operations_lock = threading.Lock()
# "Check, create, insert" is one step under the map's lock. Exactly one Account is ever # created for "ana", and every caller gets that one. def open(self, account_id: str) -> Account: with self._accounts_lock: account = self._accounts.get(account_id) if account is None: account = self._accounts[account_id] = Account() return account
def _count(self) -> None: with self._operations_lock: self._operations += 1
# The map lock is released before the account lock is taken: only one lock is held at a time, # and Ana's deposit never waits for Raj's. def deposit(self, account_id: str, amount: int) -> None: self.open(account_id).deposit(amount) self._count()
def withdraw(self, account_id: str, amount: int) -> bool: self._count() return self.open(account_id).withdraw(amount)
def balance(self, account_id: str) -> int: return self.open(account_id).balance
@property def operations(self) -> int: with self._operations_lock: return self._operations
if __name__ == "__main__": bank = Bank()
def deposit_many(account_id: str) -> None: for _ in range(10_000): bank.deposit(account_id, 1)
# Eight threads share four new accounts, so every account is opened by two threads at once. threads = [threading.Thread(target=deposit_many, args=(f"acct-{i % 4}",)) for i in range(8)] for t in threads: t.start() for t in threads: t.join()
print([bank.balance(f"acct-{i}") for i in range(4)]) # [20000, 20000, 20000, 20000] print("operations =", bank.operations) # operations = 80000using System;using System.Collections.Concurrent;using System.Collections.Generic;using System.Linq;using System.Threading;
// One lock per account. Threads working on different accounts never wait for each other.class Account{ private readonly Lock _lock = new(); private int _balance; // guarded by _lock
public bool Withdraw(int amount) { lock (_lock) { if (_balance < amount) return false; _balance -= amount; return true; } }
public void Deposit(int amount) { lock (_lock) _balance += amount; }
public int Balance { get { lock (_lock) return _balance; } }}
class Bank{ private readonly ConcurrentDictionary<string, Account> _accounts = new(); // A single number shared by every thread needs no lock: Interlocked increments it in one // hardware instruction. private long _operations;
// GetOrAdd makes "check, create, insert" one atomic step for that key. Two racing callers // might both run the factory, but only one Account is stored and both get that one. public Account Open(string id) => _accounts.GetOrAdd(id, _ => new Account());
// No bank-wide lock: the dictionary finds the account, then only that account's lock is held. // Ana's deposit never waits for Raj's. public void Deposit(string id, int amount) { Open(id).Deposit(amount); Interlocked.Increment(ref _operations); }
public bool Withdraw(string id, int amount) { Interlocked.Increment(ref _operations); return Open(id).Withdraw(amount); }
public int Balance(string id) => Open(id).Balance;
public long Operations => Interlocked.Read(ref _operations);}
static class Program{ static void Main() { var bank = new Bank(); // Eight threads share four new accounts, so every account is opened by two threads at once. var threads = Enumerable.Range(0, 8) .Select(i => new Thread(() => { for (int k = 0; k < 10_000; k++) bank.Deposit($"acct-{i % 4}", 1); })) .ToList(); threads.ForEach(t => t.Start()); threads.ForEach(t => t.Join());
Console.WriteLine(string.Join(" ", Enumerable.Range(0, 4).Select(i => bank.Balance($"acct-{i}")))); // 20000 x 4 Console.WriteLine($"operations={bank.Operations}"); // operations=80000 }}package main
import ( "fmt" "sync" "sync/atomic")
// Account has one mutex of its own. Goroutines working on different accounts never wait for// each other.type Account struct { mu sync.Mutex balance int // guarded by mu}
func (a *Account) Withdraw(amount int) bool { a.mu.Lock() defer a.mu.Unlock() if a.balance < amount { return false } a.balance -= amount return true}
func (a *Account) Deposit(amount int) { a.mu.Lock() defer a.mu.Unlock() a.balance += amount}
func (a *Account) Balance() int { a.mu.Lock() defer a.mu.Unlock() return a.balance}
type Bank struct { accounts sync.Map // string -> *Account // A single number shared by every goroutine needs no lock: an atomic increments it in one // hardware instruction. operations atomic.Int64}
// Open uses LoadOrStore, which makes "check, insert" one atomic step for that key. Two racing// callers may each build a candidate, but only one is stored and both get that one.func (b *Bank) Open(id string) *Account { if a, ok := b.accounts.Load(id); ok { return a.(*Account) // the common case: no allocation } a, _ := b.accounts.LoadOrStore(id, &Account{}) return a.(*Account)}
// Deposit takes no bank-wide lock: the map finds the account, then only that account's mutex is// held. Ana's deposit never waits for Raj's.func (b *Bank) Deposit(id string, amount int) { b.Open(id).Deposit(amount) b.operations.Add(1)}
func (b *Bank) Withdraw(id string, amount int) bool { b.operations.Add(1) return b.Open(id).Withdraw(amount)}
func (b *Bank) Balance(id string) int { return b.Open(id).Balance() }
func main() { bank := &Bank{} var wg sync.WaitGroup // Eight goroutines share four new accounts, so every account is opened by two at once. for i := range 8 { wg.Add(1) go func() { defer wg.Done() for range 10_000 { bank.Deposit(fmt.Sprintf("acct-%d", i%4), 1) } }() } wg.Wait()
for i := range 4 { fmt.Print(bank.Balance(fmt.Sprintf("acct-%d", i)), " ") // 20000 x 4 } fmt.Println("operations =", bank.operations.Load()) // operations = 80000}// Loading an account row from the database. Anything awaited is a window for other callbacks.const loadRow = (): Promise<number> => Promise.resolve(0);
class Account { #balance: number;
constructor(opening: number) { this.#balance = opening; }
withdraw(amount: number): boolean { if (this.#balance < amount) return false; // check and debit in one synchronous run this.#balance -= amount; return true; }
deposit(amount: number): void { this.#balance += amount; }
get balance(): number { return this.#balance; }}
class Bank { // Store the promise of the account, not the account. The map entry is written synchronously, // before the first await, so a second caller finds it and waits for the same load. #accounts = new Map<string, Promise<Account>>(); // A plain number is fine: only one callback runs at a time, and ++ has no await inside it. #operations = 0;
open(id: string): Promise<Account> { let pending = this.#accounts.get(id); if (!pending) { pending = loadRow().then((opening) => new Account(opening)); this.#accounts.set(id, pending); } return pending; }
async deposit(id: string, amount: number): Promise<void> { (await this.open(id)).deposit(amount); this.#operations++; }
async withdraw(id: string, amount: number): Promise<boolean> { this.#operations++; return (await this.open(id)).withdraw(amount); }
async balance(id: string): Promise<number> { return (await this.open(id)).balance; }
get operations(): number { return this.#operations; }}
async function main(): Promise<void> { const bank = new Bank(); // 80,000 deposits in flight across four new accounts, every account opened by many at once. await Promise.all(Array.from({ length: 80_000 }, (_, i) => bank.deposit(`acct-${i % 4}`, 1))); const balances = await Promise.all([0, 1, 2, 3].map((i) => bank.balance(`acct-${i}`))); console.log(balances, "operations =", bank.operations); // [ 20000, 20000, 20000, 20000 ] operations = 80000}
main();What to notice
Section titled “What to notice”- Use the map’s atomic compound operations.
computeIfAbsentin Java,GetOrAddin C#,LoadOrStorein Go, and a get-or-create under the map’s lock in Python all make “check, create, insert” one step. Every concurrent collection has a set of these (putIfAbsent,merge,compute,TryUpdate). Use them, instead of agetfollowed by aput. - Keep the function inside
computeIfAbsentshort. Java runs it while holding a lock on that part of the map. So slow work there blocks other keys, and touching the same map from inside it can throw or hang. C#’sGetOrAddmay run the factory twice and keep one result. So the factory must have no side effects. - Hold one lock at a time where you can. The Python version releases the map’s lock before taking the account’s. Holding the map lock while waiting for an account lock would make every request in the bank wait behind one slow account.
- An atomic is the right tool for one independent value.
operationsis a single number that nothing else depends on. SoAtomicLong.incrementAndGet,Interlocked.Incrementoratomic.Int64.Adddoes it in one hardware instruction, with no lock. But two atomics don’t make a pair atomic. An atomic balance and an atomic “last updated” can still be seen out of step. - TypeScript stores the promise, not the account. It writes the pending load into the map
before the first
await. So the second caller finds it, and waits for the same load. It’s the same “make check-and-insert one step” fix, enforced by the event loop instead of a lock.
“I’d store the accounts in a ConcurrentHashMap, so it’s thread safe.”
Whether you know that a concurrent collection makes each call safe, not your sequence of calls.
They want to hear about compound actions (get then put), and the atomic method that replaces
them. Then about the account’s own state, which the map doesn’t protect at all. The red flag is
stopping there, as if the map made the whole bank safe.
I’ll lock per account so requests for different accounts never wait for each other. The map is concurrent and I only use its atomic computeIfAbsent, never get-then-put. The counter is an atomic because it’s one independent number.
Transfers: deadlock and the ways out
- Deadlock and its four conditions
- Lock ordering
- tryLock with back-off
- Optimistic commits with compare-and-set
Customers can now send money to each other. A transfer must hold both accounts’ locks, or someone summing the two balances could catch the money in neither account (or in both). Ana sends 30 to Raj at the same moment Raj sends 20 to Ana.
The natural version deadlocks
Section titled “The natural version deadlocks”Locking the source account first and then the destination is the natural way to write it:
import java.util.concurrent.CyclicBarrier;import java.util.concurrent.locks.ReentrantLock;
class Account { final int id; final ReentrantLock lock = new ReentrantLock(); int balance; // guarded by lock
Account(int id, int opening) { this.id = id; this.balance = opening; }}
class Bank { // The moment between taking the first lock and asking for the second. main holds it open. static Runnable gap = () -> {};
// A transfer must hold both accounts' locks, or another thread could see the money in // neither account (or both). Locking the source first and then the destination looks // natural, and it is the bug: two opposite transfers take the same two locks in opposite // orders, and each ends up holding the lock the other is waiting for. boolean transfer(Account from, Account to, int amount) { from.lock.lock(); try { gap.run(); to.lock.lock(); // waits forever if the other transfer holds `to` and wants `from` try { if (from.balance < amount) return false; from.balance -= amount; to.balance += amount; return true; } finally { to.lock.unlock(); } } finally { from.lock.unlock(); } }}
class Main { public static void main(String[] args) throws InterruptedException { Bank bank = new Bank(); Account a = new Account(1, 100); Account b = new Account(2, 100); CyclicBarrier bothHoldOne = new CyclicBarrier(2); Bank.gap = () -> { try { bothHoldOne.await(); } catch (Exception e) { throw new RuntimeException(e); } }; Thread ana = new Thread(() -> bank.transfer(a, b, 30)); // takes 1, then wants 2 Thread raj = new Thread(() -> bank.transfer(b, a, 20)); // takes 2, then wants 1 // Daemon threads, so the program can still exit with them stuck. ana.setDaemon(true); raj.setDaemon(true); ana.start(); raj.start(); ana.join(300); raj.join(300); // Neither thread can ever finish: all four conditions for a deadlock hold. System.out.println("deadlocked=" + (ana.isAlive() && raj.isAlive())); // deadlocked=true }}import threadingfrom typing import Callable
class Account: def __init__(self, account_id: int, opening: int) -> None: self.id = account_id self.lock = threading.Lock() self.balance = opening # guarded by lock
class Bank: # The moment between taking the first lock and asking for the second. main holds it open. gap: Callable[[], object] = lambda: None
# A transfer must hold both accounts' locks, or another thread could see the money in # neither account (or both). Locking the source first and then the destination looks # natural, and it is the bug: two opposite transfers take the same two locks in opposite # orders, and each ends up holding the lock the other is waiting for. def transfer(self, source: Account, target: Account, amount: int) -> bool: with source.lock: Bank.gap() with target.lock: # waits forever if the other transfer holds target and wants source if source.balance < amount: return False source.balance -= amount target.balance += amount return True
if __name__ == "__main__": bank = Bank() a, b = Account(1, 100), Account(2, 100) Bank.gap = threading.Barrier(2).wait # Daemon threads, so the program can still exit with them stuck. ana = threading.Thread(target=lambda: bank.transfer(a, b, 30), daemon=True) # takes 1, wants 2 raj = threading.Thread(target=lambda: bank.transfer(b, a, 20), daemon=True) # takes 2, wants 1 ana.start() raj.start() ana.join(timeout=0.3) raj.join(timeout=0.3) # Neither thread can ever finish: all four conditions for a deadlock hold. print("deadlocked =", ana.is_alive() and raj.is_alive()) # deadlocked = Trueusing System;using System.Threading;
class Account{ public readonly int Id; public readonly Lock Lock = new(); public int Balance; // guarded by Lock
public Account(int id, int opening) { Id = id; Balance = opening; }}
class Bank{ // The moment between taking the first lock and asking for the second. Main holds it open. public static Action Gap = () => { };
// A transfer must hold both accounts' locks, or another thread could see the money in // neither account (or both). Locking the source first and then the destination looks // natural, and it is the bug: two opposite transfers take the same two locks in opposite // orders, and each ends up holding the lock the other is waiting for. public bool Transfer(Account from, Account to, int amount) { lock (from.Lock) { Gap(); lock (to.Lock) // waits forever if the other transfer holds `to` and wants `from` { if (from.Balance < amount) return false; from.Balance -= amount; to.Balance += amount; return true; } } }}
static class Program{ static void Main() { var bank = new Bank(); var a = new Account(1, 100); var b = new Account(2, 100); var bothHoldOne = new Barrier(2); Bank.Gap = () => bothHoldOne.SignalAndWait(); // Background threads, so the program can still exit with them stuck. var ana = new Thread(() => bank.Transfer(a, b, 30)) { IsBackground = true }; // takes 1, wants 2 var raj = new Thread(() => bank.Transfer(b, a, 20)) { IsBackground = true }; // takes 2, wants 1 ana.Start(); raj.Start(); bool anaDone = ana.Join(300); bool rajDone = raj.Join(300); // Neither thread can ever finish: all four conditions for a deadlock hold. Console.WriteLine($"deadlocked={!anaDone && !rajDone}"); // deadlocked=True }}package main
import ( "fmt" "sync" "time")
type Account struct { ID int mu sync.Mutex balance int // guarded by mu}
// gap is the moment between taking the first lock and asking for the second. main holds it open.var gap = func() {}
// Transfer must hold both accounts' locks, or another goroutine could see the money in neither// account (or both). Locking the source first and then the destination looks natural, and it is// the bug: two opposite transfers take the same two locks in opposite orders, and each ends up// holding the lock the other is waiting for.func Transfer(from, to *Account, amount int) bool { from.mu.Lock() defer from.mu.Unlock() gap() to.mu.Lock() // waits forever if the other transfer holds `to` and wants `from` defer to.mu.Unlock() if from.balance < amount { return false } from.balance -= amount to.balance += amount return true}
func main() { a := &Account{ID: 1, balance: 100} b := &Account{ID: 2, balance: 100} var bothHoldOne sync.WaitGroup bothHoldOne.Add(2) gap = func() { bothHoldOne.Done(); bothHoldOne.Wait() }
done := make(chan bool, 2) go func() { done <- Transfer(a, b, 30) }() // takes 1, then wants 2 go func() { done <- Transfer(b, a, 20) }() // takes 2, then wants 1
// Neither goroutine can ever finish: all four conditions for a deadlock hold. Go's runtime // only reports "all goroutines are asleep" when main is stuck too, so it can't help here. select { case <-done: fmt.Println("deadlocked = false") case <-time.After(300 * time.Millisecond): fmt.Println("deadlocked = true") }}// A minimal async lock. Node has one thread, but a transfer that awaits while holding an// account (to write an audit row, say) needs other calls to wait their turn. Each caller chains// onto the previous holder's release.class Mutex { #tail: Promise<void> = Promise.resolve();
async lock(): Promise<() => void> { let release!: () => void; const mine = new Promise<void>((resolve) => (release = resolve)); const previous = this.#tail; this.#tail = previous.then(() => mine); await previous; // wait until everyone ahead of us has released return release; }}
class Account { readonly lock = new Mutex(); constructor( readonly id: number, public balance: number, // guarded by lock ) {}}
// Writing the audit row for the first account. Any await while holding a lock.const gap = (): Promise<void> => Promise.resolve();
// A transfer must hold both accounts, or another call could see the money in neither (or both).// Locking the source first and then the destination looks natural, and it is the bug: two// opposite transfers take the same two locks in opposite orders, and each ends up holding the// lock the other is waiting for. In Node this hangs the two requests, not the process.async function transfer(from: Account, to: Account, amount: number): Promise<boolean> { const releaseFrom = await from.lock.lock(); try { await gap(); const releaseTo = await to.lock.lock(); // waits forever if the other transfer holds `to` try { if (from.balance < amount) return false; from.balance -= amount; to.balance += amount; return true; } finally { releaseTo(); } } finally { releaseFrom(); }}
async function main(): Promise<void> { const a = new Account(1, 100); const b = new Account(2, 100); const both = Promise.all([transfer(a, b, 30), transfer(b, a, 20)]); // 1 then 2, and 2 then 1 const timeout = new Promise<"deadlocked">((resolve) => setTimeout(() => resolve("deadlocked"), 300)); // Neither promise ever settles: all four conditions for a deadlock hold. console.log(await Promise.race([both, timeout])); // deadlocked}
main();Ana’s transfer takes account 1, and waits for account 2. Raj’s takes account 2, and waits for account 1. The threads don’t crash, and don’t use CPU. Every later request for those accounts piles up behind them, until the thread pool runs out. Play A, B, A, B. Then switch the strategy, and play the same order again.
holdswaiting for
ready
ready
A deadlock needs all four of these conditions at once (Coffman, Elphick and Shoshani, 1971). Breaking any one prevents it:
| Condition | What it means here | How to break it |
|---|---|---|
| Mutual exclusion | Only one thread can hold an account’s lock | Don’t lock at all: optimistic compare-and-set, below |
| Hold and wait | A transfer holds account 1 while waiting for account 2 | Take both or neither: tryLock and back off, below |
| No preemption | Nobody can take a lock away from its holder | Abort a victim and roll it back, as a database does |
| Circular wait | Ana waits for Raj, who waits for Ana | One global lock order, so a cycle can’t form: the usual fix |
Fix 1: one global lock order
Section titled “Fix 1: one global lock order”Every thread takes account locks lower id first, so Ana’s and Raj’s transfers both ask for account 1 first. A cycle would need someone to hold a higher id while waiting for a lower one, and nobody ever does.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
import java.util.concurrent.locks.ReentrantLock;
class Account { final int id; // unique and never changes, so it can define the lock order final ReentrantLock lock = new ReentrantLock(); int balance; // guarded by lock
Account(int id, int opening) { this.id = id; this.balance = opening; }}
class Bank { // Every thread takes account locks in one global order: lower id first. A cycle of threads // each waiting for the next needs someone to hold a higher id while waiting for a lower one, // and nobody ever does, so a deadlock can't form. boolean transfer(Account from, Account to, int amount) { if (from == to) throw new IllegalArgumentException("transfer to the same account"); Account first = from.id < to.id ? from : to; Account second = first == from ? to : from; first.lock.lock(); try { second.lock.lock(); try { // The rule is checked and applied under both locks, the same as before. if (from.balance < amount) return false; from.balance -= amount; to.balance += amount; return true; } finally { second.lock.unlock(); } } finally { first.lock.unlock(); } }
// Reading two balances that must add up also takes both locks, in the same order. // Reading them one at a time could catch money in flight and report a wrong total. int total(Account x, Account y) { Account first = x.id < y.id ? x : y; Account second = first == x ? y : x; first.lock.lock(); try { second.lock.lock(); try { return x.balance + y.balance; } finally { second.lock.unlock(); } } finally { first.lock.unlock(); } }}
class Main { public static void main(String[] args) throws InterruptedException { Bank bank = new Bank(); Account a = new Account(1, 100); Account b = new Account(2, 100); // The same opposite transfers that deadlocked, 10,000 times each way. Thread ana = new Thread(() -> { for (int i = 0; i < 10_000; i++) bank.transfer(a, b, 1); }); Thread raj = new Thread(() -> { for (int i = 0; i < 10_000; i++) bank.transfer(b, a, 1); }); ana.start(); raj.start(); ana.join(); raj.join(); System.out.println("finished, total=" + bank.total(a, b)); // finished, total=200 }}import threading
class Account: def __init__(self, account_id: int, opening: int) -> None: self.id = account_id # unique and never changes, so it can define the lock order self.lock = threading.Lock() self.balance = opening # guarded by lock
# Lower id first. One rule, used by every method that holds two account locks.def in_lock_order(x: Account, y: Account) -> tuple[Account, Account]: return (x, y) if x.id < y.id else (y, x)
class Bank: # Every thread takes account locks in one global order: lower id first. A cycle of threads # each waiting for the next needs someone to hold a higher id while waiting for a lower one, # and nobody ever does, so a deadlock can't form. def transfer(self, source: Account, target: Account, amount: int) -> bool: # threading.Lock is not reentrant: locking the same account twice would hang forever. if source is target: raise ValueError("transfer to the same account") first, second = in_lock_order(source, target) with first.lock, second.lock: # The rule is checked and applied under both locks, the same as before. if source.balance < amount: return False source.balance -= amount target.balance += amount return True
# Reading two balances that must add up also takes both locks, in the same order. # Reading them one at a time could catch money in flight and report a wrong total. def total(self, x: Account, y: Account) -> int: first, second = in_lock_order(x, y) with first.lock, second.lock: return x.balance + y.balance
if __name__ == "__main__": bank = Bank() a, b = Account(1, 100), Account(2, 100)
def many(source: Account, target: Account) -> None: for _ in range(10_000): bank.transfer(source, target, 1)
# The same opposite transfers that deadlocked, 10,000 times each way. ana = threading.Thread(target=many, args=(a, b)) raj = threading.Thread(target=many, args=(b, a)) ana.start() raj.start() ana.join() raj.join() print("finished, total =", bank.total(a, b)) # finished, total = 200using System;using System.Threading;
class Account{ public readonly int Id; // unique and never changes, so it can define the lock order public readonly Lock Lock = new(); public int Balance; // guarded by Lock
public Account(int id, int opening) { Id = id; Balance = opening; }}
class Bank{ // Lower id first. One rule, used by every method that holds two account locks. static (Account First, Account Second) InLockOrder(Account x, Account y) => x.Id < y.Id ? (x, y) : (y, x);
// Every thread takes account locks in one global order: lower id first. A cycle of threads // each waiting for the next needs someone to hold a higher id while waiting for a lower one, // and nobody ever does, so a deadlock can't form. public bool Transfer(Account from, Account to, int amount) { if (from == to) throw new ArgumentException("transfer to the same account"); var (first, second) = InLockOrder(from, to); lock (first.Lock) lock (second.Lock) { // The rule is checked and applied under both locks, the same as before. if (from.Balance < amount) return false; from.Balance -= amount; to.Balance += amount; return true; } }
// Reading two balances that must add up also takes both locks, in the same order. // Reading them one at a time could catch money in flight and report a wrong total. public int Total(Account x, Account y) { var (first, second) = InLockOrder(x, y); lock (first.Lock) lock (second.Lock) { return x.Balance + y.Balance; } }}
static class Program{ static void Main() { var bank = new Bank(); var a = new Account(1, 100); var b = new Account(2, 100); // The same opposite transfers that deadlocked, 10,000 times each way. var ana = new Thread(() => { for (int i = 0; i < 10_000; i++) bank.Transfer(a, b, 1); }); var raj = new Thread(() => { for (int i = 0; i < 10_000; i++) bank.Transfer(b, a, 1); }); ana.Start(); raj.Start(); ana.Join(); raj.Join(); Console.WriteLine($"finished, total={bank.Total(a, b)}"); // finished, total=200 }}package main
import ( "errors" "fmt" "sync")
type Account struct { ID int // unique and never changes, so it can define the lock order mu sync.Mutex balance int // guarded by mu}
// inLockOrder returns the lower id first. One rule, used by every function that holds two// account locks.func inLockOrder(x, y *Account) (*Account, *Account) { if x.ID < y.ID { return x, y } return y, x}
// Transfer takes account locks in one global order: lower id first. A cycle of goroutines each// waiting for the next needs someone to hold a higher id while waiting for a lower one, and// nobody ever does, so a deadlock can't form.func Transfer(from, to *Account, amount int) (bool, error) { // sync.Mutex is not reentrant: locking the same account twice would hang forever. if from == to { return false, errors.New("transfer to the same account") } first, second := inLockOrder(from, to) first.mu.Lock() defer first.mu.Unlock() second.mu.Lock() defer second.mu.Unlock() // The rule is checked and applied under both locks, the same as before. if from.balance < amount { return false, nil } from.balance -= amount to.balance += amount return true, nil}
// Total reads two balances that must add up, so it takes both locks, in the same order.// Reading them one at a time could catch money in flight and report a wrong total.func Total(x, y *Account) int { first, second := inLockOrder(x, y) first.mu.Lock() defer first.mu.Unlock() second.mu.Lock() defer second.mu.Unlock() return x.balance + y.balance}
func main() { a := &Account{ID: 1, balance: 100} b := &Account{ID: 2, balance: 100} // The same opposite transfers that deadlocked, 10,000 times each way. var wg sync.WaitGroup many := func(from, to *Account) { defer wg.Done() for range 10_000 { Transfer(from, to, 1) } } wg.Add(2) go many(a, b) go many(b, a) wg.Wait() fmt.Println("finished, total =", Total(a, b)) // finished, total = 200}// The same minimal async lock as before: each caller chains onto the previous holder's release.class Mutex { #tail: Promise<void> = Promise.resolve();
async lock(): Promise<() => void> { let release!: () => void; const mine = new Promise<void>((resolve) => (release = resolve)); const previous = this.#tail; this.#tail = previous.then(() => mine); await previous; return release; }}
class Account { readonly lock = new Mutex(); constructor( readonly id: number, // unique and never changes, so it can define the lock order public balance: number, // guarded by lock ) {}}
const gap = (): Promise<void> => Promise.resolve(); // the audit write, still awaited under the locks
// Lower id first. One rule, used by every function that holds two account locks.function inLockOrder(x: Account, y: Account): [Account, Account] { return x.id < y.id ? [x, y] : [y, x];}
// Every call takes account locks in one global order: lower id first. A cycle of calls each// waiting for the next needs someone to hold a higher id while waiting for a lower one, and// nobody ever does, so a deadlock can't form.async function transfer(from: Account, to: Account, amount: number): Promise<boolean> { // This Mutex is not reentrant: locking the same account twice would wait forever. if (from === to) throw new Error("transfer to the same account"); const [first, second] = inLockOrder(from, to); const releaseFirst = await first.lock.lock(); try { await gap(); const releaseSecond = await second.lock.lock(); try { // The rule is checked and applied under both locks, the same as before. if (from.balance < amount) return false; from.balance -= amount; to.balance += amount; return true; } finally { releaseSecond(); } } finally { releaseFirst(); }}
async function main(): Promise<void> { const a = new Account(1, 100); const b = new Account(2, 100); // The same opposite transfers that deadlocked, 10,000 in flight each way. const calls: Promise<boolean>[] = []; for (let i = 0; i < 10_000; i++) calls.push(transfer(a, b, 1), transfer(b, a, 1)); await Promise.all(calls); // No await between these two reads, so no transfer can run in between: the total is consistent. console.log("finished, total =", a.balance + b.balance); // finished, total = 200}
main();The order can be anything total that never changes: an id, a primary key, a creation sequence. Not
a hash code, which can tie. A transfer to the same account is rejected up front. That’s because
Python’s Lock, Go’s Mutex and the TypeScript mutex are not reentrant. A thread that takes the
same lock twice waits for itself forever. total takes both locks in the same order too.
Fix 2: tryLock and back off
Section titled “Fix 2: tryLock and back off”Sometimes a fixed order is impossible. The second lock is only known after reading inside the first, like a linked account, or a parent directory in a file system. Then never wait for the second lock while holding the first. Try it for a few milliseconds. If it doesn’t come, release everything, pause for a random moment, and start over.
Go deepertryLock in code, and the livelock trap2 min
import java.util.concurrent.ThreadLocalRandom;import java.util.concurrent.TimeUnit;import java.util.concurrent.atomic.AtomicInteger;import java.util.concurrent.locks.ReentrantLock;
class Account { final int id; final ReentrantLock lock = new ReentrantLock(); int balance; // guarded by lock
Account(int id, int opening) { this.id = id; this.balance = opening; }}
class Bank { final AtomicInteger backoffs = new AtomicInteger();
// For when a fixed order is impossible, for example when the second lock is only known after // reading the first account. Never wait for the second lock while holding the first: try for // a short time, and if it doesn't come, let go of everything and start again. Nobody holds // one lock while waiting forever for another, so no deadlock can form. boolean transfer(Account from, Account to, int amount) throws InterruptedException { while (true) { from.lock.lock(); try { if (to.lock.tryLock(5, TimeUnit.MILLISECONDS)) { try { if (from.balance < amount) return false; from.balance -= amount; to.balance += amount; return true; } finally { to.lock.unlock(); } } } finally { from.lock.unlock(); } // Gave up, holding nothing. A random pause stops two threads from retrying in lockstep // and colliding forever (a livelock). backoffs.incrementAndGet(); Thread.sleep(ThreadLocalRandom.current().nextInt(1, 4)); } }}
class Main { public static void main(String[] args) throws InterruptedException { Bank bank = new Bank(); Account a = new Account(1, 100); Account b = new Account(2, 100); // Opposite transfers in the natural from-then-to order, which deadlocked with plain locks. Thread ana = new Thread(() -> { try { for (int i = 0; i < 1_000; i++) bank.transfer(a, b, 1); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); Thread raj = new Thread(() -> { try { for (int i = 0; i < 1_000; i++) bank.transfer(b, a, 1); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); ana.start(); raj.start(); ana.join(); raj.join(); // join() makes both threads' writes visible here, so plain reads are safe. System.out.println("finished, total=" + (a.balance + b.balance)); // finished, total=200 System.out.println("backoffs=" + bank.backoffs.get()); // varies by run }}import randomimport threadingimport time
class Account: def __init__(self, account_id: int, opening: int) -> None: self.id = account_id self.lock = threading.Lock() self.balance = opening # guarded by lock
class Bank: def __init__(self) -> None: self.backoffs = 0 # only a statistic; an occasional lost count is harmless here
# For when a fixed order is impossible, for example when the second lock is only known after # reading the first account. Never wait for the second lock while holding the first: try for # a short time, and if it doesn't come, let go of everything and start again. Nobody holds # one lock while waiting forever for another, so no deadlock can form. def transfer(self, source: Account, target: Account, amount: int) -> bool: while True: with source.lock: if target.lock.acquire(timeout=0.005): try: if source.balance < amount: return False source.balance -= amount target.balance += amount return True finally: target.lock.release() # Gave up, holding nothing. A random pause stops two threads from retrying in lockstep # and colliding forever (a livelock). self.backoffs += 1 time.sleep(random.uniform(0.001, 0.003))
if __name__ == "__main__": bank = Bank() a, b = Account(1, 100), Account(2, 100)
def many(source: Account, target: Account) -> None: for _ in range(1_000): bank.transfer(source, target, 1)
# Opposite transfers in the natural source-then-target order, which deadlocked with plain locks. ana = threading.Thread(target=many, args=(a, b)) raj = threading.Thread(target=many, args=(b, a)) ana.start() raj.start() ana.join() raj.join() # join() makes both threads' writes visible here, so plain reads are safe. print("finished, total =", a.balance + b.balance) # finished, total = 200 print("backoffs =", bank.backoffs) # varies by runusing System;using System.Threading;
class Account{ public readonly int Id; public readonly Lock Lock = new(); public int Balance; // guarded by Lock
public Account(int id, int opening) { Id = id; Balance = opening; }}
class Bank{ private int _backoffs; public int Backoffs => Volatile.Read(ref _backoffs);
// For when a fixed order is impossible, for example when the second lock is only known after // reading the first account. Never wait for the second lock while holding the first: try for // a short time, and if it doesn't come, let go of everything and start again. Nobody holds // one lock while waiting forever for another, so no deadlock can form. public bool Transfer(Account from, Account to, int amount) { while (true) { lock (from.Lock) { if (to.Lock.TryEnter(5)) { try { if (from.Balance < amount) return false; from.Balance -= amount; to.Balance += amount; return true; } finally { to.Lock.Exit(); } } } // Gave up, holding nothing. A random pause stops two threads from retrying in lockstep // and colliding forever (a livelock). Interlocked.Increment(ref _backoffs); Thread.Sleep(Random.Shared.Next(1, 4)); } }}
static class Program{ static void Main() { var bank = new Bank(); var a = new Account(1, 100); var b = new Account(2, 100); // Opposite transfers in the natural from-then-to order, which deadlocked with plain locks. var ana = new Thread(() => { for (int i = 0; i < 1_000; i++) bank.Transfer(a, b, 1); }); var raj = new Thread(() => { for (int i = 0; i < 1_000; i++) bank.Transfer(b, a, 1); }); ana.Start(); raj.Start(); ana.Join(); raj.Join(); // Join makes both threads' writes visible here, so plain reads are safe. Console.WriteLine($"finished, total={a.Balance + b.Balance}"); // finished, total=200 Console.WriteLine($"backoffs={bank.Backoffs}"); // varies by run }}package main
import ( "fmt" "math/rand/v2" "sync" "sync/atomic" "time")
type Account struct { ID int mu sync.Mutex balance int // guarded by mu}
var backoffs atomic.Int32
// Transfer is for when a fixed order is impossible, for example when the second lock is only// known after reading the first account. Never wait for the second lock while holding the// first: if it isn't free, let go of everything and start again. Nobody holds one lock while// waiting for another, so no deadlock can form. Go's TryLock has no timeout and its docs call// correct uses rare; prefer a fixed order, or one goroutine that owns the accounts.func Transfer(from, to *Account, amount int) bool { for { from.mu.Lock() if to.mu.TryLock() { ok := from.balance >= amount if ok { from.balance -= amount to.balance += amount } to.mu.Unlock() from.mu.Unlock() return ok } from.mu.Unlock() // Gave up, holding nothing. A random pause stops two goroutines from retrying in // lockstep and colliding forever (a livelock). backoffs.Add(1) time.Sleep(time.Duration(rand.IntN(3)+1) * time.Millisecond) }}
func main() { a := &Account{ID: 1, balance: 100} b := &Account{ID: 2, balance: 100} // Opposite transfers in the natural from-then-to order, which deadlocked with plain locks. var wg sync.WaitGroup many := func(from, to *Account) { defer wg.Done() for range 1_000 { Transfer(from, to, 1) } } wg.Add(2) go many(a, b) go many(b, a) wg.Wait() // wg.Wait makes both goroutines' writes visible here, so plain reads are safe. fmt.Println("finished, total =", a.balance+b.balance) // finished, total = 200 fmt.Println("backoffs =", backoffs.Load()) // varies by run}// An async lock with a waiting line, so it can also answer "is it free right now?".class Mutex { #locked = false; #waiters: (() => void)[] = [];
// Wait for the lock. The releaser hands it straight to the next waiter, in arrival order. lock(): Promise<() => void> { if (!this.#locked) { this.#locked = true; return Promise.resolve(() => this.#release()); } return new Promise((resolve) => this.#waiters.push(() => resolve(() => this.#release()))); }
// Take the lock only if nobody holds it. Never waits. tryLock(): (() => void) | null { if (this.#locked) return null; this.#locked = true; return () => this.#release(); }
#release(): void { const next = this.#waiters.shift(); if (next) next(); else this.#locked = false; }}
class Account { readonly lock = new Mutex(); constructor( readonly id: number, public balance: number, // guarded by lock ) {}}
const gap = (): Promise<void> => Promise.resolve(); // the audit write, awaited under the first lockconst pause = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));let backoffs = 0;
// For when a fixed order is impossible, for example when the second lock is only known after// reading the first account. Never wait for the second lock while holding the first: if it isn't// free, let go of everything and start again. Nobody holds one lock while waiting for another,// so no deadlock can form.async function transfer(from: Account, to: Account, amount: number): Promise<boolean> { for (;;) { const releaseFrom = await from.lock.lock(); try { await gap(); const releaseTo = to.lock.tryLock(); if (releaseTo) { try { if (from.balance < amount) return false; from.balance -= amount; to.balance += amount; return true; } finally { releaseTo(); } } } finally { releaseFrom(); } // Gave up, holding nothing. A random pause stops two calls from retrying in lockstep and // colliding forever (a livelock). backoffs++; await pause(1 + Math.floor(Math.random() * 3)); }}
async function main(): Promise<void> { const a = new Account(1, 100); const b = new Account(2, 100); // Opposite transfers in the natural from-then-to order, which deadlocked with plain locks. const calls: Promise<boolean>[] = []; for (let i = 0; i < 1_000; i++) calls.push(transfer(a, b, 1), transfer(b, a, 1)); await Promise.all(calls); console.log("finished, total =", a.balance + b.balance); // finished, total = 200 console.log("backoffs =", backoffs); // varies by run}
main();This breaks “hold and wait”. It costs retries under contention (the backoffs counter). And
without the random pause, two threads can give up and retry in step forever. That’s called a
livelock: both are busy, and neither makes progress. Java’s tryLock(timeout), Python’s
acquire(timeout=...) and C#’s Lock.TryEnter(ms) take a timeout. Go’s TryLock doesn’t, and its
documentation says correct uses are rare.
Fix 3: no locks, commit with compare-and-set
Section titled “Fix 3: no locks, commit with compare-and-set”Compare-and-set (CAS) is one atomic CPU operation. It says: “if this location still holds the
value I read, replace it with my new value. Otherwise, tell me it changed”. Java’s
AtomicReference.compareAndSet, C#’s Interlocked.CompareExchange and Go’s
atomic.Pointer.CompareAndSwap expose it. It lets you work optimistically. Read a snapshot. Do
the work, holding nothing. Commit only if nobody committed in the meantime. If somebody did, start
again from their result.
With an immutable ledger, the whole bank is one reference. A transfer builds the next ledger and swaps it in with one CAS. Readers take a snapshot and never lock, and every total they compute is consistent.
import java.util.ArrayList;import java.util.HashMap;import java.util.List;import java.util.Map;import java.util.concurrent.atomic.AtomicBoolean;import java.util.concurrent.atomic.AtomicLong;import java.util.concurrent.atomic.AtomicReference;
// One immutable snapshot of every balance, plus a version that goes up on every commit.// Nobody ever changes a Ledger; a change builds a new one. Immutable objects can be shared// between any number of threads with no locks at all.record Ledger(Map<String, Integer> balances, long version) { int total() { return balances.values().stream().mapToInt(Integer::intValue).sum(); }}
class OptimisticBank { private final AtomicReference<Ledger> current; final AtomicLong retries = new AtomicLong();
OptimisticBank(Map<String, Integer> opening) { current = new AtomicReference<>(new Ledger(Map.copyOf(opening), 0)); }
// Optimistic: read a snapshot, decide and build the new state without any lock, then commit // only if nobody else committed in the meantime. If someone did, start over from their result. boolean transfer(String from, String to, int amount) { if (from.equals(to)) throw new IllegalArgumentException("transfer to the same account"); while (true) { Ledger seen = current.get(); int fromBalance = seen.balances().get(from); // The check reads a snapshot that can't change under us, so it can't go stale halfway. if (fromBalance < amount) return false; Map<String, Integer> next = new HashMap<>(seen.balances()); next.put(from, fromBalance - amount); next.merge(to, amount, Integer::sum); Ledger proposed = new Ledger(Map.copyOf(next), seen.version() + 1); // Compare-and-set swaps in the new ledger only if current is still exactly `seen`. // The hardware does the compare and the swap as one step, so there is no gap to race. if (current.compareAndSet(seen, proposed)) return true; retries.incrementAndGet(); // someone committed first: our check may be stale, so redo it } }
// Readers take one snapshot and never lock. Whatever they compute from it is consistent. Ledger snapshot() { return current.get(); }}
class Main { public static void main(String[] args) throws InterruptedException { OptimisticBank bank = new OptimisticBank(Map.of("a", 100, "b", 100, "c", 100)); String[] ids = {"a", "b", "c"}; List<Thread> writers = new ArrayList<>(); // Four writers move money around a ring: a to b, b to c, c to a, a to b. for (int i = 0; i < 4; i++) { String from = ids[i % 3], to = ids[(i + 1) % 3]; writers.add(new Thread(() -> { for (int k = 0; k < 5_000; k++) bank.transfer(from, to, 1); })); } // A reader sums every snapshot while the writers run. It never sees money in flight. AtomicBoolean running = new AtomicBoolean(true); AtomicLong badTotals = new AtomicLong(); Thread reader = new Thread(() -> { while (running.get()) if (bank.snapshot().total() != 300) badTotals.incrementAndGet(); }); reader.start(); for (Thread t : writers) t.start(); for (Thread t : writers) t.join(); running.set(false); reader.join();
Ledger end = bank.snapshot(); System.out.println("total=" + end.total() + " badTotals=" + badTotals.get()); // total=300 badTotals=0 // version counts successful transfers; retries varies by run and grows with contention. System.out.println("version=" + end.version() + " retries=" + bank.retries.get()); }}import threadingfrom dataclasses import dataclassfrom types import MappingProxyTypefrom typing import Mapping
# One immutable snapshot of every balance, plus a version that goes up on every commit.## Nobody ever changes a Ledger; a change builds a new one. Immutable objects can be shared# between any number of threads with no locks at all.@dataclass(frozen=True)class Ledger: balances: Mapping[str, int] # a read-only view over a dict nobody else holds version: int
def total(self) -> int: return sum(self.balances.values())
class OptimisticBank: def __init__(self, opening: dict[str, int]) -> None: self._current = Ledger(MappingProxyType(dict(opening)), 0) # Python's standard library has no compare-and-set. A lock held only for the compare and # the swap gives the same guarantee; the slow part, building the new ledger, stays outside. self._commit_lock = threading.Lock() self.retries = 0 # guarded by _commit_lock
def _compare_and_set(self, seen: Ledger, proposed: Ledger) -> bool: with self._commit_lock: if self._current is not seen: self.retries += 1 return False self._current = proposed return True
# Optimistic: read a snapshot, decide and build the new state without holding anything, then # commit only if nobody else committed in the meantime. If someone did, start over from theirs. def transfer(self, source: str, target: str, amount: int) -> bool: if source == target: raise ValueError("transfer to the same account") while True: seen = self._current source_balance = seen.balances[source] # The check reads a snapshot that can't change under us, so it can't go stale halfway. if source_balance < amount: return False nxt = dict(seen.balances) nxt[source] = source_balance - amount nxt[target] = nxt.get(target, 0) + amount if self._compare_and_set(seen, Ledger(MappingProxyType(nxt), seen.version + 1)): return True # someone committed first: our check may be stale, so redo it
# Readers take one snapshot and never lock. Whatever they compute from it is consistent. def snapshot(self) -> Ledger: return self._current
if __name__ == "__main__": bank = OptimisticBank({"a": 100, "b": 100, "c": 100}) ids = ["a", "b", "c"]
def many(source: str, target: str) -> None: for _ in range(5_000): bank.transfer(source, target, 1)
# Four writers move money around a ring: a to b, b to c, c to a, a to b. writers = [threading.Thread(target=many, args=(ids[i % 3], ids[(i + 1) % 3])) for i in range(4)]
# A reader sums every snapshot while the writers run. It never sees money in flight. stop = threading.Event() bad_totals = 0
def read_totals() -> None: global bad_totals while not stop.is_set(): if bank.snapshot().total() != 300: bad_totals += 1
reader = threading.Thread(target=read_totals) reader.start() for t in writers: t.start() for t in writers: t.join() stop.set() reader.join()
end = bank.snapshot() print("total =", end.total(), "bad totals =", bad_totals) # total = 300 bad totals = 0 # version counts successful transfers; retries varies by run and grows with contention. print("version =", end.version, "retries =", bank.retries)using System;using System.Collections.Generic;using System.Collections.Immutable;using System.Linq;using System.Threading;
// One immutable snapshot of every balance, plus a version that goes up on every commit.// Nobody ever changes a Ledger; a change builds a new one. Immutable objects can be shared// between any number of threads with no locks at all.sealed record Ledger(ImmutableDictionary<string, int> Balances, long Version){ public int Total() => Balances.Values.Sum();}
class OptimisticBank{ private Ledger _current; private long _retries; public long Retries => Interlocked.Read(ref _retries);
public OptimisticBank(IDictionary<string, int> opening) => _current = new Ledger(opening.ToImmutableDictionary(), 0);
// Optimistic: read a snapshot, decide and build the new state without any lock, then commit // only if nobody else committed in the meantime. If someone did, start over from their result. public bool Transfer(string from, string to, int amount) { if (from == to) throw new ArgumentException("transfer to the same account"); while (true) { var seen = Volatile.Read(ref _current); int fromBalance = seen.Balances[from]; // The check reads a snapshot that can't change under us, so it can't go stale halfway. if (fromBalance < amount) return false; var next = seen.Balances .SetItem(from, fromBalance - amount) .SetItem(to, (seen.Balances.TryGetValue(to, out var toBalance) ? toBalance : 0) + amount); var proposed = new Ledger(next, seen.Version + 1); // CompareExchange swaps in the new ledger only if _current is still exactly `seen`. // The hardware does the compare and the swap as one step, so there is no gap to race. if (ReferenceEquals(Interlocked.CompareExchange(ref _current, proposed, seen), seen)) return true; Interlocked.Increment(ref _retries); // someone committed first: our check may be stale, so redo it } }
// Readers take one snapshot and never lock. Whatever they compute from it is consistent. public Ledger Snapshot() => Volatile.Read(ref _current);}
static class Program{ static void Main() { var bank = new OptimisticBank(new Dictionary<string, int> { ["a"] = 100, ["b"] = 100, ["c"] = 100 }); string[] ids = ["a", "b", "c"]; // Four writers move money around a ring: a to b, b to c, c to a, a to b. var writers = Enumerable.Range(0, 4) .Select(i => new Thread(() => { for (int k = 0; k < 5_000; k++) bank.Transfer(ids[i % 3], ids[(i + 1) % 3], 1); })) .ToList(); // A reader sums every snapshot while the writers run. It never sees money in flight. int running = 1; long badTotals = 0; var reader = new Thread(() => { while (Volatile.Read(ref running) == 1) if (bank.Snapshot().Total() != 300) badTotals++; }); reader.Start(); writers.ForEach(t => t.Start()); writers.ForEach(t => t.Join()); Volatile.Write(ref running, 0); reader.Join();
var end = bank.Snapshot(); Console.WriteLine($"total={end.Total()} badTotals={badTotals}"); // total=300 badTotals=0 // Version counts successful transfers; retries varies by run and grows with contention. Console.WriteLine($"version={end.Version} retries={bank.Retries}"); }}package main
import ( "errors" "fmt" "maps" "sync" "sync/atomic")
// Ledger is one immutable snapshot of every balance, plus a version that goes up on every// commit. Nobody ever changes a Ledger after it is published; a change builds a new one.// Immutable values can be shared between any number of goroutines with no locks at all.type Ledger struct { Balances map[string]int // never written after the Ledger is stored Version int64}
func (l *Ledger) Total() int { t := 0 for _, v := range l.Balances { t += v } return t}
type OptimisticBank struct { current atomic.Pointer[Ledger] retries atomic.Int64}
func NewOptimisticBank(opening map[string]int) *OptimisticBank { b := &OptimisticBank{} b.current.Store(&Ledger{Balances: maps.Clone(opening)}) return b}
// Transfer is optimistic: read a snapshot, decide and build the new state without any lock,// then commit only if nobody else committed in the meantime. If someone did, start over from// their result.func (b *OptimisticBank) Transfer(from, to string, amount int) (bool, error) { if from == to { return false, errors.New("transfer to the same account") } for { seen := b.current.Load() fromBalance := seen.Balances[from] // The check reads a snapshot that can't change under us, so it can't go stale halfway. if fromBalance < amount { return false, nil } next := maps.Clone(seen.Balances) next[from] = fromBalance - amount next[to] += amount proposed := &Ledger{Balances: next, Version: seen.Version + 1} // CompareAndSwap stores the new ledger only if current is still exactly `seen`. The // hardware does the compare and the swap as one step, so there is no gap to race. if b.current.CompareAndSwap(seen, proposed) { return true, nil } b.retries.Add(1) // someone committed first: our check may be stale, so redo it }}
// Snapshot is for readers: one load, never a lock. Whatever they compute from it is consistent.func (b *OptimisticBank) Snapshot() *Ledger { return b.current.Load() }
func main() { bank := NewOptimisticBank(map[string]int{"a": 100, "b": 100, "c": 100}) ids := []string{"a", "b", "c"} // Four writers move money around a ring: a to b, b to c, c to a, a to b. var writers sync.WaitGroup for i := range 4 { writers.Add(1) go func() { defer writers.Done() for range 5_000 { bank.Transfer(ids[i%3], ids[(i+1)%3], 1) } }() } // A reader sums every snapshot while the writers run. It never sees money in flight. var stop atomic.Bool badTotals := make(chan int) go func() { bad := 0 for !stop.Load() { if bank.Snapshot().Total() != 300 { bad++ } } badTotals <- bad }() writers.Wait() stop.Store(true)
end := bank.Snapshot() fmt.Println("total =", end.Total(), "bad totals =", <-badTotals) // total = 300 bad totals = 0 // Version counts successful transfers; retries varies by run and grows with contention. fmt.Println("version =", end.Version, "retries =", bank.retries.Load())}// One immutable snapshot of every balance, plus a version that goes up on every commit.// Nobody ever changes a Ledger; a change builds a new one.interface Ledger { readonly balances: ReadonlyMap<string, number>; readonly version: number;}
const total = (l: Ledger) => [...l.balances.values()].reduce((s, v) => s + v, 0);
// The slow step of a transfer, run on the snapshot: a fraud score, a limit lookup. Other// transfers commit while it is awaited.const gap = (): Promise<void> => Promise.resolve();
class OptimisticBank { #current: Ledger; retries = 0;
constructor(opening: Record<string, number>) { this.#current = { balances: new Map(Object.entries(opening)), version: 0 }; }
// Optimistic: read a snapshot, do the slow work and build the new state without holding // anything, then commit only if nobody else committed in the meantime. If someone did, start // over from their result. No lock means no lock order to get wrong, and nothing to deadlock. async transfer(from: string, to: string, amount: number): Promise<boolean> { if (from === to) throw new Error("transfer to the same account"); for (;;) { const seen = this.#current; const fromBalance = seen.balances.get(from)!; if (fromBalance < amount) return false; await gap(); const next = new Map(seen.balances); next.set(from, fromBalance - amount); next.set(to, (next.get(to) ?? 0) + amount); // The compare and the set have no await between them, so in Node they are one atomic // step: the same guarantee compare-and-set gives threads. if (this.#current === seen) { this.#current = { balances: next, version: seen.version + 1 }; return true; } this.retries++; // someone committed during our await: our check may be stale, so redo it } }
snapshot(): Ledger { return this.#current; }}
async function main(): Promise<void> { const bank = new OptimisticBank({ a: 100, b: 100, c: 100 }); // Two transfers in flight at once. Both read version 0; the first to commit wins, and the // second notices and redoes its work against version 1. const results = await Promise.all([bank.transfer("a", "b", 30), bank.transfer("b", "c", 20)]); const end = bank.snapshot(); console.log(results, "total =", total(end)); // [ true, true ] total = 300 console.log("version =", end.version, "retries =", bank.retries); // version = 2 retries = 1}
main();No lock means no lock order to get wrong and nothing to deadlock. A database does the same with optimistic concurrency: an update applies only if the row’s version is the one you read.
UPDATE accountsSET balance = balance - 30, version = version + 1WHERE id = 1 AND version = 7 AND balance >= 30;-- 1 row updated: committed. 0 rows: someone changed it first, or funds are short. Re-read and retry.Python’s standard library has no CAS. So its tab commits under a lock, held only for the compare and
the swap. The expensive part, building the new ledger, still runs outside it. TypeScript needs no
lock either. With no await between the version check and the assignment, the event loop makes
them one step.
Which fix
Section titled “Which fix”| Approach | Breaks | Wins when | Costs |
|---|---|---|---|
| Lock ordering | Circular wait | You know every lock up front: transfers, seat sets, room ranges | Everyone must follow the rule; one method that doesn’t breaks it |
| tryLock and back off | Hold and wait | Locks are discovered as you go | Retries under contention, livelock without random back-off |
| Optimistic CAS or versioning | Mutual exclusion | Conflicts are rare, reads far outnumber writes, state is small | Wasted work and retries under heavy contention, copying on write |
| One owner thread (actor) | All of them: no sharing | Many entities interact in complex ways: an elevator, a matching engine | One thread’s throughput is the ceiling; callers wait on a queue |
Go deeperThe ABA problem, and why the code here is safe from it2 min
CAS compares values, not history. Say a thread reads A. Another thread changes it to B, and back to
A. The first thread’s CAS succeeds, as if nothing happened. For a plain balance, that can be
harmless. For a lock-free stack, where A is a node pointer that was freed and reused, it corrupts
the structure. The ledger code is safe, because every commit allocates a new Ledger object, and
CAS compares references. A new object never equals the one you read, even with the same balances. A
version counter that only goes up gives the same protection, in a database row or a packed 64-bit
word. Garbage-collected languages also never reuse an address while you still hold a reference to
it. That removes the classic pointer form of the problem.
Your transfer locks both accounts. What happens when two transfers go in opposite directions at the same moment?
Whether you spot the deadlock without being led to it, name the cycle, and fix it with a global
lock order. Senior candidates also mention what breaks the rule: a method that locks in another
order, or a callback that takes a lock. And they offer the database version: a conditional
update, or SELECT ... FOR UPDATE in primary key order. The red flag is “that’s rare, a retry
handles it”. A deadlocked thread never fails, so nothing retries.
A transfer needs both accounts, so it takes both locks, always lower id first. That way two transfers in opposite directions can’t each hold one and wait for the other. If conflicts are rare I’d rather go optimistic: read, compute, and commit with a version check.
The rest of the toolkit
Section titled “The rest of the toolkit”The cheapest safety: don’t share, or don’t mutate
Section titled “The cheapest safety: don’t share, or don’t mutate”A race needs shared mutable state. The cheapest fix removes one of the two words before reaching for a lock.
- Immutability. An object whose fields are all final, and set in the constructor, can be shared
by any number of threads with no locks. Value objects (
Money,TimeSlot,Ledgerabove) should be immutable anyway. A change builds a new value, and publishes it through one safe reference, as the optimistic bank does. - Confinement. State that only one thread touches needs no protection. That could be a request’s locals, or a whole subsystem owned by one thread that others reach through a queue. The elevator and vending machine designs do this, which removes every lock from the domain classes.
Concurrent collections
Section titled “Concurrent collections”A concurrent collection makes each of its own operations atomic and visible. It does not make your sequence of operations atomic, and it does not protect the objects stored inside it.
Go deeperConcurrent collections in each language1 min
| Need | Java | Python | C# | Go | TypeScript |
|---|---|---|---|---|---|
| Map | ConcurrentHashMap |
dict plus a Lock |
ConcurrentDictionary |
map plus sync.Mutex, or sync.Map |
Map (one thread) |
| Bounded blocking queue | ArrayBlockingQueue |
queue.Queue(maxsize=n) |
BlockingCollection, Channel.CreateBounded |
buffered chan |
none built in |
| Read-mostly list | CopyOnWriteArrayList |
tuple swapped on change | ImmutableList swapped on change |
slice swapped under a mutex or atomic.Pointer |
array swapped on change |
| Counter | AtomicLong, LongAdder |
int plus a Lock |
Interlocked |
atomic.Int64 |
number |
Go’s built-in map is not safe for concurrent writes, and the runtime stops the whole program
with “concurrent map writes” when it detects one. Java’s LongAdder spreads a hot counter over
several cells and sums on read, which beats AtomicLong when dozens of threads increment the
same counter.
Go deeperRead-write locks, and when they pay2 min
A read-write lock lets many readers in at once, or one writer alone. The trap is a “read” that
writes. An LRU cache get moves the entry to the front of a list. So
two readers under a shared read lock corrupt the list. Ask what a method changes, not what it is
called.
It pays when reads far outnumber writes, and each read holds the lock long enough for sharing to
matter. For short critical sections, a plain mutex is usually as fast. A read-write lock costs more
per acquire. And some versions let a steady stream of readers starve writers. Java’s
ReentrantReadWriteLock and StampedLock, C#’s ReaderWriterLockSlim and Go’s sync.RWMutex
provide one. Python’s standard library does not.
Go deeperWaiting for a condition: producer and consumer2 min
Sometimes a thread must wait for a state, not a lock: “the queue is not empty”. A condition variable lets it release the lock, and sleep until another thread signals that the state may have changed. The standard use is a bounded queue between producers and consumers. A logging framework uses one to move disk writes off request threads. A task scheduler uses one to hand jobs to workers.
- Color key
- Background worker
- Queue or stream
Three rules make it correct. The built-in blocking queues follow all of them, so use those:
- Wait in a
whileloop, never anif. Another consumer may have taken the item first. And Java and POSIX threads allow spurious wake-ups, with no signal at all. - Change the state under the same lock the waiter checks under. Otherwise, a signal sent between the waiter’s check and its sleep is lost, and the waiter sleeps forever.
- Bound the queue. An unbounded queue turns a slow consumer into an out-of-memory crash. A bounded one makes producers wait, or answer “busy”.
Each language, honestly
Section titled “Each language, honestly”The ideas are the same in all five languages. Two differences matter. First, Python’s GIL does not
make code thread safe. A thread can be switched out between the bytecodes of
self.balance -= amount. So the easy level’s race happens on CPython. Second, TypeScript’s races
are about await, not threads. Put no await between a check and the action that depends on it.
Or hold an async lock across both.
Go deeperThe five languages side by side2 min
| Language | What runs in parallel | Lock | Atomics and CAS | Tooling |
|---|---|---|---|---|
| Java | Threads on every core | synchronized, ReentrantLock (tryLock, fairness) |
AtomicInteger, AtomicReference, LongAdder |
jstack prints deadlocked threads |
| Python | One thread runs Python bytecode at a time under the GIL; free-threaded 3.13+ builds run threads in parallel | threading.Lock, RLock, Condition |
none in the standard library | faulthandler dumps stacks of a hung process |
| C# | Threads and the thread pool on every core | lock on a private Lock (.NET 9+) or object, SemaphoreSlim for async |
Interlocked, Volatile |
Parallel Stacks window shows deadlocks |
| Go | Goroutines on every core | sync.Mutex, sync.RWMutex; or channels |
sync/atomic types |
go test -race finds data races |
| TypeScript | One thread per process; Worker threads share only SharedArrayBuffer |
An async mutex (a small class or a library) between awaits |
Atomics on a SharedArrayBuffer |
none needed for the event loop |
Java and C# work the way the levels show. Use synchronized or lock on a private object for most
needs. Use ReentrantLock and Lock.TryEnter for timeouts. Use AtomicInteger and Interlocked
for single values. Use volatile for visibility alone.
Python’s GIL lets one thread run bytecode at a time, and some single operations such as
list.append or dict[key] = value happen to be atomic in CPython. Free-threaded builds
(PEP 703, optional since 3.13) run threads truly in parallel, so code that leaned on the GIL by
accident fails more often there. Lock around every invariant, exactly as in Java.
Node runs one callback at a time, so synchronous code is never interrupted, and every await is
a point where other requests run. Worker threads share memory only through a
SharedArrayBuffer, with Atomics.add, Atomics.compareExchange and Atomics.wait.
Go offers two models. Use mutexes. Or use channels, so one goroutine owns the state and others send
it requests (confinement with a queue). Run tests with -race. It reports any two unsynchronized
accesses where one is a write. It finds data races, not logic races. The medium level’s lost
account passes it cleanly.
Where in-process locks stop working
Section titled “Where in-process locks stop working”Everything above protects state inside one process. A lock in server 1’s memory means nothing to server 2. So two servers can each take their own “account 1” lock, and each pay out.
- Color key
- Database
The shared state lives in the database, so the atomic step has to happen there:
| Option | How | Use when |
|---|---|---|
| Constraint in the schema | Unique index on (show, seat), a check that balance is at least 0, an exclusion constraint on room and time range | The rule fits a constraint. The strongest answer, because no code path can skip it |
| Conditional update | UPDATE ... WHERE balance >= 30, or WHERE version = 7, then check the row count |
One row per decision; the optimistic version of the hard level |
| Row lock | SELECT ... FOR UPDATE in a transaction, rows locked in primary key order |
Several rows must change together and conflicts are common |
| Lease from a coordinator | A lock with an expiry in etcd, ZooKeeper or a database row, plus a fencing token | Work outside the database must run once: a nightly job, a leader |
A distributed lock is the last resort. It can expire while its holder is paused in a garbage collection. Then two holders act at once. Only a fencing token, checked by the resource, makes that safe. (Consensus covers leases and fencing.) Consistency models covers write skew, the database version of check-then-act.
Tradeoffs
Section titled “Tradeoffs”Reach for the tools in this order, and stop at the first one that holds the invariant:
- Don’t share: confine state to one thread, or one owner with a queue.
- Don’t mutate: immutable values, published through one safe reference.
- One atomic for one independent value: a counter, a flag, a reference.
- One lock per entity, held around the whole invariant, with nothing slow inside.
- Several locks: one global order, or tryLock with random back-off.
- Optimistic CAS or versions when conflicts are rare and retries are cheap.
- The database or a coordinator once more than one process shares the state.
Commonly taught wrong
Section titled “Commonly taught wrong”Commonly confused with
Section titled “Commonly confused with”| Idea | Looks like | The difference |
|---|---|---|
| Race condition vs. data race | Both are “concurrency bugs” | A data race is two unsynchronized accesses to one memory location, one a write; tools find it. A race condition is a wrong result from timing, and can happen with every access synchronized, like the medium level’s lost account. |
| Deadlock vs. livelock vs. starvation | Threads make no progress | Deadlock: blocked forever, doing nothing. Livelock: busy retrying, getting nowhere. Starvation: others progress, one thread never gets a turn. |
| Concurrency vs. parallelism | Many things at once | Concurrency is tasks whose steps interleave, even on one core (Node). Parallelism is steps running at the same instant on several cores. Races need only concurrency. |
| Thread safe vs. reentrant | Both about being called “again” | Reentrant means a function can be called again while already running on the same thread (a callback, recursion). A reentrant lock can be taken twice by its holder. Neither implies the other. |
Where it shows up
Section titled “Where it shows up”Every LLD problem on this site ends with a concurrency step. The list below groups them by the idea each one leans on.
Go deeperEvery LLD problem, by the concurrency idea it uses1 min
- Check-then-act on one entity: parking lot (two gates, one spot), movie ticket booking (one seat, two clicks, and the database version), hotel booking (the last suite, nights locked in order), car rental (the last SUV, payment kept outside the lock), ATM (the race is at the bank, not the machine) and library management (a hold that misses a return).
- Lost updates: Splitwise (two expenses update one balance) and rate limiter (two threads take one token, and why holding two bucket locks at once invites deadlock).
- Lock granularity and ordering: file system (a lock per directory, and why move makes it hard), stock exchange (one lock per order book), pub-sub (per-topic ordering without a global lock) and food delivery (a cancel racing an accept).
- Producer and consumer: logging framework (a bounded queue in front of a slow disk), task scheduler (a watcher that must be woken without a lost wake-up) and notification service (two workers, one delivery).
- One owner thread: elevator and vending machine.
- Reads that write, and torn reads: LRU cache and text editor undo.
- Races that a lock doesn’t fix: tic-tac-toe, snake and ladder and chess, where a duplicate request needs an idempotency check, not just a lock.
The approach page shows where this step sits in a 45 minute round and grows a meeting room booker to a lock per room.
Interview questions
Section titled “Interview questions”Interviewer asksWhat does it mean for a class to be thread safe?
It keeps its invariants however many threads call it, and however their steps interleave, with no extra coordination from callers. Then name the invariant for the class in front of you (“a seat has at most one holder”). Say which reads and writes it depends on, because those are what the lock must cover.
Interviewer asksHow do you choose between a lock, an atomic and a concurrent collection?
An atomic for one independent value: a counter, a flag, a reference to an immutable snapshot. A
concurrent collection when the invariant is about the collection itself and its atomic methods
(computeIfAbsent, putIfAbsent) cover it. A lock when the invariant spans several values or
several calls, like a check and a debit, or two accounts.
Interviewer asksHow do you prevent deadlock?
Name the four conditions and break one. Usually circular wait: every thread takes locks in one global order, such as by id. When the order can’t be known up front, tryLock with a timeout and random back-off, so nobody holds one lock while waiting forever for another. Simpler still: hold only one lock at a time, and never call unknown code while holding one.
Interviewer asksYour service runs on ten servers. Does your lock still work?
No. It only orders threads inside one process. Move the atomic step to the shared store. Use a
unique or check constraint. Or a conditional update, with a version or a row count check. Or
SELECT ... FOR UPDATE, with rows locked in key order. For work outside the database that must
run once, use a lease from a coordinator, with a fencing token that the resource checks.
| Decision | We chose | We gave up |
|---|---|---|
| What to protect | The invariant: every read and write it depends on | Locking per field or per method, which looks safe and isn’t |
| How big a lock | One per entity, taken around the whole check and act | One coarse lock that is easier to prove correct |
| Two locks at once | A fixed global order, lower id first | Freedom to lock in whatever order the code reads naturally |
| When order is unknown | tryLock with a timeout and random back-off | Predictable latency under contention |
| A single shared value | An atomic, or a CAS loop on an immutable snapshot | Retries and copying when many threads write at once |
| Lookup-or-create in a shared map | The map’s atomic method, never get-then-put | Slow work inside the map’s own lock |
| More than one process | A constraint, conditional update or row lock in the database | The speed of an in-memory lock |
Further reading
Section titled “Further reading”- How to approach an LLD interview, for where the concurrency step sits in the round
- Singleton, for the first-use race, double-checked locking and leases across servers
- Observer, for why listeners are called after the lock is released
- Consistency models and consensus, for the same problems across machines
- Brian Goetz et al., Java Concurrency in Practice (2006), the standard reference for invariants, visibility and lock ordering; it applies well beyond Java
- The Go Memory Model, a short, precise statement of when one goroutine’s write is visible to another
- Martin Kleppmann, How to do distributed locking (2016), on leases, pauses and fencing tokens
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.