InterviewKata
Low-level design · Module 06 of 6 · Level 1: Foundations

Concurrency and thread safety

Almost every LLD interview ends with "what if two users do this at the same time?" This module covers the three ideas you need to answer it: race conditions, locks around check-then-act, and lock ordering to avoid deadlock, including why JavaScript still needs locks around await.

1. The idea

Two people share one notebook to keep a tally. Each reads the number, adds one in their head, and writes the result. If both read "5" before either writes, both write "6", and one tally is lost. A lock is agreeing that only the person holding the pen may read and write. Most concurrency bugs in LLD answers are this notebook.

JavaScript and TypeScript run one callback at a time, so a synchronous method can't be interrupted. But every await is a point where other tasks run: a read before an await and a write after it can still race, so async code needs async locks.

2. How it works

Two threads transfer money in opposite directions. Without a lock order they can deadlock; with one, they can't.

3. Try it yourself

Be the scheduler

Two threads each add 1 to a shared counter that starts at 5. You decide which thread runs its next step. Can you produce a lost update? Then switch the lock on and try again.

Thread 1

    Thread 2

      4. In code

      4,000 transfers run concurrently in both directions (threads in Python and Java, async tasks in TypeScript). Locks are always taken lower id first, and the total never changes.

      from __future__ import annotations
      import threading
      
      
      class Account:
          def __init__(self, acct_id: int, owner: str, balance: int) -> None:
              self.id, self.owner, self.balance = acct_id, owner, balance
              self.lock = threading.Lock()
      
      
      def transfer(src: Account, dst: Account, amount: int) -> bool:
          """Lock both accounts, always in the same order (lower id first), so two opposite transfers can't deadlock."""
          first, second = (src, dst) if src.id < dst.id else (dst, src)
          with first.lock, second.lock:
              if src.balance < amount:   # check and update under the same locks: no other thread can slip in between
                  return False
              src.balance -= amount
              dst.balance += amount
              return True
      
      
      if __name__ == "__main__":
          alice, bob = Account(1, "alice", 5000), Account(2, "bob", 5000)
          done = [0]
          count_lock = threading.Lock()
      
          def worker(src: Account, dst: Account) -> None:
              for _ in range(1000):
                  if transfer(src, dst, 1):
                      with count_lock:
                          done[0] += 1
      
          threads = [threading.Thread(target=worker, args=(a, b)) for a, b in ((alice, bob), (bob, alice)) * 2]
          for t in threads:
              t.start()
          for t in threads:
              t.join()
          print("transfers done:", done[0])
          print(f"alice {alice.balance} bob {bob.balance} total {alice.balance + bob.balance}")
      /** JavaScript runs one callback at a time, but every await is a point where another task can run.
       *  So a read-then-write that spans an await still needs a lock: an async one. */
      class AsyncLock {
        private tail: Promise<void> = Promise.resolve();
        async run<T>(fn: () => Promise<T>): Promise<T> {
          const prev = this.tail;
          let release!: () => void;
          this.tail = new Promise<void>((r) => (release = r));
          await prev;
          try {
            return await fn();
          } finally {
            release();
          }
        }
      }
      
      class Account {
        readonly lock = new AsyncLock();
        constructor(readonly id: number, readonly owner: string, public balance: number) {}
      }
      
      const pause = () => new Promise<void>((r) => setImmediate(r)); // stands in for a database round trip
      declare function setImmediate(cb: () => void): void;
      
      /** Lock both accounts, always in the same order (lower id first), so two opposite transfers can't deadlock. */
      async function transfer(src: Account, dst: Account, amount: number): Promise<boolean> {
        const [first, second] = src.id < dst.id ? [src, dst] : [dst, src];
        return first.lock.run(() => second.lock.run(async () => {
          const balance = src.balance; // read…
          await pause();               // …another task could run here…
          if (balance < amount) return false;
          src.balance = balance - amount; // …so the write is only safe because we hold the locks
          dst.balance += amount;
          return true;
        }));
      }
      
      async function main(): Promise<void> {
        const alice = new Account(1, "alice", 5000), bob = new Account(2, "bob", 5000);
        const jobs: Promise<boolean>[] = [];
        for (const [a, b] of [[alice, bob], [bob, alice], [alice, bob], [bob, alice]]) for (let i = 0; i < 1000; i++) jobs.push(transfer(a, b, 1));
        const results = await Promise.all(jobs);
        console.log("transfers done:", results.filter(Boolean).length);
        console.log(`alice ${alice.balance} bob ${bob.balance} total ${alice.balance + bob.balance}`);
      }
      void main();
      import java.util.ArrayList;
      import java.util.List;
      import java.util.concurrent.atomic.AtomicInteger;
      import java.util.concurrent.locks.ReentrantLock;
      
      class Account {
          final int id;
          final String owner;
          int balance;
          final ReentrantLock lock = new ReentrantLock();
      
          Account(int id, String owner, int balance) {
              this.id = id;
              this.owner = owner;
              this.balance = balance;
          }
      }
      
      class Bank {
          /** Lock both accounts, always in the same order (lower id first), so two opposite transfers can't deadlock. */
          static boolean transfer(Account src, Account dst, int amount) {
              Account first = src.id < dst.id ? src : dst, second = first == src ? dst : src;
              first.lock.lock();
              try {
                  second.lock.lock();
                  try {
                      if (src.balance < amount) return false; // check and update under the same locks
                      src.balance -= amount;
                      dst.balance += amount;
                      return true;
                  } finally {
                      second.lock.unlock();
                  }
              } finally {
                  first.lock.unlock();
              }
          }
      }
      
      public class Main {
          public static void main(String[] args) throws InterruptedException {
              Account alice = new Account(1, "alice", 5000), bob = new Account(2, "bob", 5000);
              AtomicInteger done = new AtomicInteger();
              List<Thread> threads = new ArrayList<>();
              Account[][] pairs = {{alice, bob}, {bob, alice}, {alice, bob}, {bob, alice}};
              for (Account[] p : pairs) {
                  threads.add(Thread.ofPlatform().start(() -> {
                      for (int i = 0; i < 1000; i++) if (Bank.transfer(p[0], p[1], 1)) done.incrementAndGet();
                  }));
              }
              for (Thread t : threads) t.join();
              System.out.println("transfers done: " + done.get());
              System.out.println("alice " + alice.balance + " bob " + bob.balance + " total " + (alice.balance + bob.balance));
          }
      }

      Output (the same in all three languages)

      transfers done: 4000
      alice 5000 bob 5000 total 10000

      5. In the interview

      6. Remember it

      Memory hook: "Check and act under one lock; lock in one order"Guard every check-then-act with a single lock, and when you need two locks, always take them in the same order.
      How this hook works: what “Check and act under one lock; lock in one order” means

      What the slogan means, phrase by phrase

      “Check and act under one lock”
      The read, the decision and the write happen while no one else can touch the data.
      “lock in one order”
      If a method needs two locks, take them in a global order (by id), so no two threads wait for each other in a circle.

      Why this picture fits: The shared notebook: only the person holding the pen may read and write the tally; and if two notebooks are needed, everyone picks them up in the same order.

      The story → the concept

      The notebookConcurrency
      Reading 5, both writing 6A lost update
      Holding the penHolding the lock
      Two people, two notebooks, each waitingDeadlock
      Always pick up the red notebook firstLock ordering

      Watch it on an example

      1. Check and act: "is A2 free?" and "mark A2 held" inside the show's lock.
      2. Lock order: transfer(alice, bob) and transfer(bob, alice) both lock account 1 first.
      3. Result in the code: 4,000 concurrent transfers, no deadlock, total unchanged.

      The trap it saves you from

      Locking the check and the act separately, which looks safe and isn't.

      It still works when…

      Your code is single-threaded JavaScript. Every await is a possible interleaving: the same rule applies with an async lock.

      7. Common mistakes

      Locking only the writeThe check and the act must be inside the same lock.
      Two locks in different ordersThe textbook deadlock. Order them by a stable key.
      Holding a lock during slow I/OEveryone waits on a network call. Do the I/O outside, or rethink.
      Thinking JavaScript needs no locksawait splits a method; async read-modify-writes can still race.
      In-memory locks across serversThey protect one process only. Use the database or a distributed lock.

      8. Practice exercise (25 minutes)

      1. Find the races (10 min). In P06, P12 and P13, find each check-then-act and the lock that guards it.
      2. Break it (5 min). In the widget, produce a lost update; count how many orderings lose it.
      3. Deadlock on paper (5 min). Write two methods that deadlock, then fix them with lock ordering.
      4. Database version (5 min). Write the SQL that makes "book seat A2" atomic without an in-memory lock.

      9. Self-check quiz

      Two threads read a counter of 5 and both write 6. What's this called?

      Read-modify-write without a lock.

      How do you prevent deadlock when a method needs two locks?

      No circular wait is possible.

      Does single-threaded async JavaScript ever need locks?

      Other tasks run at every await.

      Three app servers book seats. What prevents double booking?

      In-process locks don't cross servers.

      10. Go deeper

      ResourceTimeWhy
      Concurrency (Java tutorials)60 minThreads, locks, atomics and liveness, with examples.
      threading (Python docs)20 minLocks and threads in Python.
      P12: Movie booking45 minA race for one seat, solved.