π File 09: Read-Write Locks β shared_mutex, shared_lock vs unique_lock
From your actual interview: "We will Acquire a Read Write lock for Parent Nodes which will allow multiple reads and single write."
π What is a Read-Write Lock? (Simple Explanation)
A Read-Write Lock allows:
- Multiple readers at the same time (they don't modify anything, so they can't conflict)
- Only ONE writer at a time (and NO readers while writing)
Analogy: A Wikipedia article.
- Many people can READ the article simultaneously β no problem
- Only ONE editor can WRITE at a time β must lock others out
- While someone is editing, readers must wait for the edit to finish
Why It's Better Than a Plain Mutex for Our Tree:
With plain mutex on Asia:
Thread 1: lock(China) β needs to CHECK if Asia is locked β needs Asia's mutex
Thread 2: lock(India) β needs to CHECK if Asia is locked β needs Asia's mutex
β Thread 2 WAITS for Thread 1, even though BOTH are just READING Asia's state!
With Read-Write Lock on Asia:
Thread 1: lock(China) β SHARED READ LOCK on Asia β reads Asia's state
Thread 2: lock(India) β SHARED READ LOCK on Asia β reads Asia's state
β Both proceed simultaneously! β
Thread 3: lock(Asia) β EXCLUSIVE WRITE LOCK β must wait for readers to finishπ§ How Read-Write Locks Work Internally
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Read-Write Lock State β
β β
β ββββββββββββββββ ββββββββββββββββββββ β
β β reader_count β β writer_active β β
β β (int) β β (bool) β β
β ββββββββββββββββ ββββββββββββββββββββ β
β β
β shared_lock (READ): β
β if writer_active == false: β
β reader_count++ β
β ALLOWED β
(multiple readers OK) β
β else: β
β BLOCK β (writer is active) β
β β
β unique_lock (WRITE): β
β if reader_count == 0 && writer_active == false:β
β writer_active = true β
β ALLOWED β
(exclusive access) β
β else: β
β BLOCK β (readers or another writer active) β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββCompatibility Matrix:
| Read held | Write held | |
|---|---|---|
| Read request | β Allowed | β Blocked |
| Write request | β Blocked | β Blocked |
π» C++ Implementation
Using std::shared_mutex (C++17):
#include <shared_mutex>
std::shared_mutex rwLock;
// READER (shared access β multiple threads can do this simultaneously)
{
std::shared_lock<std::shared_mutex> readLock(rwLock);
// Read shared data here
// Multiple threads can be here at the same time
}
// WRITER (exclusive access β only one thread)
{
std::unique_lock<std::shared_mutex> writeLock(rwLock);
// Modify shared data here
// No other thread (reader OR writer) can be here
}Key Types:
| Type | Lock Mode | Purpose |
|---|---|---|
std::shared_lock<shared_mutex> | READ (shared) | Multiple threads can hold simultaneously |
std::unique_lock<shared_mutex> | WRITE (exclusive) | Only one thread, no readers allowed |
β Java Implementation
Using ReentrantReadWriteLock:
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
ReadWriteLock rwLock = new ReentrantReadWriteLock();
// READER
rwLock.readLock().lock();
try {
// Read shared data
} finally {
rwLock.readLock().unlock();
}
// WRITER
rwLock.writeLock().lock();
try {
// Modify shared data
} finally {
rwLock.writeLock().unlock();
}π³ Tree of Space with Read-Write Locks
The Insight from Your Interview:
When locking node X:
- We need WRITE access to node X (we're modifying its
lockedByand propagating up) - We need READ access to ancestor nodes (we're only CHECKING if they're locked)
#include <shared_mutex>
#include <vector>
#include <unordered_set>
#include <unordered_map>
#include <string>
using namespace std;
// Each node has its own read-write lock
vector<shared_mutex> nodeRWLock;
vector<int> locked;
vector<int> par;
vector<unordered_set<int>> lockedDesc;
unordered_map<string, int> nameMap;
bool lockWithRWLock(const string& name, int uid) {
int node = nameMap[name];
// Step 1: Acquire WRITE lock on the target node
unique_lock<shared_mutex> writeLock(nodeRWLock[node]);
// Step 2: Check self
if (locked[node] != -1) return false;
if (!lockedDesc[node].empty()) return false;
// Step 3: Acquire READ locks on ancestors (leaf to root)
// Note: We need to hold the write lock on our node while reading ancestors
vector<shared_lock<shared_mutex>> readLocks;
int cur = par[node];
while (cur != -1) {
readLocks.emplace_back(nodeRWLock[cur]);
if (locked[cur] != -1) return false;
cur = par[cur];
}
// Step 4: All checks pass β lock the node
locked[node] = uid;
// Step 5: Propagate up β we need WRITE access to ancestor's sets!
// β οΈ PROBLEM: We hold READ locks on ancestors but need to WRITE!
// Option A: Upgrade readβwrite (not supported in std::shared_mutex)
// Option B: Release read locks, re-acquire as write locks
// Option C: Use a separate mutex just for the lockedDesc set
// For simplicity, use a separate per-node mutex for set modification:
// (In practice, you'd design this more carefully)
cur = par[node];
for (auto& rlock : readLocks) {
rlock.unlock(); // Release read locks
}
readLocks.clear();
cur = par[node];
while (cur != -1) {
unique_lock<shared_mutex> ancestorWrite(nodeRWLock[cur]);
lockedDesc[cur].insert(node);
cur = par[cur];
}
return true;
// writeLock on node auto-releases
}β οΈ The Read-to-Write Upgrade Problem
In the code above, there's a subtle issue:
1. We take READ lock on ancestor Asia to CHECK if it's locked β OK
2. We need to INSERT into Asia's lockedDescendants set β need WRITE lock
3. We can't upgrade a shared_lock to unique_lock in C++!
4. If we release the read lock and re-acquire as write β
TOCTOU gap (Asia might get locked between release and reacquire)Cleaner Solution: Hybrid Approach
bool lockHybrid(const string& name, int uid) {
int node = nameMap[name];
// Gather path
vector<int> path;
int cur = node;
while (cur != -1) {
path.push_back(cur);
cur = par[cur];
}
// Acquire WRITE lock on target, READ locks on ancestors
// Target node (path[0]) β write lock
unique_lock<shared_mutex> targetLock(nodeRWLock[path[0]]);
// Ancestors β read locks
vector<shared_lock<shared_mutex>> ancestorLocks;
for (int i = 1; i < (int)path.size(); i++) {
ancestorLocks.emplace_back(nodeRWLock[path[i]]);
}
// Check self
if (locked[node] != -1) return false;
if (!lockedDesc[node].empty()) return false;
// Check ancestors (all read-locked, safe to read)
for (int i = 1; i < (int)path.size(); i++) {
if (locked[path[i]] != -1) return false;
}
// All pass β mark node as locked
locked[node] = uid;
// Release all locks
ancestorLocks.clear();
targetLock.unlock();
// Now propagate UP with write locks (separate phase)
// This creates a brief window β acceptable trade-off for parallelism
cur = par[node];
while (cur != -1) {
unique_lock<shared_mutex> wl(nodeRWLock[cur]);
lockedDesc[cur].insert(node);
cur = par[cur];
}
return true;
}π When to Use Read-Write Locks vs Plain Mutex
| Scenario | Read-Write Lock | Plain Mutex |
|---|---|---|
| Many readers, few writers | β Better | β Over-locks |
| Mostly writes | β Extra overhead | β Simpler |
| Short critical sections | Either works | β Simpler |
| Tree ancestor checks | β Multiple threads can check | β Serialize checks |
For Tree of Space:
- Ancestor checks (reading
locked[ancestor]) = READ operation β shared lock OK - Locking a node (writing
locked[node] = uid) = WRITE operation β exclusive lock needed - Propagating up (inserting into
lockedDesc) = WRITE operation β needs upgrade
β Java Version with ReentrantReadWriteLock
import java.util.concurrent.locks.*;
import java.util.*;
class Node {
String name;
int lockedBy = -1;
Node parent;
List<Node> children = new ArrayList<>();
Set<Node> lockedDescendants = new HashSet<>();
ReadWriteLock rwLock = new ReentrantReadWriteLock();
Node(String name, Node parent) {
this.name = name;
this.parent = parent;
}
}
class TreeOfSpace {
Map<String, Node> nodeMap = new HashMap<>();
boolean lock(String name, int uid) {
Node node = nodeMap.get(name);
// Write lock on target
node.rwLock.writeLock().lock();
// Read locks on ancestors
List<Node> ancestors = new ArrayList<>();
Node cur = node.parent;
while (cur != null) {
cur.rwLock.readLock().lock();
ancestors.add(cur);
cur = cur.parent;
}
try {
if (node.lockedBy != -1) return false;
if (!node.lockedDescendants.isEmpty()) return false;
for (Node anc : ancestors) {
if (anc.lockedBy != -1) return false;
}
node.lockedBy = uid;
return true;
} finally {
// Release all locks
for (Node anc : ancestors) {
anc.rwLock.readLock().unlock();
}
node.rwLock.writeLock().unlock();
// If successful, propagate (with write locks)
// (handled separately to avoid holding all locks)
}
}
}π What to Say to the Interviewer
"I can improve parallelism using read-write locks. When Thread 1 locks China and Thread 2 locks India, both need to CHECK if their common ancestor Asia is locked. With a plain mutex, one thread blocks the other. With a read-write lock, both acquire SHARED READ locks on Asia simultaneously β they're both just reading, so there's no conflict.
The target node gets an EXCLUSIVE WRITE lock (since we're modifying its state). Ancestors get SHARED READ locks for checking. For propagating up (inserting into ancestor sets), we need to upgrade to write locks β this is the tricky part since C++'s
shared_mutexdoesn't support atomic upgrade.The trade-off: better parallelism for ancestor checks, but more complexity in the propagation phase."