Free lesson · Concurrency Interview Mastery
Exercise: Coarse-Grained vs Fine-Grained Locking
This exercise walks the granularity dial from one extreme toward the other and confronts the cost that appears along the way. Part 1 converts a bottlenecked coarse design to fine-grained; Part 2 hits the deadlock that fine-grained locking introduces; Part 3 designs lock striping as the bounded middle ground. Reason each through before the solution - the skill is recognizing when to refine granularity and what new problems refining creates.
Important: The judgment this exercise builds: refining granularity is never free. Every time you split a lock for more concurrency, ask what it costs - deadlock risk, memory, complexity - and whether the contention you're solving is real. "Start coarse, refine on evidence" is the meta-rule; these parts make the costs concrete.
Part 1 - A coarse bottleneck
A bank holds thousands of accounts behind a single global lock. Under load, throughput is terrible - threads spend their time waiting for the one lock even when operating on completely different accounts.
class Bank:
lock = new Mutex()
accounts = {...} // thousands of accounts
function deposit(id, amount):
lock.lock()
accounts[id].balance += amount // all deposits serialize, even to different accounts
lock.unlock()
Your task: Refine to fine-grained locking so that deposits to different accounts proceed concurrently. What does each lock now protect, and what concurrency do you gain?
<details> <summary>Hint</summary>
Give each account its own lock. A deposit to account A and a deposit to account B then take different locks and run in parallel. The single global lock - the bottleneck - is gone; contention now only happens between operations on the same account. </details>
Part 2 - The deadlock fine-grained locking introduces
With per-account locks, a transfer must lock two accounts. Two transfers run concurrently:
function transfer(from, to, amount):
from.lock.lock()
to.lock.lock()
from.balance -= amount
to.balance += amount
to.lock.unlock()
from.lock.unlock()
// Thread 1: transfer(A, B, 100)
// Thread 2: transfer(B, A, 50)
Your task: This occasionally hangs forever. Explain why, and fix it without reverting to a single global lock (that would undo Part 1's concurrency).
<details> <summary>Hint</summary>
Thread 1 locks A then wants B; Thread 2 locks B then wants A. If both grab their first lock before either grabs its second, each waits for the other - circular wait, deadlock. The fix: always acquire the two account locks in a consistent global order (e.g., by account ID), regardless of transfer direction. </details>
Part 3 - Lock striping
You decide that a lock per account is wasteful: with millions of accounts, that's millions of lock objects consuming memory, most never contended. You want most of the concurrency benefit at a fraction of the memory.
Your task: Design lock striping - a fixed number of locks (say 64) guarding all accounts. How do you map an account to a lock, what concurrency do you get, and what's the trade-off versus per-account locks?
<details> <summary>Hint</summary>
Keep an array of 64 locks. Map each account to one by hashing its ID: lock = stripes[hash(id) % 64]. Up to 64 operations on accounts that hash to different stripes run concurrently, using 64 locks instead of millions. The trade-off: two accounts that happen to hash to the same stripe contend even though they're unrelated (false contention), but with enough stripes this is rare.
</details>
Solution
Part 1 - Fine-grained per-account locks
class Account:
lock = new Mutex()
balance = 0
class Bank:
accounts = {...} // each Account has its own lock
function deposit(id, amount):
acct = accounts[id]
acct.lock.lock()
acct.balance += amount // only contends with other ops on THIS account
acct.lock.unlock()
Each account now carries its own lock, so a deposit to account A and a deposit to account B acquire different locks and run fully in parallel. The single global lock - which forced every deposit in the entire bank to serialize - is gone. Contention now occurs only between operations on the same account, which is the genuine, irreducible conflict (you really can't apply two updates to one balance simultaneously). This is the core win of fine-grained locking: it removes false contention (between unrelated operations) while preserving the true contention (between operations that actually conflict). Throughput now scales with the number of distinct accounts being touched, instead of bottlenecking on one lock.
Part 2 - The deadlock and the ordering fix
Why it hangs: Thread 1 locks A and wants B; Thread 2 locks B and wants A. On the unlucky interleaving where both acquire their first lock before either acquires its second, each holds what the other needs and neither will release - a circular wait, the textbook deadlock. It hangs only sometimes (when the interleaving lines up), which is exactly what makes it dangerous: it passes testing and strikes in production.
The fix - consistent lock ordering:
function transfer(from, to, amount):
// acquire the two locks in a FIXED global order, not transfer order
first, second = (from.id < to.id) ? (from, to) : (to, from)
first.lock.lock()
second.lock.lock()
from.balance -= amount
to.balance += amount
second.lock.unlock()
first.lock.unlock()
By always locking the lower-ID account first regardless of transfer direction, both threads acquire the two locks in the same order. Whichever thread grabs the lower-ID lock first will also get the higher-ID lock (the other thread waits on just the first lock), so the circular wait can't form. This preserves Part 1's concurrency - transfers on disjoint account pairs still run in parallel - while eliminating the deadlock. The general principle: fine-grained locking makes multi-piece operations need multiple locks, and multiple locks require a consistent global acquisition order. This is the module's capstone topic, previewed here because it's the unavoidable companion to fine-grained locking.
Part 3 - Lock striping
NUM_STRIPES = 64
stripes = [64 Mutexes]
function lockFor(id): return stripes[hash(id) % NUM_STRIPES]
function deposit(id, amount):
lock = lockFor(id)
lock.lock()
accounts[id].balance += amount
lock.unlock()
Instead of one lock per account (millions of objects) or one global lock (no concurrency), use a fixed 64 locks, assigning each account to one by hash(id) % 64. Concurrency: up to 64 operations run in parallel as long as they hash to different stripes - almost always enough to remove the bottleneck for typical thread counts. Memory: 64 lock objects total, regardless of whether there are thousands or billions of accounts - bounded and tiny. The trade-off versus per-account locks: two unrelated accounts that happen to hash to the same stripe will contend even though they don't actually conflict (false contention from hash collision on stripes). But with 64 stripes and far fewer concurrent threads than stripes, such collisions are rare, so you capture nearly all the real concurrency at a fraction of the cost. Striping is the pragmatic answer to "fine-grained is too expensive, coarse is too slow" - most of the benefit, bounded cost. (For transfers, you'd still order the two stripe locks consistently, and handle the case where both accounts map to the same stripe by locking it once.)
Common mistakes
Warning: Watch for: (1) Refining granularity without measuring - splitting locks everywhere before confirming the coarse lock is actually the bottleneck; premature complexity. (2) Forgetting that multi-piece operations need ordering - going fine-grained without adding consistent lock ordering for operations spanning pieces, reintroducing deadlock. (3) Reverting to a global lock to "fix" the deadlock - it works but throws away the concurrency you just gained; order the locks instead. (4) A lock per element when striping would do - wasting memory on millions of rarely-contended locks; stripe to a bounded count. (5) Striping then forgetting the same-stripe case - when two keys in a multi-key operation map to the same stripe, lock it once, not twice (a non-reentrant lock would self-deadlock - see the next lesson).
Why this exercise matters for interviews
Interview: "Design a thread-safe hash map / bank / counter" is an extremely common system-design-flavored concurrency question, and it is this exercise: start coarse, identify the bottleneck, refine to fine-grained, handle the multi-piece deadlock with ordering, and reach for striping as the bounded middle. Walking that arc - and naming the cost at each step (deadlock risk, memory, false contention) - demonstrates that you understand granularity as a tunable trade-off rather than a binary. The single strongest move is to not jump straight to a lock per element: saying "I'd start with one lock, measure, and stripe if it's contended" shows the judgment interviewers are actually testing. Practice articulating the per-account-to-striping progression, because it's the spine of the answer.