Hello! Welcome back to our module on Concurrency Control.
In our last lesson, we established the theoretical foundation for correctness. We learned that a concurrent schedule is correct if it's serializable, and we developed a practical test for this using dependency graphs to check for cycles. This is great for analysis, but a database needs a proactive way to guarantee serializability as transactions are running.
Today, we move from theory to practice. Our goal is to implement a lock manager that supports shared (read) and exclusive (write) locks. This is the fundamental mechanism that most databases use to prevent conflicting operations from causing data corruption. We'll design the core data structures and logic for this critical system component, setting the stage for the locking protocols we'll build on top of it.
The Problem, Revisited
Let's quickly refresh our memory on why we need this. A simple, real-world example makes the problem crystal clear.
Database Locks Explained | With Real-World E-Commerce Example
This video from Redhwan Nacef uses a simple e-commerce stock management scenario to demonstrate how two concurrent transactions can lead to selling an item that's out of stock—a classic concurrency bug.
Watch the first three minutes of the video (00:00 - 03:04). Pay close attention to how both Alice and Bob read the stock quantity before either of them can update it, leading to an incorrect final state.
This "lost update" problem happens because the read and write operations from different transactions are interleaved without any control. The solution is to introduce locks to protect data while a transaction is working on it.
The Lock Manager: A Centralized Gatekeeper
To manage locks, a database system uses a centralized component called the Lock Manager. Think of it as a traffic controller for data access. Whenever a transaction wants to read or write an object, it must first request permission (a lock) from the lock manager.
The lock manager's job is to:
- Receive lock requests from transactions.
- Grant the lock if it's compatible with existing locks.
- Block the transaction if the request is not compatible.
- Keep track of which transactions hold which locks.
- Manage the release of locks when a transaction is done.
The following video segment from CMU's database course introduces this concept.
16 - Two-Phase Locking in Databases (CMU Intro to Database Systems / Fall 2022)
This clip from Andy Pavlo's lecture introduces the lock manager as the central authority for coordinating access to database objects. It also makes a crucial distinction between locks (for transactions) and latches (for internal data structures).
Watch from 03:20 to 08:42. Focus on understanding the role of the lock manager as a centralized component and the key difference between a transactional 'lock' and a low-level 'latch' (which you know as a mutex).
As the video highlights, it's vital to distinguish between locks and latches. Given your background in C++, you can think of it this way:
- Locks: A high-level, logical concept managed by the database to ensure transaction serializability. They protect database objects (like tuples or tables) for the duration of a transaction.
- Latches: A low-level, physical synchronization primitive, like a
std::mutex. They provide short-term protection for the internal data structures of the database itself, such as the lock manager's internal hash table or the buffer pool. They are held for microseconds, not the entire length of a transaction.
Today, we are building a manager for transactional locks, but we will need to use latches inside our implementation to protect its own data structures from concurrent access by different worker threads.
Lock Modes: Shared and Exclusive
To allow for more concurrency, we don't just have one type of lock. We have different lock modes. The two most basic and important modes are:
- Shared Lock (S-lock): A transaction requests a shared lock before it reads an object.
- Exclusive Lock (X-lock): A transaction requests an exclusive lock before it writes to an object.
These locks follow a simple compatibility rule: multiple transactions can hold a shared lock on the same object simultaneously, but an exclusive lock cannot be shared with any other lock (not even a shared one). This is summarized in the lock compatibility matrix:
| Requesting | Shared (S) | Exclusive (X) |
|---|---|---|
| Shared (S) | ✅ | ❌ |
| Exclusive (X) | ❌ | ❌ |
This logic is intuitive: it's fine for many people to read a document at once, but if someone is editing it, nobody else should be able to read or edit it until they are finished.
Designing the Lock Manager's Internals
Now for the core of the lesson: how would we implement this? This is a great systems design problem that maps well to your experience with data structures and C++.
Let's start with a high-level visual. The lock manager needs a central data structure to track the state of all locked objects. A hash table is a perfect fit, mapping an object identifier (e.g., a RecordID or RID for a tuple) to its lock information.

