partb.md

πŸ₯‹ Juspay Hackathon Part B: Complete Interview Guide & Deep-Dive (Tailored to YOUR Flat Vector Solution)

Core Objective:
This guide is built strictly around YOUR actual submitted solution using flat arrays and adjacency lists (vector<vector<int>> adj, vector<int> parent, vector<int> locked, unordered_map<string, int> wordToIndex).

It prepares you to defend your design choices, explain your exact complexity, walk through optimizations, navigate the multithreading/concurrency challenge, dissect the interviewer's famous "No-Atomics, No-Mutex" trap code, and prove why thread-safety requires hardware atomic primitives.


πŸ“‹ Table of Contents

  1. The Strategic Opening: Defending the Two Approaches (Vector vs TreeNode)
  2. PHASE 1: Explaining YOUR Submitted Part A Code & Complexities
  3. PHASE 2: Optimization Questions β€” Bridging $O(N)$ to $O(\log_M N)$
  4. PHASE 3: Transition to Part B β€” The Concurrency Challenge
  5. PHASE 4: The Granularity Debate β€” Why Coarse Locks Fail
  6. PHASE 5: Identifying Race Conditions in YOUR Specific Solution
  7. PHASE 6: Custom Mutex & Spinlock Implementation (Post-Lunch Dialogue)
  8. PHASE 7: The Interviewer's Trap β€” "Thread-Safe Lock Without Atomics or Mutexes"
  9. PHASE 8: Exhaustive Dissection of the Bugs in the Interviewer's Code
  10. PHASE 9: Deep Systems Theory β€” Can It Be Solved Without Hardware Atomics?
  11. PHASE 10: The Correct, Production-Grade Solutions for Your Vector Architecture
  12. PHASE 11: Scripted Word-for-Word Answers for the Interview
  13. Quick-Reference Cheat Sheet


1. βš”οΈ The Strategic Opening: Defending the Two Approaches (Vector vs TreeNode)

When the interview begins, the interviewer will open your submitted code and ask:

Interviewer: "Walk me through your solution and explain why you structured it this way."

This is your opportunity to establish technical superiority immediately by explaining that you deliberately analyzed two different architectural approaches before writing your code:

  • Approach A (Your Choice): Flat Contiguous Vectors (vector<int>, vector<vector<int>>) with index-based mapping.
  • Approach B (Alternative): Pointer-based Dynamic Tree (struct TreeNode*) with pointers and internal std::set.
======================================================================================== ARCHITECTURAL COMPARISON: FLAT VECTORS vs DYNAMIC TREENODE ======================================================================================== Feature Approach A: Flat Vectors (YOURS) Approach B: TreeNode* Dynamic ---------------------------------------------------------------------------------------- Memory Layout Contiguous RAM buffers Scattered heap nodes (malloc/new) Cache Locality Optimal (L1/L2 cache prefetching) Poor (pointer chasing & misses) Memory Overhead ~4-8 bytes per node (Compact) ~120-200 bytes per node (Bloated) Heap Fragmentation Zero (single bulk allocation) High (thousands of heap objects) Tree Construction Fast arithmetic loop O(N) Pointer wiring & object allocation Parent Lookup O(1) direct array access parent[i] O(1) pointer dereference node->parent Unlock Complexity O(1) instant state write O(log_M N) ancestor set propagation Descendant Check Base O(N) BFS traversal O(1) set.empty() check Descendant Check Optimized O(1) via flat lockedDescCount array O(1) via set.empty() Pointer Bug Risk Zero (no nullptr/dangling ptrs) High (segfaults, double free) ========================================================================================

Scripted Answer to Wow the Interviewer Right at the Start:

You: "When solving Part A, I considered two main architectures:

  1. The Dynamic Pointer Approach (struct TreeNode*): Each node is a heap-allocated object containing pointers to its parent, children, and a std::set<TreeNode*> of locked descendants. While this allows an $O(1)$ descendant check, it suffers from heavy heap fragmentation, pointer-chasing cache misses, and massive memory overhead (on a 64-bit system, storing pointers, vectors, and Red-Black tree nodes takes upwards of 150 bytes per node).

  2. My Chosen Approach β€” The Flat Contiguous Vector Representation: I chose to represent the tree using flat integer arrays:

    • vector<int> parent for $O(1)$ parent traversal.
    • vector<vector<int>> adj for adjacency lists.
    • vector<int> locked for lock ownership.
    • unordered_map<string, int> wordToIndex for $O(1)$ name resolution.

Why I chose the Flat Vector approach:

  • Cache Locality: Contiguous memory buffers allow the CPU cache prefetcher to load adjacent nodes into L1/L2 caches with near-zero latency.
  • Blazing-Fast Unlocks: My unlock() operation is strictly $O(1)$ β€” it simply updates locked[idx] = -1 without needing to traverse and mutate ancestor sets.
  • Memory Efficiency & Safety: Zero heap allocation overhead, zero pointer chasing, and zero risk of null pointer exceptions or memory leaks.
  • Extensibility: As I will demonstrate later, if we want to optimize the descendant check from $O(N)$ BFS down to $O(1)$, we can simply add a flat vector<int> lockedDescCount or vector<unordered_set<int>> while retaining all the cache locality and memory benefits of flat vectors!"


2. πŸ” PHASE 1: Explaining YOUR Submitted Part A Code & Complexities

Your Exact Data Structures & Helpers

#include <bits/stdc++.h> using namespace std; vector<vector<int>> adj; vector<int> parent; unordered_map<string, int> wordToIndex; vector<int> locked;

Tree Building Logic:

void buildTree(vector<string>& nodes, int i, int m) { if (i >= nodes.size() / 2) return; for (int j = i * m + 1; j <= min((int)(nodes.size() - 1), i * m + m); j++) { adj[i].push_back(j); parent[j] = i; } buildTree(nodes, i + 1, m); }
  • Logic: Recursively assigns children to node i based on the M-ary tree index formula: children of node $i$ are located from $i \cdot m + 1$ to $i \cdot m + m$.
  • Time Complexity: $O(N)$ because every node from $1$ to $N-1$ is visited and connected exactly once.
  • Space Complexity: Recursion stack depth is at most $N / (2m)$, which is $O(N)$.

