Skip to content

Low Level DesignFoundations

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.

Mid-level28 min core · 33 min full depth

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.

The idea in one picture
Raj's threadJoint accountAna's threadRaj's threadJoint accountAna's threadbalance is -100, both paid outThe fix makes read, check and write one step that the other thread cannot cut intoread balance100, enoughread balance100, enoughbalance = 100 - 100balance = 0 - 100Raj's threadJoint accountAna's threadRaj's threadJoint accountAna's threadbalance is -100, both paid outThe fix makes read, check and write one step that the other thread cannot cut intoread balance100, enoughread balance100, enoughbalance = 100 - 100balance = 0 - 100
Scroll or pinch to zoom, drag to pan

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:

Thread A · Ana

    ready

    Thread B · Raj

      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.

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

      Here is a hit counter: hits++ on a shared field. Is it thread safe?

      What they are testing

      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.

      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.

      Easy

      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.

      Main.javaaccount.pyAccount.csaccount.goaccount.ts
      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
      }
      }

      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.

      Give the account one private lock and hold it around every read and write of the balance.

      Easy: the account owns its lock

      calls, never sees the lock

      Account

      -lock: Lock

      -balance: int

      +withdraw(amount) : bool

      +deposit(amount)

      +balance() : int

      Caller

      +any thread

      calls, never sees the lock

      Account

      -lock: Lock

      -balance: int

      +withdraw(amount) : bool

      +deposit(amount)

      +balance() : int

      Caller

      +any thread

      Scroll or pinch to zoom, drag to pan
      • Color key
      • Facade
      • Entity
      • Value object
      • Enum
      • Interface
      • Implementation
      Account.javaaccount.pyAccount.csaccount.goaccount.ts
      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
      }
      }
      • The lock is private. Java’s synchronized(this) and C#’s lock(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 await in 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.
      Interviewer

      Why not just make balance volatile, or an AtomicInteger?

      What they are testing

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

      Say this out loud

      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.

      Medium

      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.

      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.

      Per-account locks need a map from id to account, shared by every thread. A thread-safe map seems to settle it:

      Bank.javabank.pyBank.csbank.gobank.ts
      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
      }
      }

      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.

      Medium: a concurrent map finds the account, the account's lock guards the money

      one lock each

      1

      many

      Bank

      -accounts: ConcurrentMap<String, Account>

      -operations: AtomicLong

      +open(id) : Account

      +deposit(id, amount)

      +withdraw(id, amount) : bool

      Account

      -lock: Lock

      -balance: int

      +deposit(amount)

      +withdraw(amount) : bool

      one lock each

      1

      many

      Bank

      -accounts: ConcurrentMap<String, Account>

      -operations: AtomicLong

      +open(id) : Account

      +deposit(id, amount)

      +withdraw(id, amount) : bool

      Account

      -lock: Lock

      -balance: int

      +deposit(amount)

      +withdraw(amount) : bool

      Scroll or pinch to zoom, drag to pan
      • Color key
      • Facade
      • Entity
      • Value object
      • Enum
      • Interface
      • Implementation
      Bank.javabank.pyBank.csbank.gobank.ts
      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
      }
      }
      • Use the map’s atomic compound operations. computeIfAbsent in Java, GetOrAdd in C#, LoadOrStore in 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 a get followed by a put.
      • Keep the function inside computeIfAbsent short. 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#’s GetOrAdd may 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. operations is a single number that nothing else depends on. So AtomicLong.incrementAndGet, Interlocked.Increment or atomic.Int64.Add does 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.
      Interviewer

      “I’d store the accounts in a ConcurrentHashMap, so it’s thread safe.”

      What they are testing

      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.

      Say this out loud

      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.

      Hard

      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.

      Locking the source account first and then the destination is the natural way to write it:

      Bank.javabank.pyBank.csaccount.goaccount.ts
      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
      }
      }

      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.

      account 1account 2Ana (A)Raj (B)

      holdswaiting for

      Thread A · Ana

        ready

        Thread B · Raj

          ready

          Two transfers in opposite directions deadlock
          Raj's transfer, 2 to 1Lock on account 2Lock on account 1Ana's transfer, 1 to 2Raj's transfer, 2 to 1Lock on account 2Lock on account 1Ana's transfer, 1 to 2Each holds what the other needs. Nobody gives anything back.lock, grantedlock, grantedlock, waits for Rajlock, waits for AnaRaj's transfer, 2 to 1Lock on account 2Lock on account 1Ana's transfer, 1 to 2Raj's transfer, 2 to 1Lock on account 2Lock on account 1Ana's transfer, 1 to 2Each holds what the other needs. Nobody gives anything back.lock, grantedlock, grantedlock, waits for Rajlock, waits for Ana
          Scroll or pinch to zoom, drag to pan

          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

          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.

          Hard: transfers lock in id order, or commit a new ledger with compare-and-set

          locks lower id first

          swaps whole snapshots

          Bank

          +transfer(from, to, amount) : bool

          +total(x, y) : int

          Account

          +id: int

          -lock: Lock

          -balance: int

          OptimisticBank

          -current: AtomicReference<Ledger>

          +transfer(from, to, amount) : bool

          +snapshot() : Ledger

          Ledger

          +balances: Map<String, int>

          +version: long

          +total() : int

          locks lower id first

          swaps whole snapshots

          Bank

          +transfer(from, to, amount) : bool

          +total(x, y) : int

          Account

          +id: int

          -lock: Lock

          -balance: int

          OptimisticBank

          -current: AtomicReference<Ledger>

          +transfer(from, to, amount) : bool

          +snapshot() : Ledger

          Ledger

          +balances: Map<String, int>

          +version: long

          +total() : int

          Scroll or pinch to zoom, drag to pan
          • Color key
          • Facade
          • Entity
          • Value object
          • Enum
          • Interface
          • Implementation
          • New in this step
          Bank.javabank.pyBank.csaccount.goaccount.ts
          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
          }
          }

          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.

          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
          Bank.javabank.pyBank.csaccount.goaccount.ts
          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
          }
          }

          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.

          OptimisticBank.javaoptimistic_bank.pyOptimisticBank.csoptimistic_bank.gooptimistic-bank.ts
          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());
          }
          }

          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 accounts
          SET balance = balance - 30, version = version + 1
          WHERE 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.

          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.

          Interviewer

          Your transfer locks both accounts. What happens when two transfers go in opposite directions at the same moment?

          What they are testing

          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.

          Say this out loud

          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 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, Ledger above) 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.

          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.

          A bounded queue: producers wait when it's full, consumers when it's empty
          ConsumerBounded queue, capacity 2ProducerConsumerBounded queue, capacity 2Producertake, queue empty, wait on notEmptyput job 1, signal notEmptywakes, re-checks, takes job 1put job 2put job 3put job 4, queue full, wait on notFulltake job 2, signal notFullwakes, re-checks, puts job 4ConsumerBounded queue, capacity 2ProducerConsumerBounded queue, capacity 2Producertake, queue empty, wait on notEmptyput job 1, signal notEmptywakes, re-checks, takes job 1put job 2put job 3put job 4, queue full, wait on notFulltake job 2, signal notFullwakes, re-checks, puts job 4
          Scroll or pinch to zoom, drag to pan
          • 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 while loop, never an if. 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”.

          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.

          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.

          Two servers, two local locks, one double withdrawal
          Server 2DatabaseServer 1Server 2DatabaseServer 1takes its own lock on account 1takes its own lock on account 1paid out 200, recorded 100read balance100read balance100write balance 0write balance 0Server 2DatabaseServer 1Server 2DatabaseServer 1takes its own lock on account 1takes its own lock on account 1paid out 200, recorded 100read balance100read balance100write balance 0write balance 0
          Scroll or pinch to zoom, drag to pan
          • 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.

          Reach for the tools in this order, and stop at the first one that holds the invariant:

          1. Don’t share: confine state to one thread, or one owner with a queue.
          2. Don’t mutate: immutable values, published through one safe reference.
          3. One atomic for one independent value: a counter, a flag, a reference.
          4. One lock per entity, held around the whole invariant, with nothing slow inside.
          5. Several locks: one global order, or tryLock with random back-off.
          6. Optimistic CAS or versions when conflicts are rare and retries are cheap.
          7. The database or a coordinator once more than one process shares the state.
          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.

          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

          The approach page shows where this step sits in a 45 minute round and grows a meeting room booker to a lock per room.

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