09_read_write_locks.md

πŸ“– 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 heldWrite 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:

TypeLock ModePurpose
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 lockedBy and 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

ScenarioRead-Write LockPlain Mutex
Many readers, few writersβœ… Better❌ Over-locks
Mostly writes❌ Extra overheadβœ… Simpler
Short critical sectionsEither 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_mutex doesn't support atomic upgrade.

The trade-off: better parallelism for ancestor checks, but more complexity in the propagation phase."