Locks and mutexes
Making a critical section one-at-a-time, and what it costs.
01The intuition
The problem it solves
A ticket counter sells the last seat. Two clerks, on two threads, both check that one seat remains and both sell it. The code looks correct because each line is correct; the problem is between the lines.
import threading
class Counter:
def __init__(self, seats: int) -> None:
self.seats = seats
def sell(self) -> bool:
if self.seats > 0: # thread A reads 1, thread B reads 1
self.seats -= 1 # both decrement: seats becomes -1
return True
return False
Run 2,000 sells across 20 threads on 1,000 seats and you will see negative counts some runs and not others. That is the signature of a race: the check and the change are two steps, and another thread can slip in between them. Any object that several threads update has this problem unless it does something about it.
Build it one piece at a time
Step one: a lock. A lock is a token that only one thread can hold; the check-and-change pair runs while holding it, so the pair becomes one indivisible step from every other thread's point of view. The region inside the lock is the critical section, and the with statement guarantees the lock is released even if the code raises.
class Counter:
def __init__(self, seats: int) -> None:
self.seats = seats
self.lock = threading.Lock()
def sell(self) -> bool:
with self.lock: # one thread at a time from here
if self.seats > 0:
self.seats -= 1
return True
return False
Step two: keep the critical section small. If selling also sends an email that takes 200 ms, holding the lock during the email serialises every sale behind the slowest network call. Decide inside the lock, act outside it.
class Counter(Counter): # continued
def sell_and_notify(self, email: str) -> bool:
with self.lock:
if self.seats == 0:
return False
self.seats -= 1
seat_no = self.seats + 1 # decided under the lock
send_email(email, seat_no) # slow work outside the lock
return True
def send_email(to: str, seat: int) -> None:
pass
Step three: one lock per independent thing. If the counter sells seats for 500 shows, one global lock makes every show wait for every other. A lock per show lets unrelated sales proceed in parallel; the dictionary of locks itself needs a lock only while a new show is added.
class BoxOffice:
def __init__(self) -> None:
self.seats: dict[str, int] = {}
self.locks: dict[str, threading.Lock] = {}
self.registry_lock = threading.Lock()
def add_show(self, show: str, seats: int) -> None:
with self.registry_lock:
self.seats[show] = seats
self.locks[show] = threading.Lock()
def sell(self, show: str) -> bool:
with self.locks[show]: # only this show waits
if self.seats[show] > 0:
self.seats[show] -= 1
return True
return False
What two threads see with and without the lock, selling the last seat:
| Moment | Without lock | With lock |
|---|---|---|
| 1 | A reads seats = 1 | A acquires, reads 1 |
| 2 | B reads seats = 1 | B blocks on acquire |
| 3 | A writes 0, returns True | A writes 0, releases |
| 4 | B writes −1, returns True | B acquires, reads 0, returns False |
Why it works
The invariant a lock protects is whatever the object promises about its fields, here seats is never negative and every True corresponds to exactly one seat. The lock makes the critical section atomic with respect to other critical sections on the same lock, so the invariant can be broken inside the section and must be restored before leaving it. Nothing outside the section may read the fields without the lock, or it can see the broken middle state.
Where it breaks: forgetting that reads need the lock too, calling a method that takes the same lock from inside the section (a plain Lock deadlocks against itself; RLock allows re-entry at a cost), and holding two locks in different orders in two places, which is the deadlock discussed in its own article. In Python specifically, the interpreter lock does not save you: seats -= 1 is still several bytecodes.
throughput with one global lock ≈ 1 / (critical section time). With k independent locks and uniform traffic ≈ k / (critical section time)
The trade-off you must say out loud
A lock trades parallelism for correctness. One coarse lock is simplest and often enough; finer locks buy throughput and risk deadlock; lock-free structures and atomics avoid blocking but are hard to get right. Say which you chose, what the critical section contains, and what you deliberately moved outside it. The follow-up that usually comes next is "what if most calls only read", which leads to read-write locks, and "what if two objects must change together", which leads to lock ordering or optimistic versions.
Where it shows up in the 100 Google problems
- Multi-threaded parking lot: two cars for the last spot is the ticket counter with a lock per floor or per spot.
- Thread-safe LFU cache: a plain lock around get and put is the first answer; the interviewer then asks what it costs.
- Rate limiter class: one lock per client so one chatty client does not serialise everyone.
- Digital wallet: a transfer touches two accounts, which is where lock ordering enters.
- Blocking connection pool: a lock plus a condition variable so acquirers wait instead of spinning.
02Used in these problems
- Design a thread-safe LFU cache
- Design a rate limiter class
- Design a concurrent priority task scheduler
- Design a blocking connection pool
- Build your own Future / CompletableFuture
- Design a thread-safe singleton and justify the alternative
- Design a multi-threaded parking lot
- Design a movie ticket booking system
- Design a digital wallet
- Design an inventory management system
- Design an online auction