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
- Race condition: the result depends on timing. Typically a check-then-act ("is the seat free? then book it") or a read-modify-write ("read the count, add one, write it").
- Lock (mutex): only one thread at a time runs the guarded code. The check and the act must be inside the same lock.
- Deadlock: thread 1 holds lock A and waits for B; thread 2 holds B and waits for A. Neither ever continues. The standard fix: always take locks in the same global order.
- Granularity: one big lock is simple and correct but serialises everything; finer locks (per show, per account) allow more parallelism and need ordering.
- Atomic types and concurrent collections (AtomicInteger, ConcurrentHashMap) avoid locks for single operations, not for multi-step invariants.
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.
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
- Say where the shared state is and what invariant protects it: "a seat is held by at most one hold".
- Find the check-then-act in your design and put the check and the act in one critical section.
- Choose granularity out loud: one lock per show rather than per system; explain why it's enough.
- Mention lock ordering as soon as a method takes two locks.
- Several servers: in-process locks don't help. The database (a conditional update, a unique constraint, a transaction) becomes the lock.
6. Remember it
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 notebook | Concurrency |
|---|---|
| Reading 5, both writing 6 | A lost update |
| Holding the pen | Holding the lock |
| Two people, two notebooks, each waiting | Deadlock |
| Always pick up the red notebook first | Lock ordering |
Watch it on an example
- Check and act: "is A2 free?" and "mark A2 held" inside the show's lock.
- Lock order: transfer(alice, bob) and transfer(bob, alice) both lock account 1 first.
- 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
8. Practice exercise (25 minutes)
- Find the races (10 min). In P06, P12 and P13, find each check-then-act and the lock that guards it.
- Break it (5 min). In the widget, produce a lost update; count how many orderings lose it.
- Deadlock on paper (5 min). Write two methods that deadlock, then fix them with lock ordering.
- 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?
How do you prevent deadlock when a method needs two locks?
Does single-threaded async JavaScript ever need locks?
Three app servers book seats. What prevents double booking?
10. Go deeper
| Resource | Time | Why |
|---|---|---|
| Concurrency (Java tutorials) | 60 min | Threads, locks, atomics and liveness, with examples. |
| threading (Python docs) | 20 min | Locks and threads in Python. |
| P12: Movie booking | 45 min | A race for one seat, solved. |