Based on this diagram and the requirements of our system, for each RID that has a lock on it, we need to store:
- A queue of lock requests. This is crucial for transactions that must wait. Each request in the queue should contain at least the transaction ID (
txn_id) and the desired lock mode (SorX). - The current state of the lock, such as the lock mode (e.g.,
SHARED,EXCLUSIVE) and which transactions are currently holding the lock.
This waiting queue is fundamental. When a transaction requests a lock that cannot be granted, we create a LockRequest object, add it to the queue, and put the transaction's thread to sleep. When another transaction unlocks the object, the lock manager re-examines the queue and wakes up any waiting threads whose requests can now be granted.
Implementing the Wait/Notify Logic in C++
In C++, this blocking and waking mechanism is implemented using std::mutex (a latch) and std::condition_variable.
- A
std::mutexprotects the lock manager's internal hash table, ensuring only one thread can modify it at a time. - A
std::condition_variableis used to block a thread until it is notified by another. EachLockRequestin our waiting queue can contain a condition variable for its transaction to wait on.
This is a classic readers-writer lock problem. Let's look at a technical resource that discusses a common pitfall and provides a C++ solution.
Shared locks from multiple threads can starve a single ...
This Stack Overflow post discusses 'writer starvation'—a scenario where a stream of read-lock requests can indefinitely block a write-lock request. The accepted answer provides a C++ implementation of a Readers-Writer lock that addresses this, giving us a concrete code example for managing lock requests for a single object.
First, read the question to understand the writer starvation problem. Then, study the C++ code in the first answer. Focus on how it uses a std::mutex, std::condition_variable, and counters (waitingWriters, currentReaders) to correctly manage waiting and notifications.
The Implementation Task
Now let's formalize our implementation plan. We can use the project specification from CMU's course as a guide. It provides a clear API for a lock manager that you would build in C++.
Project #3 - Concurrency Control
This project description from CMU's database course outlines the exact task of building a lock manager. It defines the API and internal components, providing a perfect blueprint for our implementation.
Read the sections for 'Task #1 - Lock Manager', including the 'REQUIREMENTS AND HINTS' and 'Advice from the TAs'. You don't need to read about deadlock detection/prevention for now. Pay close attention to the function signatures you need to implement and the advice to use std::condition_variable.
As outlined in the spec, your lock manager class would need to implement these core methods:
LockShared(Transaction* txn, const RID& rid)LockExclusive(Transaction* txn, const RID& rid)LockUpgrade(Transaction* txn, const RID& rid)Unlock(Transaction* txn, const RID& rid)
Let's walk through the high-level logic for LockExclusive, which is the most restrictive case.
Logic for LockExclusive(txn, rid):
- Acquire the lock manager's global latch (
std::unique_lock<std::mutex>). - Look up the
LockRequestQueuefor the givenridin your hash map. - Check if any other transaction holds any lock (S or X) on this
rid. - If a conflict exists:
a. Create aLockRequestfor this transaction and add it to the wait queue.
b. Block the current thread by callingwait()on astd::condition_variable. The thread will sleep here until another thread callsnotify.
c. When woken up, re-check for conflicts (this handles spurious wakeups). If a conflict still exists, go back to sleep. - If no conflict exists:
a. Grant the lock. Update theLockRequestQueueto show that thistxnnow holds an exclusive lock. - Release the lock manager's latch.
The logic for Unlock(txn, rid) is the reverse process:
- Acquire the global latch.
- Remove
txnfrom the list of lock holders forrid. - Check the wait queue for
rid. Iterate through the waiting requests and see which ones are now compatible (e.g., multiple S-locks can be granted, or one X-lock). - For each transaction you decide to unblock, call
notify_one()ornotify_all()on itsstd::condition_variable. - Release the global latch.
Test your understanding!
Imagine your lock manager has granted a Shared (S) lock on RID_1 to transaction T1. Now, two new requests arrive in this order:
T2requests an Exclusive (X) lock onRID_1.T3requests a Shared (S) lock onRID_1.
Assuming a simple FIFO queue that doesn't prioritize writers, what happens to T2 and T3? What happens when T1 finally calls Unlock()?
Show answer
- T2 requests X-lock: T2's request conflicts with T1's S-lock. T2 is blocked, and its request is added to the wait queue for
RID_1. - T3 requests S-lock: T3's request conflicts with T2's request waiting in the queue (even though T3 is compatible with T1). To prevent starvation and ensure fairness, T3 is also blocked and added to the queue behind T2.
- T1 calls
Unlock():- T1's S-lock is released.
- The lock manager checks the wait queue. The first request is from T2 for an X-lock.
- Since there are no other active locks on
RID_1, T2's request is granted. T2 is unblocked and now holds the X-lock. - T3 remains blocked in the queue because its S-lock request is not compatible with T2's newly acquired X-lock.
Conclusion
Today, we designed the machinery for one of the most fundamental concurrency control methods. We moved from the what (serializability) to the how (a lock manager).
Key Takeaways:
- A lock manager is a centralized database component that arbitrates access to data by granting, denying, and tracking locks.
- The two primary lock modes are Shared (read) and Exclusive (write), governed by a compatibility matrix.
- A practical implementation uses a hash map to store a request queue for each locked object.
- The logic for blocking and waking up transactions is implemented using low-level latches (
std::mutex) and condition variables (std::condition_variable).
Preview of the Next Lesson
Having a lock manager is like having a throttle and a brake in a car—they are the mechanisms of control. But you still need rules of the road to drive safely. How should transactions use the lock manager to guarantee serializability?
In our next lesson, we will implement Strict Two-Phase Locking (S2PL), a protocol that dictates when transactions can acquire and release locks. By integrating S2PL with our lock manager, we will have a complete, working concurrency control system that guarantees correctness.