Free lesson · Concurrency Interview Mastery
Coarse-grained vs Fine-grained Locking
Module 3 gave you the primitives - mutexes, semaphores, condition variables, the rest. This module is about strategy: given those tools, how do you actually use them to build systems that are both correct and fast? The first and most fundamental strategic question is granularity - how much should a single lock protect? Lock the whole data structure with one lock, or break it into many small locks each guarding a piece? This single decision shapes everything downstream: how much concurrency you get, how likely you are to deadlock, how hard the code is to reason about, and how much overhead you pay. There's no universally right answer - it's a genuine trade-off - and understanding that trade-off is what this lesson is about. It's also one of the most practical concurrency interview topics, because real systems live or die on getting granularity right.
The two ends of the spectrum
Coarse-grained locking uses one lock (or very few) to protect a large amount of state - an entire data structure, a whole subsystem. A single global lock around the whole thing.
// Coarse: ONE lock for the entire map
class CoarseMap:
lock = new Mutex()
data = {}
function get(k): lock.lock(); v = data[k]; lock.unlock(); return v
function put(k,v): lock.lock(); data[k] = v; lock.unlock()
Fine-grained locking uses many locks, each protecting a small piece of the state - a lock per hash bucket, a lock per list node, a lock per row. Threads operating on different pieces don't contend.
// Fine: one lock PER BUCKET
class FineMap:
buckets = [...] // each bucket has its own lock
function get(k):
b = buckets[hash(k)]
b.lock.lock(); v = b.data[k]; b.lock.unlock(); return v
Between these extremes lies a spectrum, and most real designs sit somewhere in the middle rather than at either pole.
flowchart LR
subgraph C["Coarse-grained: 1 lock"]
L1[ single lock ] --> WHOLE[(entire structure)]
end
subgraph F["Fine-grained: many locks"]
LB1[lock] --> B1[(bucket 1)]
LB2[lock] --> B2[(bucket 2)]
LB3[lock] --> B3[(bucket 3)]
LB4[lock] --> B4[(bucket 4)]
end
C -.simple, low concurrency.-> TRADE{granularity<br/>trade-off}
F -.complex, high concurrency.-> TRADE
This diagram contrasts coarse-grained and fine-grained locking. On the left, coarse-grained locking uses a single lock guarding the entire data structure - simple, but it serializes all access. On the right, fine-grained locking uses many locks, one per bucket of the structure, so threads working on different buckets don't block each other - more concurrency, but more complex. Both feed into the central granularity trade-off: coarse is simple with low concurrency, fine is complex with high concurrency, and the strategic choice is where on that spectrum to sit.
The trade-off, precisely
Each end buys something and costs something. Naming both sides exactly is what an interview wants.
Coarse-grained - what you gain: simplicity and safety. One lock is trivial to reason about: hold it, you have exclusive access to everything; there's no lock ordering to worry about, so deadlock from multiple locks is impossible. It's the correct, boring default, and it's easy to verify. What you lose: concurrency. Because one lock serializes all access, every operation queues behind every other, even operations on completely unrelated parts of the structure. Under load the single lock becomes a bottleneck - adding threads doesn't help because they all funnel through it. The structure is correct but doesn't scale.
Fine-grained - what you gain: concurrency. Threads touching different pieces (different buckets, different nodes) hold different locks and proceed in parallel, so the structure scales with the number of independent operations. This is the whole point - recovering the parallelism the coarse lock threw away. What you lose: simplicity and safety. Many locks means: deadlock risk (an operation spanning multiple pieces acquires multiple locks, and inconsistent ordering can deadlock - the exact hazard from the mutex exercise); more overhead (more lock objects consuming memory, and more acquire/release operations per logical action); and harder reasoning (you must track which lock guards which piece and ensure every access takes the right one). Fine-grained locking is powerful but genuinely harder to get right.
Important: The strategic rule: start coarse, refine only where profiling proves you need to. Begin with the simplest correct design - usually one lock - because it's easy to get right and easy to verify. Then measure: if the single lock is a contention bottleneck (threads spending their time waiting for it, throughput not improving with more cores), refine the granularity in that specific hot spot. Jumping straight to fine-grained locking everywhere is premature optimization that buys complexity and deadlock risk you may not need. Correct-and-simple first, fast-where-it-matters second. "I'd start with one lock and split it only where measurement shows contention" is the answer that signals engineering judgment rather than a reflex to over-engineer.
Lock striping: the pragmatic middle
The pure extremes - one global lock vs a lock per element - are rarely optimal. One global lock under-scales; a lock per element wastes enormous memory (imagine a million-element map with a million lock objects) and adds overhead without proportional benefit. The pragmatic middle ground is lock striping: use a fixed number of locks N (say 16 or 64) to protect M pieces where M ≫ N, mapping each piece to a lock by hashing. Piece i is guarded by lock i mod N.
// Lock striping: 16 locks guard all buckets, assigned by hash
stripes = [16 locks]
function lockFor(key): return stripes[hash(key) mod 16]
function put(k, v):
lock = lockFor(k)
lock.lock(); buckets[hash(k)].put(k, v); lock.unlock()
Striping captures most of the concurrency benefit (up to N operations on different stripes proceed in parallel) at a fixed, bounded cost (only N lock objects regardless of how large the structure grows). With 16 stripes, up to 16 threads can operate concurrently as long as they hash to different stripes - usually enough to eliminate the bottleneck - while using 16 locks instead of a million. This is exactly how early concurrent hash maps worked (Java's ConcurrentHashMap historically used a fixed number of segment locks, a form of striping). Lock striping is the canonical "fine-grained but bounded" strategy and a high-value thing to know for interviews, because it shows you understand that granularity is a dial, not a binary. The one wrinkle to keep in mind: a multi-key operation whose keys land on the same stripe must lock that stripe only once, not twice - with a non-reentrant lock, naively locking it per key would self-deadlock, which is a neat illustration of how the next lesson's topic (reentrancy) and this one intersect in real code.
The multi-piece operation problem
Fine-grained locking introduces a specific hazard worth calling out, because it's where fine-grained designs go wrong. Some operations span multiple pieces - a transfer touching two accounts, a range query touching several buckets, a resize touching the whole structure. With one coarse lock these are trivial (you hold the one lock). With fine-grained locks, a multi-piece operation must acquire multiple locks, which reintroduces deadlock risk: two such operations acquiring their locks in different orders can deadlock (the circular wait from the mutex exercise). The defense is consistent lock ordering - always acquire multiple locks in the same global order - which gets its own full treatment as this module's capstone. For now, recognize that the moment you go fine-grained, any operation spanning pieces needs careful lock-ordering discipline, and that's a real part of fine-grained locking's complexity cost.
A worked progression: the thread-safe hash map
The thread-safe hash map is the canonical vehicle for the granularity decision, and walking the full progression makes the trade-off concrete. Start with the simplest correct design - one lock for the whole map:
// Stage 1 - coarse: one lock, the simple correct baseline
class Map1:
lock = Mutex(); data = {}
get(k): lock.lock(); v = data[k]; lock.unlock(); return v
put(k,v): lock.lock(); data[k]=v; lock.unlock()
This is correct and trivially verifiable - no deadlock possible, every operation obviously serialized. Ship it, then measure. If profiling shows the map is a hot spot where threads pile up on the single lock - many threads doing get/put concurrently, throughput flat as you add cores - you have a real contention bottleneck, and now refining granularity is justified. The key observation that makes refinement possible: operations on different keys don't actually conflict. Two threads reading or writing unrelated keys could safely run in parallel; the single lock is forcing false serialization between them.
So split the lock by where keys live - the buckets:
// Stage 2 - striped: N locks guard all buckets by hash (the practical choice)
class Map2:
stripes = [16 Mutexes]; buckets = [...]
lockFor(k): return stripes[hash(k) % 16]
get(k): L = lockFor(k); L.lock(); v = buckets[hash(k)].get(k); L.unlock(); return v
put(k,v): L = lockFor(k); L.lock(); buckets[hash(k)].put(k,v); L.unlock()
Now up to 16 threads operating on keys that hash to different stripes proceed fully in parallel, while keys on the same stripe still serialize (acceptable - that's rare with enough stripes). The bottleneck is gone, and you paid only 16 lock objects regardless of map size. Notice we jumped to striping, not a lock per bucket - a lock per bucket would scale to millions of lock objects for a large map, wasting memory on locks that are almost never contended, for little benefit over a fixed stripe count. This progression - coarse baseline, measure, then stripe - is the answer to "design a thread-safe hash map," and articulating why you stop at striping rather than per-bucket (bounded cost, sufficient concurrency) is the part that shows judgment. The general operations like size() or a resize that touch the whole map are where striping gets genuinely tricky (you'd need to acquire all stripe locks in order), which is the natural segue into fine-grained locking's costs.
The hidden costs of fine-grained locking
Fine-grained locking's downsides are easy to underestimate, so it's worth making them concrete - because underestimating them is how fine-grained designs become buggy and slow despite "more concurrency." The first cost is deadlock risk on multi-piece operations, already noted: any operation touching several pieces acquires several locks, and without consistent ordering, two such operations deadlock. A whole-map operation like resize or size() may need every lock, acquired in a fixed order, held together - expensive and error-prone, and a place where the "simple coarse lock" would have been a single trivial acquisition.
The second cost is per-operation overhead. Every lock acquisition and release costs something (an atomic operation at minimum, a system call under contention), and fine-grained locking multiplies the number of acquisitions per logical action. An operation that touched one coarse lock might now touch three fine locks - three times the locking overhead. For operations that span pieces, the fine-grained version can actually be slower than the coarse one despite the theoretical concurrency, because the lock overhead dominates. The concurrency benefit only pays off when operations are mostly independent (touching disjoint pieces), which is the case fine-grained locking is designed for and the case where it shines.
The third cost is reasoning difficulty and correctness risk. With one lock, the invariant is simple: hold it, you have exclusive access to everything. With many locks, you must track precisely which lock guards which piece, ensure every access takes the right lock, and ensure multi-piece operations take all the needed locks in the right order. The mental model is far more complex, and complexity breeds bugs - a forgotten lock on one access path, a missed ordering, an operation that reads two pieces under separate locks and sees them in mutually inconsistent states because they were locked at different moments. This last one is subtle: with separate locks, an operation reading piece A then piece B doesn't see a consistent snapshot of both, because B could change between the two lock acquisitions. Coarse locking gives you that consistency for free; fine-grained locking makes you engineer it. All three costs are why the discipline is "coarse first, refine on evidence" - you take on this complexity only where the concurrency payoff justifies it, and never reflexively.
Note: A useful way to hold the whole spectrum in mind: granularity trades contention against complexity, and you want the least complexity that removes the contention that actually matters. One coarse lock is the floor of complexity and the ceiling of contention; a lock per element is the reverse. Striping deliberately picks a point in between - enough locks to scatter the real contention, few enough to stay simple and cheap. The engineering move is never "maximize concurrency"; it's "remove the contention that profiling shows is hurting you, at the lowest complexity that does so." That framing keeps you from both under-locking (a bottleneck) and over-locking (deadlock-prone, memory-hungry complexity you didn't need).
The interview angle
Interview: Core questions: (1) "Coarse vs fine-grained locking trade-off?" - Coarse (one lock) is simple, deadlock-free, easy to verify, but serializes everything and bottlenecks under load. Fine (many locks) gives concurrency for independent operations but adds deadlock risk, overhead, and complexity. (2) "How do you decide?" - Start coarse (correct and simple); profile; refine granularity only where measurement shows a contention bottleneck. (3) "What is lock striping?" - A fixed number of locks N guarding M ≫ N pieces by hashing, capturing most of the concurrency at bounded memory cost; how concurrent hash maps are built. (4) "What new problem does fine-grained locking create?" - Multi-piece operations need multiple locks, reintroducing deadlock risk, which requires consistent lock ordering. (5) "Design a thread-safe hash map." - Discuss coarse (one lock) vs striped (N segment locks) and the trade-off; striping is the strong answer. Framing granularity as a dial you tune by measurement, with striping as the practical middle, is what distinguishes a strategic answer from a rote one.
Tip: In a system-design or coding interview, the phrase that signals maturity is "let me start with the simplest correct version and refine if it's a bottleneck." It applies directly to locking granularity: propose one lock, note that you'd profile, and describe striping as the refinement you'd reach for if the single lock contends. This shows you optimize based on evidence rather than instinct, and it lets you discuss the full spectrum without committing prematurely to complexity you might not need.
Quick reference
GRANULARITY = how much state one lock protects. The core locking-strategy dial.
COARSE (one lock for everything):
+ simple, easy to verify, NO deadlock (single lock)
- serializes ALL access -> bottleneck, doesn't scale
FINE (many locks, one per piece):
+ concurrency: independent operations run in parallel
- deadlock risk (multi-piece ops need multiple locks -> need lock ordering)
- more overhead (memory + acquire/release), harder to reason about
STRATEGY: start COARSE (correct + simple) -> profile -> refine ONLY at proven bottlenecks
LOCK STRIPING (the pragmatic middle):
N fixed locks guard M >> N pieces, assigned by hash (lock = stripes[hash(key) % N])
most of the concurrency at bounded cost (N locks, not M) -> how concurrent hash maps work
WATCH: fine-grained + multi-piece operation = multiple locks = deadlock risk -> lock ordering
Granularity is the first locking-strategy dial. The next lesson examines a property of locks themselves that's also a strategic choice: reentrancy - whether a thread can re-acquire a lock it already holds, why that prevents a subtle self-deadlock, and why some languages deliberately refuse to allow it.