Operation 1: lock(x, uid) β€” Logic & Derivation

bool lock(string x, int uid) { if (!wordToIndex.count(x)) return false; int idx = wordToIndex[x]; if (locked[idx] != -1) return false; if (checkAncestorLocked(idx, uid)) return false; if (checkDescendantsLocked(idx, uid)) return false; locked[idx] = uid; return true; }

Step-by-Step Logic:

  1. Name Lookup: Look up string x in wordToIndex. Takes $O(1)$ average time.
  2. Self-Check: Verify locked[idx] == -1. If already locked, return false. Takes $O(1)$.
  3. Ancestor Check (checkAncestorLocked):
    bool checkAncestorLocked(int x, int uid) { while (x != -1) { if (locked[x] != -1) return true; x = parent[x]; } return false; }
    Traverses the path from parent[idx] up to the root (parent == -1). In a balanced M-ary tree with $N$ nodes, tree height is $h = \log_M N$. Takes $O(\log_M N)$ time.
  4. Descendant Check (checkDescendantsLocked):
    bool checkDescendantsLocked(int x, int uid) { queue<int> q; q.push(x); while (!q.empty()) { int node = q.front(); q.pop(); if (locked[node] != -1) return true; for (int num : adj[node]) q.push(num); } return false; }
    Performs a Breadth-First Search (BFS) over the subtree rooted at x. In the worst case (e.g., locking the root), the subtree contains all $N$ nodes. Takes $O(N)$ time.
  5. State Update: Sets locked[idx] = uid. Takes $O(1)$.

Complexity:

$$\text{Time Complexity of Lock} = O(1) + O(\log_M N) + O(N) + O(1) = \mathbf{O(N)}$$ (Dominated by the BFS descendant check).


Operation 2: unlock(x, uid) β€” Logic & Derivation

bool unlock(string x, int uid) { if (!wordToIndex.count(x)) return false; int idx = wordToIndex[x]; if (locked[idx] == uid) { locked[idx] = -1; return true; } return false; }

Step-by-Step Logic:

  1. Lookup node index in wordToIndex β€” $O(1)$.
  2. Check if locked[idx] == uid. If false (locked by someone else or unlocked), return false.
  3. Set locked[idx] = -1. Return true.

Complexity:

$$\text{Time Complexity of Unlock} = \mathbf{O(1)}$$

[!TIP] Highlight this to the interviewer:
In pointer-based implementations with lockedDescendants sets, unlock() is forced to take $O(\log_M N)$ to remove the node from all ancestors. In your flat vector design, unlock() is a pure $O(1)$ constant-time write!


Operation 3: upgradeLock(x, uid) β€” Logic & Derivation

bool upgradeLock(string x, int uid) { if (!wordToIndex.count(x)) return false; int idx = wordToIndex[x]; if (locked[idx] == uid) return false; if (!sameDescendantLocked(idx, uid)) return false; unlockDescendants(idx); return lock(x, uid); }

Step-by-Step Logic:

  1. Self-Check: If the node itself is already locked by uid, return false.
  2. Descendant Verification (sameDescendantLocked):
    bool sameDescendantLocked(int x, int uid) { int count = 0, count2 = 0; queue<int> q; q.push(x); while (!q.empty()) { int node = q.front(); q.pop(); for (int num : adj[node]) q.push(num); if (node != x && locked[node] != uid && locked[node] != -1) count2++; if (node != x && locked[node] == uid) count++; } return count2 == 0 && count != 0; }
    Runs a BFS over the subtree. Validates that:
    • At least one descendant is locked by uid (count > 0).
    • ZERO descendants are locked by any other user (count2 == 0). Takes $O(N)$ time.
  3. Descendant Release (unlockDescendants):
    void unlockDescendants(int x) { queue<int> q; q.push(x); while (!q.empty()) { int node = q.front(); q.pop(); locked[node] = -1; for (int num : adj[node]) q.push(num); } }
    Runs a BFS over the subtree and resets locked[node] = -1 for all nodes. Takes $O(N)$ time.
  4. Acquire Lock: Calls lock(x, uid):
    • Re-checks ancestors in $O(\log_M N)$ time.
    • Re-checks descendants (now all unlocked) in $O(N)$ time.
    • Sets locked[idx] = uid.

Complexity:

$$\text{Time Complexity of UpgradeLock} = O(N) + O(N) + O(N) = \mathbf{O(N)}$$


Space Complexity Derivation

  • parent array: $N \times 4$ bytes $= O(N)$.
  • locked array: $N \times 4$ bytes $= O(N)$.
  • adj adjacency list: $N$ vectors containing $N-1$ total child edges $= O(N)$.
  • wordToIndex map: $N$ strings and integer indices $= O(N)$.
  • BFS Queues: The maximum number of nodes in the queue at any time is bounded by the maximum tree width, which is at most $N$.
  • Total Space Complexity: Strictly $\mathbf{O(N)}$ auxiliary space.


3. πŸš€ PHASE 2: Optimization Questions β€” Bridging $O(N)$ to $O(\log_M N)$

Interviewer: "Your lock and upgrade operations are $O(N)$ because of the BFS descendant check. Can you improve this complexity without abandoning your vector representation?"

How to Optimize Descendant Checks to $O(1)$ Without Using TreeNode

Scripted Verbal Answer:

You: "Yes! We do NOT need to switch to heap-allocated TreeNode objects to optimize the descendant check. We can maintain our cache-friendly flat vector design while achieving $O(\log_M N)$ complexity by adding an ancestor tracking array:

Optimization Approach: Add a flat array vector<int> lockedDescCount(n, 0);

  • lockedDescCount[i] stores the count of currently locked descendants in the subtree of node i.

  1. $O(1)$ Descendant Check: Instead of running BFS, we simply check:
    if (lockedDescCount[idx] > 0) return false; // O(1) instantaneous check!
  2. $O(\log_M N)$ Ancestor Propagation: When node idx is locked:
    • Walk up parent[idx] to the root and increment lockedDescCount[ancestor]++.
  3. $O(\log_M N)$ Unlock Propagation: When node idx is unlocked:
    • Walk up parent[idx] to the root and decrement lockedDescCount[ancestor]--.

