Pessimistic Concurrency Control Mechanism: Exploring Two-Phase Locking (2PL)
Several topics like this are discussed on my YouTube channel. Please, visit. Appreciate your support.
We learned about the concurrency control mechanisms in the article Concurrency Control Mechanisms in Distributed Systems. In this article, we will explore Two-Phase Locking (2PL), a crucial pessimistic concurrency control (PCC) mechanism used in databases to ensure data consistency in multi-user environments.
Introduction to 2PL:
Two-Phase Locking (2PL) is a concurrency control mechanism that operates on the principle that transactions need to acquire locks on data items before reading or modifying them. It consists of two main phases: the Growing Phase, where transactions acquire locks, and the Shrinking Phase, where locks are released. Hence the name, 2PL.
Note that while two-phase locking (2PL) sounds very similar to two-phase commit (2PC), they are completely different things. 2PL manages locks in databases, while 2PC coordinates distributed transactions for data consistency.
In a distributed system with multiple concurrent users, Serializability means each transaction behaves as if it’s the sole active one in the entire database. For around 30 years, there was only one widely used algorithm for serializability in databases: two-phase locking (2PL). 2PL is one of the methods to achieve Serializability.
In 2PL several transactions are allowed to concurrently read the same object as long as nobody is writing to it. But as soon as anyone wants to write (modify or delete) an object, exclusive access is required:
1. If transaction A has read an object and transaction B wants to write to that object, B must wait until A commits or aborts before it can continue. (This ensures that B can’t change the object unexpectedly behind A’s back.)
2. If transaction A has written an object and transaction B wants to read that object, B must wait until A commits or aborts before it can continue. (Reading an old version of the object is not acceptable under 2PL.)
In 2PL, writers don’t just block other writers; they also block readers and readers block writers. That is, several transactions reading the same object use a shared lock while a transaction writing to the object must acquire an exclusive lock. If a transaction first reads and then writes an object, it may upgrade its shared lock to an exclusive lock. The upgrade works the same as getting an exclusive lock directly.
Scenario 1: Transaction A Reads, and Transaction B Wants to Write
1. Growing Phase (Lock Acquisition):
a. Transaction A begins and reads an object, acquiring a read lock on it.
b. Transaction B starts and intends to write to the same object but finds it locked by A.
c. Transaction B must wait during the growing phase since it cannot acquire a write lock on the object while A holds a read lock.
2. Shrinking Phase (Lock Release):
a. Transaction A completes its reading operation and releases the read lock on the object.
b. Now, Transaction B, which was waiting, acquires a write lock and proceeds with its writing operation.
c. After finishing the write, Transaction B releases the lock.
Scenario 2: Transaction A Writes, and Transaction B Wants to Read
1. Growing Phase (Lock Acquisition):
a. Transaction A begins and writes to an object, obtaining a write lock on it.
b. Transaction B starts and intends to read the same object but finds it locked by A.
c. Transaction B must wait during the growing phase since it cannot acquire a read lock on the object while A holds a write lock.
2. Shrinking Phase (Lock Release):
a. Transaction A completes its writing operation and releases the write lock on the object.
b. Now, Transaction B, which was waiting, acquires a read lock and proceeds with its reading operation.
c. Transaction B ensures it reads the most up-to-date version of the object.
Example Code depicting scenarios 1 and 2 above.
import java.util.concurrent.locks.ReentrantLock;
public class Database {
private final ReentrantLock lock = new ReentrantLock();
private int data; // Shared data object
// Scenario 1: Transaction A Reads, and Transaction B Wants to Write
public void scenario1ReadAWriteB() {
// Transaction A (Read)
lock.lock();
try {
// Reading the data
System.out.println("Transaction A acquires the lock and reads data: " + data);
// Simulate some processing time
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); // Release lock
System.out.println("Transaction A releases the lock");
}
// Introduce a delay to simulate B attempting to write while A is reading
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
// Transaction B (Write)
lock.lock();
try {
// Writing to the data
data += 10; // Simulating a write operation
System.out.println("Transaction B acquires the lock and writes data: " + data);
} finally {
lock.unlock(); // Release lock
System.out.println("Transaction B releases the lock");
}
}
// Scenario 2: Transaction A Writes, and Transaction B Wants to Read
public void scenario2WriteAReadB() {
// Transaction A (Write)
lock.lock();
try {
// Writing to the data
data += 20; // Simulating a write operation
System.out.println("Transaction A acquires the lock and writes data: " + data);
// Simulate some processing time
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); // Release lock
System.out.println("Transaction A releases the lock");
}
// Introduce a delay to simulate B attempting to read while A is writing
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
// Transaction B (Read)
lock.lock();
try {
// Reading the data (ensuring it's the most up-to-date version)
System.out.println("Transaction B acquires the lock and reads data: " + data);
} finally {
lock.unlock(); // Release lock
System.out.println("Transaction B releases the lock");
}
}
public static void main(String[] args) {
Database database = new Database();
// Scenario 1: Transaction A Reads, and Transaction B Wants to Write
Thread transactionA = new Thread(database::scenario1ReadAWriteB);
Thread transactionB = new Thread(database::scenario1ReadAWriteB);
transactionA.start();
transactionB.start();
try {
transactionA.join();
transactionB.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Scenario 1 completed.\n");
// Reset data for Scenario 2
database.data = 0;
// Scenario 2: Transaction A Writes, and Transaction B Wants to Read
Thread transactionC = new Thread(database::scenario2WriteAReadB);
Thread transactionD = new Thread(database::scenario2WriteAReadB);
transactionC.start();
transactionD.start();
try {
transactionC.join();
transactionD.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Scenario 2 completed.");
}
}
In both scenarios, Two-Phase Locking (2PL) ensures that transactions A and B do not interfere with each other’s access to the shared object. During the growing phase, one transaction acquires a lock, preventing the other from acquiring a conflicting lock. In the shrinking phase, the locks are released, allowing the waiting transaction to proceed, ensuring data consistency, and preventing unexpected changes or outdated reads.
Real-World Examples:
Databases like Oracle Database, IBM Db2, Microsoft SQL Server, MySQL, PostgreSQL, and Hadoop HBase commonly implement 2PL or variations to ensure data consistency and prevent conflicts in concurrent transactions.
In conclusion, Two-Phase Locking (2PL) plays a crucial role in maintaining data integrity and preventing conflicts in distributed systems with multiple concurrent users. Understanding its principles and implementations is essential for building robust and reliable database systems.
To learn more, refer to the below:
Designing Data-Intensive Applications: https://bit.ly/3uDu4cH
Design Guru’s Courses — https://www.designgurus.io/courses/?aff=ds2xpx