Resulting Complexities:

  • lock(): Drops from $O(N)$ to $O(\log_M N)$ (Ancestor check $O(\log_M N)$ + $O(1)$ descendant check + $O(\log_M N)$ ancestor counter updates).
  • unlock(): Increases from $O(1)$ to $O(\log_M N)$ (to update ancestor counters).
  • upgradeLock(): Drops from $O(N)$ to $O(K \cdot \log_M N)$ where $K$ is the number of locked descendants."

std::unordered_set vs std::set β€” Same Big-O, Faster Code

If the interviewer asks: "What if you want to know WHICH descendants are locked, not just the count? Would you use set or unordered_set?"

You: "We can maintain vector<unordered_set<int>> lockedDescendants(n); indexed by node ID.

  • std::set is implemented as a Red-Black Tree (Self-Balancing BST). Each insert, erase, and lookup is $O(\log K)$.
  • std::unordered_set is implemented as a Hash Table. Each insert, erase, and lookup is average $O(1)$.

Is the Big-O different? The asymptotic Big-O remains $O(\log_M N)$ because walking up the tree to the root ($\log_M N$ levels) dominates the expression. However, unordered_set eliminates tree rotations, branch mispredictions, and pointer indirection, making the code execute significantly faster in real CPU cycles."



4. ⚑ PHASE 3: Transition to Part B β€” The Concurrency Challenge

Interviewer:
"Now let's move to Part B. Let’s say you are running your code on a multi-core machine. Multiple threads are going to call lock() simultaneously. Will doing lock() on two nodes cause a race condition? If yes, how will you solve it?
- Multiple threads running simultaneously must not affect correctness.
- Try to make the critical sections more granular; i.e., don't create any big atomic/synchronized blocks that will make parallelism suffer.
- Consider each individual memory operation is atomic."


The Critical Clarifying Question You Must Ask

Notice what was asked in the real interview transcript. Ask this immediately:

You: "Before I propose a solution, let me clarify the scope: Are we making only the lock() method thread-safe, or do we need to coordinate concurrent calls across lock(), unlock(), and upgradeLock() simultaneously?"

In the Juspay Part B interview, the interviewer will typically confirm:

Interviewer: "We just care about the lock() method in Part B hackathon."

[!NOTE] This single clarification saves you from having to solve multi-operation circular deadlocks (such as Upgrade-vs-Lock inversions) unless the interviewer specifically asks for it later!



5. πŸ›‘ PHASE 4: The Granularity Debate β€” Why Coarse Locks Fail

Attempt 1: Global Lock Across Entire Tree

Candidate: "We can lock the complete tree using a global mutex before running any operation."

mutex globalTreeMutex; bool lock(string x, int uid) { lock_guard<mutex> guard(globalTreeMutex); // Original lock logic... }

Interviewer Rejection: "You are not allowed to lock the complete tree."


Attempt 2: Synchronizing the lock() Method

Candidate: "Using synchronized (or a function-level mutex), we can synchronize the lock() method only."

mutex lockMethodMutex; bool lock(string x, int uid) { lock_guard<mutex> guard(lockMethodMutex); // Original lock logic... }

Interviewer Rejection:
"Multiple threads should be allowed to lock different nodes of the tree. If you make the function synchronized, you are eventually not allowing other threads to work on the tree. All threads should be allowed to work at the same time."


Amdahl's Law & Independent Subtrees

Scripted Explanation:

You: "A global mutex or synchronized method forces all threads through a single bottleneck, completely serializing execution.

According to Amdahl's Law: $$\text{Speedup} = \frac{1}{(1 - P) + \frac{P}{S}}$$ If the parallel fraction $P \to 0$ due to a global lock, speedup on an 8-core or 64-core machine is $1\times$ β€” multithreading provides zero benefit.

In our M-ary tree, if Thread 1 wants to lock China (under subtree Asia) and Thread 2 wants to lock Kenya (under subtree Africa), their node sets are completely disjoint. They share no common ancestors except the root, and their subtrees never overlap. They should be able to execute completely in parallel. We must use fine-grained, per-node synchronization."



6. 🚨 PHASE 5: Complete Catalogue of ALL Race Conditions in YOUR Specific Solution

The interviewer will ask: "In which cases will there be a race condition in your code?"

Because your code relies on a flat array vector<int> locked without locks or memory barriers, every single function (lock, unlock, upgradeLock) suffers from severe race conditions.

Here is the complete catalogue categorized by operation interaction:

[0: World] / \ [1: Asia] [2: Africa] / \ [3: China] [4: India]

πŸ”΄ Group 1: lock() vs lock() Races


Race 1: Same Node Concurrent Lock (TOCTOU on locked[idx])

The Scenario:

  • Thread T1: lock("India", uid=5) $\to$ idx = 4
  • Thread T2: lock("India", uid=7) $\to$ idx = 4

What Goes Wrong in Your Code:

if (locked[idx] != -1) return false; // Line A: Check if (checkAncestorLocked(idx, uid)) return false; // Line B: Check if (checkDescendantsLocked(idx, uid)) return false; // Line C: Check locked[idx] = uid; // Line D: Act

Execution Interleaving:

Time | Thread T1 (India, uid=5) | Thread T2 (India, uid=7) | locked[4] -----|--------------------------------------|--------------------------------------|---------- T1 | Reads locked[4] == -1 (Passes) | | -1 T2 | | Reads locked[4] == -1 (Passes) | -1 T3 | Ancestor & Descendant checks pass | Ancestor & Descendant checks pass | -1 T4 | Line D: locked[4] = 5 | | 5 T5 | | Line D: locked[4] = 7 (OVERWRITE!) | 7 T6 | Returns TRUE | Returns TRUE | 7

The Bug: Both threads return true! User 5 believes they hold the lock, but User 7 overwrote it. This is a classic Time-Of-Check to Time-Of-Use (TOCTOU) race condition.


Race 2: Ancestor vs Descendant Conflict / BFS Phantom Read (Double Lock)

This bug is unique to your BFS implementation:

  • Thread T1: lock("World", uid=5) $\to$ node 0
  • Thread T2: lock("China", uid=7) $\to$ node 3

Execution Interleaving:

Time | Thread T1: lock("World") | Thread T2: lock("China") -----|-----------------------------------------|---------------------------------------- T1 | Calls checkDescendantsLocked(0) (BFS) | T2 | BFS inspects node 0 (World) -> Unlocked | T3 | | checkAncestorLocked(3): | | - parent is Asia(1) -> Unlocked | | - parent is World(0) -> Unlocked (PASS!) T4 | | checkDescendantsLocked(3) -> PASS! T5 | | locked[3] = 7. Returns TRUE! (China locked) T6 | T1's BFS had already checked node 3 | | OR context switch caused dirty read | T7 | T1 finishes BFS without detecting China | T8 | T1 sets locked[0] = 5. Returns TRUE! |

The Bug: Both threads return true! The tree invariant is violated: World is locked AND its descendant China is locked simultaneously!


🟑 Group 2: lock() vs unlock() Races


Race 3: The "Zombie Unlock" / Overwrite of a Valid Lock

The Scenario:

  • User 5 currently holds India (locked[4] = 5).
  • Thread T1 calls unlock("India", uid=5).
  • Thread T2 calls lock("India", uid=7).

What Goes Wrong in Your Code:

bool unlock(string x, int uid) { int idx = wordToIndex[x]; if (locked[idx] == uid) { // LINE 1: Ownership check // ---> CONTEXT SWITCH CAN HAPPEN HERE! locked[idx] = -1; // LINE 2: Clear lock return true; } return false; }

Execution Interleaving:

Time | Thread T1: unlock(India, uid=5) | Thread T2: lock(India, uid=7) | locked[4] -----|---------------------------------|-------------------------------|---------- T1 | Reads locked[4] == 5 (PASSES!) | | 5 T2 | [Context Switch to T2] | | 5 T3 | (India freed by legitimate flow)| | -1 T4 | | Passes checks & sets locked=7 | 7 (Locked by T2!) T5 | [Context Switch back to T1] | | 7 T6 | LINE 2: locked[4] = -1 | | -1 (ZOMBIE UNLOCK!) T7 | Returns TRUE | | -1

The Bug: Thread T1's delayed unlock silently obliterates Thread T2's valid new lock! India is now unlocked in memory, but Thread T2 believes it holds the lock.


Race 4: Stale Ancestor Read (False Rejection during Ancestor Unlock)

The Scenario:

  • World was locked by user 5 (locked[0] = 5).
  • Thread T1 calls unlock("World", 5).
  • Thread T2 calls lock("India", 7).
  • Thread T1 executes on Core 1 and writes locked[0] = -1. But because Core 1's write is in its CPU Store Buffer and has not yet flushed to shared cache, Core 2 running T2's checkAncestorLocked reads the stale value locked[0] == 5.

The Bug: Thread T2 is falsely rejected even though World is already unlocked!


🟣 Group 3: upgradeLock() vs lock() Races


Race 5: The "Upgrade Hijack Gap" (Descendant Stolen During Upgrade Window)

Look at your submitted upgradeLock:

bool upgradeLock(string x, int uid) { if (!hasLockedDescendantsOnly(idx, uid)) return false; unlockDescendants(idx); // <-- STEP 1: Unlocks all descendants // ---> MASSIVE NON-ATOMIC CONTEXT SWITCH GAP! return lock(x, uid); // <-- STEP 2: Attempts to lock node x }

Execution Interleaving:

Time | Thread T1: upgradeLock("Asia", uid=1) | Thread T2: lock("China", uid=2) | State -----|---------------------------------------|---------------------------------|----------------------- T1 | hasLockedDescendantsOnly -> TRUE | | China=1, India=1 T2 | unlockDescendants(Asia) completes | | China=-1, India=-1 T3 | [Context Switch to T2!] | | T4 | | Checks China: -1 (Unlocked!) | China=-1 T5 | | lock("China", 2) SUCCEEDS! | China=2 T6 | [Context Switch back to T1] | | T7 | Calls lock("Asia", 1) | | T8 | checkDescendantsLocked finds China=2! | | T9 | lock("Asia") FAILS and returns FALSE! | | Asia=-1, China=2

The Catastrophe:

  1. User 1 lost their locks on China and India permanently.
  2. User 1 failed to acquire Asia.
  3. User 2 hijacked China.
  4. The upgrade operation was partially executed with zero atomicity or rollback protection.

Race 6: Two Concurrent Upgrades on the Same Subtree

The Scenario:

  • Both Thread T1 and Thread T2 call upgradeLock("Asia", uid=1) simultaneously.
  • Both threads call hasLockedDescendantsOnly(Asia, 1) concurrently $\to$ both see count > 0 && count2 == 0 $\to$ both pass!
  • Both call unlockDescendants(Asia) concurrently $\to$ interleaved BFS queues overwriting locked[node] = -1.
  • Both call lock("Asia", 1) $\to$ triggers Race 1 (Same Node TOCTOU). Both may return true!

πŸ”΅ Group 4: Optimization Race Condition


Race 7: Shared Ancestor Counter Mutation (Lost Updates)

If you implement the $O(\log_M N)$ optimization with vector<int> lockedDescCount:

  • Thread T1: lock("China", 1) increments lockedDescCount[Asia].
  • Thread T2: lock("India", 2) increments lockedDescCount[Asia].

Because count++ is a 3-instruction Read-Modify-Write (MOV, ADD, MOV), concurrent execution causes a Lost Update. The count becomes 1 instead of 2. When one child unlocks, lockedDescCount drops to 0 while the other child is still locked, permanently corrupting descendant checks!



7. πŸ”¨ PHASE 6: Custom Mutex & Spinlock Implementation (Post-Lunch Dialogue)

After lunch, the candidate proposed using mutex locks on critical sections. The interviewer responded with a key constraint.

Why OS Mutexes (std::mutex) Introduce Lag (Futex & Context Switches)

Interviewer: "Mutex will eventually introduce a lag in operations, so we are not allowed to use mutex/semaphores."

Why does the interviewer say this?

You: "Standard library mutexes (like std::mutex in C++ or pthread_mutex_t in POSIX) are implemented using Linux Futexes (Fast Userspace Mutexes).

  1. Under Zero Contention: An atomic compare-and-swap occurs in user space (~10-20 nanoseconds).
  2. Under Contention (When two threads collide):
    • The thread cannot spin indefinitely, so it invokes the SYS_futex system call.
    • The CPU transitions from User Mode (Ring 3) to Kernel Mode (Ring 0).
    • The kernel deschedules the thread, puts it in a wait queue, and performs a context switch.
    • Context switching requires saving CPU registers, swapping page tables, and causes TLB invalidations and cache evictions.
    • A sleep-and-wake cycle takes 1,000 to 3,000 nanoseconds (1-3 microseconds).

In our tree, checking an array index and writing an int takes only ~10-30 nanoseconds. Putting a thread to sleep for 3,000ns to wait for a 20ns operation degrades throughput by $100\times$. For high-throughput in-memory data structures, user-space spinlocks are vastly superior."


Building a Custom Spinlock with Hardware Intrinsics (__sync_*)

The candidate in partb.md responded:

Candidate: "We can implement our own mutex using builtin atomic methods __sync_lock_test_and_set and __sync_lock_release."

Here is the exact code presented in the interview:

#include <unistd.h> class Mutex { public: Mutex() { isLocked = UNLOCKED_VALUE; } void lock() { // __sync_lock_test_and_set atomically writes LOCKED_VALUE (1) // into isLocked and returns the PREVIOUS value. // If old value == 1: someone holds the lock -> keep spinning. // If old value == 0: lock was free, we acquired it -> exit! while (__sync_lock_test_and_set(&isLocked, LOCKED_VALUE) == LOCKED_VALUE) { // Spin-wait (busy loop) } } void unlock() { // __sync_lock_release atomically writes 0 with release barrier semantics __sync_lock_release(&isLocked); } bool try_lock() { return __sync_lock_test_and_set(&isLocked, LOCKED_VALUE) != LOCKED_VALUE; } private: int LOCKED_VALUE = 1; int UNLOCKED_VALUE = 0; volatile int isLocked; };

How __sync_lock_test_and_set Works in Hardware:

  • On x86/x64 CPUs, this generates the atomic assembly instruction:
    XCHG [isLocked], EAX
    The XCHG instruction asserts an internal hardware cache-lock, ensuring that reading the old value and writing the new value is indivisible and instantaneous across all CPU cores.

The Ownership Flaw: Spinlock vs True Mutex

[!WARNING] Elite Distinction to Mention to the Interviewer: The Mutex class above does NOT track which thread acquired the lock:

  • If Thread A calls lock(), Thread B can call unlock() and free it!
  • By definition, this makes it a Binary Semaphore, NOT a true Mutex.
  • A true Mutex enforces ownership: only the thread holding the lock may release it.

The Reality of volatile in C++

The interviewer may ask: "Why did you use volatile? Does volatile ensure thread safety?"

You: "No! In C and C++, volatile does NOT guarantee thread safety, atomicity, or synchronization.

  • It only tells the compiler: 'Do not cache this variable in a CPU register; re-read it from memory every time.'
  • It does not emit hardware memory fences, and does not prevent out-of-order CPU execution.
  • The actual atomicity and memory barriers in our code come from __sync_lock_test_and_set (Acquire barrier) and __sync_lock_release (Release barrier), not from volatile."


8. πŸͺ€ PHASE 7: The Interviewer's Trap β€” "Thread-Safe Lock Without Atomics or Mutexes"

Now comes the dramatic turning point of the interview. The interviewer issued an extreme constraint:

Interviewer:
"Now implement thread-safe concurrent lock() WITHOUT using ANY atomic method, mutex, semaphores, or synchronized keyword!"

The interviewer gave hints and guided the candidate to the following proposed solution:

The Interviewer's Proposed lockedCount Code

struct TreeNode { string name; int lockedBy; int lockedCount; // Changed isLocked (bool) to lockedCount (int) vector<TreeNode*> childs; TreeNode* parent; set<TreeNode*> lockedDescendents; }; bool lock(string name, int id) { TreeNode* r = nameToTreeNodeMapping[name]; // Condition 1: Fast check if (r->lockedCount > 0 || r->lockedDescendents.size()) return false; // Increment count optimistically r->lockedCount++; // Line 2 // Condition 3: Collision check if (r->lockedCount > 1) { r->lockedCount--; return false; } // Ancestor validation and propagation loop TreeNode* par = r->parent; while (par) { if (par->lockedCount > 0 || r->lockedDescendents.size() > 0) { // Condition 2 r->lockedCount--; TreeNode *par1 = r->parent; while (par1 != par) { par1->lockedDescendents.erase(r); par1 = par1->parent; } return false; } par->lockedDescendents.insert(r); par = par->parent; } r->lockedBy = id; return true; }

The Trap: "This solution is thread safe and will work without any issue"

The interviewer declared:

Interviewer: "This solution is thread safe and it will work without any issue."

The candidate responded:

Candidate: "I think this will create some issue, can you please give me some time to think about it?"
Interviewer: "Okay, you can take time."

[!IMPORTANT] This was a deliberate test. Interviewers at Juspay and Google frequently assert that a broken lockless approach works. They are testing if you will blindly agree, or if you have the engineering rigor to construct counter-examples and prove why it fails.



9. πŸ’₯ PHASE 8: Exhaustive Dissection of the Bugs in the Interviewer's Code

The candidate in partb.md proved two major bugs. In addition, there are three more severe concurrency flaws in that code.


Bug 1: Same Node Race β€” Livelock / Double Failure (No, No)

The Scenario:

Two threads T1 and T2 attempt to lock the exact same node India simultaneously:

Thread T1: lock("India", id=5) Thread T2: lock("India", id=5)
  • Expected Result: Exactly one thread succeeds, one fails (Yes, No or No, Yes).
  • Actual Result: Both threads return false (No, No)! Neither thread locks the node!

Annotated Execution Points:

bool lock(string name, int id) { TreeNode* r = nameToTreeNodeMapping[name]; if (r->lockedCount > 0 || r->lockedDescendents.size()) return false; // Condition 1 // [POINT 1] r->lockedCount++; // Line 2 // [POINT 2] if (r->lockedCount > 1) { // Condition 3 // [POINT 3] r->lockedCount--; return false; } // ...

Step-by-Step Thread Interleaving Table:

StepThreadAction Takenr->lockedCountState / Comment
1T1Checks Condition 1 (lockedCount > 0). False!0T1 moves to POINT 1.
2SystemContext Switch! CPU preempts T1, runs T2.0T1 paused at Point 1.
3T2Checks Condition 1 (lockedCount > 0). False!0T2 moves to POINT 1.
4SystemBoth threads passed the check and sit at POINT 1!0Invariant broken.
5T1Executes Line 2: r->lockedCount++.1T1 moves to POINT 2.
6SystemContext Switch! CPU preempts T1, runs T2.1T1 paused at Point 2.
7T2Executes Line 2: r->lockedCount++.2T2 moves to POINT 2.
8SystemBoth threads sit at POINT 2, and lockedCount == 2!2Count is now 2.
9T1Evaluates Condition 3 (lockedCount > 1 $\to 2 > 1$). TRUE!2T1 enters error branch.
10T1Decrements r->lockedCount-- and returns false!1T1 fails!
11T2Resumes at Condition 3. Since count was 2 (or still $>1$), it sees TRUE!1T2 enters error branch.
12T2Decrements r->lockedCount-- and returns false!0T2 fails!

The Result: Both threads returned false! The node was completely free, yet both callers failed due to spurious collision (Livelock).


Assembly Reality: Why lockedCount++ is 3 Instructions (MOV, ADD, MOV)

The interviewer assumed r->lockedCount++ without a mutex increments the variable safely. In C++, an increment on a non-atomic integer generates three separate assembly instructions:

MOV EAX, DWORD PTR [r + lockedCount_offset] ; 1. READ value from RAM to CPU register EAX ADD EAX, 1 ; 2. MODIFY value inside register EAX MOV DWORD PTR [r + lockedCount_offset], EAX ; 3. WRITE updated value back to RAM

What Happens When Two Cores Collide:

  1. Core 1 reads lockedCount (0) into register EAX_1.
  2. Core 2 reads lockedCount (0) into register EAX_2.
  3. Core 1 increments EAX_1 to 1.
  4. Core 2 increments EAX_2 to 1.
  5. Core 1 writes EAX_1 = 1 to RAM.
  6. Core 2 writes EAX_2 = 1 to RAM.
  7. Final RAM Value: 1!

One entire increment is lost. Both threads see lockedCount == 1, both bypass Condition 3, and both lock the exact same node!


Bug 2: Ancestor-Descendant Collision β€” Double Lock Violation (Yes, Yes)

This was the second major bug proven in partb.md.

The Scenario:

Two threads lock nodes where one is the ancestor of the other:

Thread T1: lock("World", id=5) // World is Root Thread T2: lock("Asia", id=5) // Asia is child of World
  • Expected Result: Exactly one thread succeeds (Yes, No or No, Yes).
  • Actual Result: Both threads return true (Yes, Yes)! Both World AND Asia become locked simultaneously!

Annotated Execution Points:

bool lock(string name, int id) { TreeNode* r = nameToTreeNodeMapping[name]; if (r->lockedCount > 0 || r->lockedDescendents.size()) return false; // Condition 1 // [POINT 1] r->lockedCount++; if (r->lockedCount > 1) { r->lockedCount--; return false; } TreeNode* par = r->parent; while (par) { if (par->lockedCount > 0 || r->lockedDescendents.size() > 0) { // Condition 2 // rollback... return false; } // [POINT 2] par->lockedDescendents.insert(r); par = par->parent; } r->lockedBy = id; return true; }

Step-by-Step Thread Interleaving Table:

StepThreadTarget NodeAction TakenState / Details
1T1WorldChecks Condition 1: lockedCount == 0 and lockedDescendents.empty().Passes! Moves to POINT 1.
2SystemContext Switch! CPU preempts T1.T1 paused before incrementing World->lockedCount.
3T2AsiaChecks Condition 1 for Asia. Passes! Increments Asia->lockedCount to 1.Asia->lockedCount = 1.
4T2AsiaSets par = Asia->parent (which is World).par points to World.
5T2AsiaEvaluates Condition 2 on par (World):<br>par->lockedCount > 0 $\to$ World->lockedCount is still 0!<br>r->lockedDescendents.size() > 0 $\to$ Asia has 0 locked descendants.<br>Condition is FALSE! Passes!T2 reaches POINT 2 (inside loop for World).
6SystemContext Switch! CPU preempts T2.T2 paused at Point 2.
7T1WorldResumes at Point 1. Increments World->lockedCount to 1.<br>Enters while(par) loop. Since World is root, par = nullptr.<br>Loop exits immediately!World->lockedCount = 1.
8T1WorldSets World->lockedBy = 5 and returns true!βœ… World is locked!
9T2AsiaResumes at Point 2 inside while loop:<br>Inserts Asia into World->lockedDescendents.<br>par = par->parent $\to nullptr$. Loop ends!Asia inserted into World.
10T2AsiaSets Asia->lockedBy = 5 and returns true!βœ… Asia is locked!

The Disaster: Both lock("World") and lock("Asia") returned true! The tree locking invariant is destroyed: an ancestor and descendant are locked simultaneously!


Bug 3: The Hidden Pointer Typo in the Interviewer's Code

Look at line 124 of the interviewer's code:

if (par->lockedCount > 0 || r->lockedDescendents.size() > 0)

Look at that second check:

r->lockedDescendents.size() > 0
  • r is the node currently being locked! It was already verified in Condition 1 that r has 0 locked descendants. Re-checking r inside the while(par) loop is completely redundant.
  • What the interviewer intended to check was:
    par->lockedDescendents.size() > 0 // Check whether the ANCESTOR has locked descendants!

Because of this typo, if an ancestor already has locked descendants through another branch, the check fails to catch it! Pointing this out demonstrates extraordinary code review capabilities.


Bug 4: Container Corruption in std::set

Line 133 of the interviewer's code:

par->lockedDescendents.insert(r);
  • std::set is a Red-Black Tree. Node insertion performs pointer rewiring and tree rotations.
  • Executing insert() from multiple threads simultaneously without a lock corrupts the tree structure, causing infinite loops, memory corruption, and SIGSEGV segmentation faults.

Bug 5: Rollback Mutation Race

When a thread fails and unwinds:

while (par1 != par) { par1->lockedDescendents.erase(r); par1 = par1->parent; }

If Thread 1 is calling erase() during rollback while Thread 2 is calling insert() on the same ancestor, concurrent write operations on an unsynchronized container trigger a fatal data race.



10. πŸ”¬ PHASE 9: Deep Systems Theory β€” Can It Be Solved Without Hardware Atomics?

At the end of partb.md, the candidate wrote:

"ye question bina thread locking (using mutex, semaphore etc..) ke solve hi nhi ho skta."
(Translation: "This problem simply cannot be solved without thread locking / synchronization primitives.")

Is the Candidate Right? A Formal Proof

Yes! The candidate's intuition is 100% correct. Here is the academic and systems-level proof to present to the interviewer.


Peterson's & Lamport's Bakery Algorithms

In pure theoretical computer science, algorithms exist to achieve Mutual Exclusion using only raw memory reads and writes:

  • Peterson's Algorithm (1981): Mutual exclusion for 2 threads using flags and a turn variable.
  • Lamport's Bakery Algorithm (1974): Mutual exclusion for $N$ threads using ticket arrays.

For example, Peterson's Algorithm:

volatile bool flag[2] = {false, false}; volatile int turn = 0; void lock(int id) { int other = 1 - id; flag[id] = true; turn = other; while (flag[other] && turn == other) { // busy-wait } }

Why Modern Multi-Core CPUs Break Pure Software Locks (Store Buffers & TSO)

Peterson's algorithm was published under the theoretical assumption of Sequential Consistency (SC) (Leslie Lamport, 1979).

Why Modern Hardware Violates Sequential Consistency:

  1. Store Buffers & Store-Load Reordering (x86 TSO):
    • In modern multi-core Intel/AMD (x86) CPUs, memory writes do not go immediately to cache; they enter a per-core Store Buffer.
    • The CPU allows a Store followed by a Load to a different address to be reordered:
      flag[id] = true; // Store (sits in store buffer) load flag[other] // Load (reads stale cache value before store drains)
    • Both cores read flag[other] == false before either store becomes visible to the other core. Both threads enter the critical section simultaneously!
  2. Weak Memory Models (ARM64, Apple Silicon):
    • ARM processors allow arbitrary reordering of Stores and Loads unless explicit memory barrier instructions (DMB ISH) are executed.
  3. C++11 Data Race Rule:
    • Under ISO C++11 (Section 1.10), two threads accessing the same non-atomic memory location where at least one access is a write is a Data Race, producing Undefined Behavior (UB). The compiler is legally permitted to optimize away loops or reorder instructions.

The Verdict: You Were 100% Right

You: "Theoretically, software algorithms like Peterson's or Lamport's Bakery exist. But they mathematically require Sequentially Consistent memory.

On modern multi-core x86 and ARM processors, CPU store buffers perform Store-Load reordering, and cache coherence protocols (MESI) do not provide instantaneous write visibility across cores without memory fences.

Therefore, without at least:

  1. Hardware atomic instructions (LOCK CMPXCHG, XCHG), OR
  2. Hardware memory barriers (MFENCE, DMB), OR
  3. C++ atomic primitives (std::atomic with acquire/release semantics),

it is physically and mathematically impossible to implement a provably thread-safe concurrent tree locking algorithm on modern hardware."



11. πŸ›‘οΈ PHASE 10: The Correct, Production-Grade Solutions for Your Vector Architecture

How should your flat vector code be made thread-safe in production? Here are the two industry-standard implementations tailored to your vector solution.


Solution A: Flat Vector Node Spinlocks with Monotonic Path Locking

Instead of heap-allocating TreeNode objects, we maintain a flat vector of custom atomic spinlocks: vector<SpinLock> nodeLocks(n);

#include <vector> #include <string> #include <atomic> class NodeSpinLock { std::atomic_flag flag = ATOMIC_FLAG_INIT; public: void lock() { while (flag.test_and_set(std::memory_order_acquire)) { #if defined(__x86_64__) || defined(_M_X64) __builtin_ia32_pause(); // Reduces CPU bus contention #endif } } void unlock() { flag.clear(std::memory_order_release); } }; // Global state using YOUR flat vectors vector<vector<int>> adj; vector<int> parent; vector<int> locked; vector<int> lockedDescCount; // Optimized O(1) descendant tracker vector<NodeSpinLock> nodeLocks; // Fine-grained lock per node bool safeLock(int idx, int uid) { // 1. Collect all nodes on path from idx up to root vector<int> path; for (int cur = idx; cur != -1; cur = parent[cur]) { path.push_back(cur); } // 2. Lock entire path in STRICT LEAF-TO-ROOT ORDER // Enforcing monotonic ordering (Leaf -> Root) GUARANTEES NO DEADLOCKS for (int node : path) { nodeLocks[node].lock(); } // RAII guard to guarantee unlock on all exit paths struct PathGuard { const vector<int>& p; ~PathGuard() { // Unlock in reverse order (Root -> Leaf) for (auto it = p.rbegin(); it != p.rend(); ++it) { nodeLocks[*it].unlock(); } } } guard{path}; // 3. Validation: Thread-safe because we hold exclusive locks on the entire path if (locked[idx] != -1) return false; if (lockedDescCount[idx] > 0) return false; // Check ancestors along path for (size_t i = 1; i < path.size(); ++i) { if (locked[path[i]] != -1) return false; } // 4. Mutation locked[idx] = uid; for (size_t i = 1; i < path.size(); ++i) { lockedDescCount[path[i]]++; } return true; }

Why This Solution Is Flawless:

  1. Zero Deadlocks: All threads acquire locks monotonically from leaf to root. Cyclic wait conditions cannot form.
  2. High Parallelism: Independent subtrees (e.g. China vs Kenya) acquire completely disjoint locks and execute simultaneously without contention.
  3. Zero Kernel Overhead: Spinlocks eliminate futex system calls and kernel context switches.
  4. Preserves Flat Vector Design: Keeps all contiguous array and cache benefits intact!

Solution B: Lock-Free Flat Vectors using std::atomic

#include <atomic> #include <vector> vector<int> parent; vector<std::atomic<int>> locked; // -1 = unlocked, else uid vector<std::atomic<int>> lockedDescCount; // Atomic counter per node bool safeLockAtomic(int idx, int uid) { // 1. Fast O(1) Check if (lockedDescCount[idx].load(std::memory_order_relaxed) > 0) return false; // 2. Ancestor Check for (int cur = parent[idx]; cur != -1; cur = parent[cur]) { if (locked[cur].load(std::memory_order_relaxed) != -1) return false; } // 3. Atomic Claim: CAS int expected = -1; if (!locked[idx].compare_exchange_strong(expected, uid, std::memory_order_acq_rel, std::memory_order_relaxed)) { return false; // Another thread claimed it first! } // 4. Double-Check Ancestors (Ensure no ancestor locked concurrently) for (int cur = parent[idx]; cur != -1; cur = parent[cur]) { if (locked[cur].load(std::memory_order_relaxed) != -1) { // Abort and rollback our claim locked[idx].store(-1, std::memory_order_release); return false; } } // 5. Atomic Propagation to Ancestors for (int cur = parent[idx]; cur != -1; cur = parent[cur]) { lockedDescCount[cur].fetch_add(1, std::memory_order_relaxed); } return true; }


12. πŸ—£οΈ PHASE 11: Scripted Word-for-Word Answers for the Interview

1. Opening Statement (Defending Vector vs TreeNode)

You: "I chose a flat vector and adjacency list representation over dynamic TreeNode* objects. It provides superior CPU cache locality, eliminates heap fragmentation, enables $O(1)$ unlocks, and avoids 64-bit pointer bloat. While my baseline descendant check is $O(N)$ BFS, it can be trivially optimized to $O(1)$ by adding a flat lockedDescCount array without losing our cache locality benefits."

2. When Asked to Optimize to $O(\log_M N)$

You: "We add vector<int> lockedDescCount(n, 0). When locking a node, we check lockedDescCount[idx] == 0 in $O(1)$ time, and walk up the parent array to increment ancestor counts in $O(\log_M N)$ time. This drops lock() to $O(\log_M N)$ while keeping our contiguous memory structure."

3. When Asked About Part B Concurrency

You: "Before proposing a solution, let me clarify: Are we making only the lock() method thread-safe, or all three operations concurrently?

If multiple threads call lock() simultaneously, we face three primary race conditions:

  1. Same-node TOCTOU: Two threads both see locked[idx] == -1 and overwrite each other.
  2. BFS Phantom Read: A thread executing BFS descendant checks misses a concurrent lock on a lower descendant.
  3. Ancestor-Descendant Double Lock: One thread locks an ancestor while another locks a descendant, both passing checks before writes are globally visible."

4. When Rejected on Global Mutex / Synchronized

You: "A global mutex serializes execution and violates Amdahl's Law. Threads operating on independent subtrees (like Asia and Africa) should execute in parallel. We need fine-grained, per-node synchronization."

5. When Rejected on std::mutex ("Mutex introduces lag")

You: "std::mutex relies on OS futexes. Under contention, the OS triggers a Ring 3 to Ring 0 kernel context switch, taking 1 to 3 microseconds. In contrast, our tree traversal takes 20 nanoseconds. Putting threads to sleep causes a 100x slowdown. We should use user-space spinlocks with atomic intrinsics like __sync_lock_test_and_set."

6. When the Interviewer Presents the lockedCount++ Trap Code

You: "Could you please give me a moment to trace through the thread interleavings? (After 30 seconds) This solution has fatal concurrency bugs:

  1. Same-Node Livelock (No, No): If T1 and T2 lock India concurrently, both pass Condition 1, both increment lockedCount to 2, both see lockedCount > 1, and both decrement and return false. Neither thread locks the node!
  2. Ancestor-Descendant Invariant Violation (Yes, Yes): If T1 locks World and T2 locks Asia, T2 passes the ancestor check before T1 updates World->lockedCount. Both return true, locking ancestor and descendant simultaneously.
  3. Non-Atomic Read-Modify-Write: lockedCount++ compiles to 3 instructions (MOV, ADD, MOV). Concurrent execution causes lost updates.
  4. Hardware Reality: On modern out-of-order multi-core CPUs, store buffer reordering breaks lock-free mutual exclusion without hardware atomic instructions or memory barriers."


13. ⚑ Quick-Reference Cheat Sheet

Question / TopicGolden Answer to Deliver
Why Flat Vectors over TreeNode*?Contiguous memory, L1/L2 cache prefetching, $O(1)$ unlocks, zero heap fragmentation, zero 64-bit pointer bloat.
Scope of Part B"Are we making only lock() thread-safe, or all three methods?"
Optimizing to $O(\log_M N)$Add vector<int> lockedDescCount(n). Descendant check becomes $O(1)$, ancestor propagation becomes $O(\log_M N)$.
Why Global Lock is RejectedAmdahl's Law: serializes independent subtrees (e.g. Asia vs Africa).
Why OS Mutex is SlowFutex syscall, Ring 3 $\to$ Ring 0 switch, thread descheduling, TLB flush ($\sim 1\text{--}3,\mu\text{s}$ vs $20,\text{ns}$).
What __sync_lock_test_and_set doesIndivisibly reads old value and writes new value via atomic XCHG assembly instruction.
Does volatile make C++ thread-safe?No! Only prevents compiler register caching. Does not emit memory barriers or ensure atomicity.
Why x++ is not atomicCompiles to 3 instructions: MOV (read), ADD (modify), MOV (write). Causes lost updates.
Bug 1 in lockedCount++ codeSame node collision $\to$ both threads increment to 2, both see $>1$, both decrement $\to$ No, No (Livelock).
Bug 2 in lockedCount++ codeAncestor vs Descendant $\to$ World and Asia both pass checks before visibility $\to$ Yes, Yes (Invariant Broken).
Bug 3 in lockedCount++ codeTypo: checks r->lockedDescendents inside while(par) instead of par->lockedDescendents.
Can we solve without atomics?No. Store-load reordering in CPU store buffers breaks software algorithms (Peterson's) without memory barriers.
Deadlock-Free Node LockingAlways acquire path locks monotonically from Leaf to Root.