1. Pessimistic Locking (Khóa bi quan)
Pessimistic Locking dựa trên giả định rằng các xung đột dữ liệu sẽ xảy ra thường xuyên. Khi một luồng (thread) truy cập vào một tài nguyên dùng chung, nó sẽ thực hiện khóa tài nguyên đó lại ngay lập tức. Các luồng khác muốn truy cập vào tài nguyên này sẽ phải rơi vào trạng thái chờ (blocked) cho đến khi luồng giữ khóa giải phóng nó.
Trong Java, các cơ chế như từ khóa synchronized hoặc lớp ReentrantLock là những ví dụ điển hình cho tư duy khóa bi quan.
// Sử dụng ReentrantLock để bảo vệ tài nguyên
public class Counter {
private final Lock mutex = new ReentrantLock();
private int value = 0;
public void increment() {
mutex.lock();
try {
value++;
} finally {
mutex.unlock();
}
}
}
Mặc dù đảm bảo an toàn dữ liệu tuyệt đối, nhưng trong môi trường có tính cạnh tranh cao, Pessimistic Locking dễ dẫn đến hiện tượng thắt nút cổ chai do chi phí chuyển đổi ngữ cảnh (context switch) giữa các luồng bị chặn. Ngoài ra, việc sử dụng không khéo léo có thể dẫn đến tình trạng Deadlock.
2. Optimistic Locking (Khóa lạc quan)
Ngược lại, Optimistic Locking giả định rằng xung đột dữ liệu hiếm khi xảy ra. Các luồng có thể đọc và thực hiện tính toán trên tài nguyên mà không cần khóa. Chỉ đến bước cuối cùng khi cập nhật dữ liệu, hệ thống mới kiểm tra xem dữ liệu đó có bị luồng nào khác thay đổi kể từ lúc đọc hay không.
Các phương pháp phổ biến để triển khai khóa lạc quan bao gồm sử dụng số phiên bản (versioning) hoặc thuật toán CAS (Compare-And-Swap).
// Sử dụng LongAdder - một triển khai hiệu quả của khóa lạc quan trong Java
LongAdder statistics = new LongAdder();
statistics.add(100);
int currentVal = statistics.intValue();
Optimistic Locking mang lại hiệu suất tốt hơn trong các kịch bản "đọc nhiều, ghi ít" vì nó loại bỏ được thời gian chờ đợi của luồng. Tuy nhiên, nếu tỷ lệ ghi quá cao, các luồng sẽ liên tục phải thử lại (retry), gây lãng phí tài nguyên CPU.
3. Các phương thức triển khai Optimistic Locking
Cơ chế phiên bản (Versioning)
Mỗi bản ghi dữ liệu sẽ đi kèm với một trường version. Khi cập nhật, câu lệnh SQL sẽ có dạng:
UPDATE inventory
SET stock = stock - 1, version = version + 1
WHERE id = 101 AND version = 5;
Nếu một luồng khác đã cập nhật trước đó khiến version tăng lên 6, câu lệnh trên sẽ không tìm thấy bản ghi phù hợp và việc cập nhật thất bại, buộc luồng hiện tại phải xử lý lại.
Thuật toán CAS (Compare And Swap)
CAS là một thao tác nguyên tử (atomic operation) được hỗ trợ ở cấp độ phần cứng CPU. Nó sử dụng ba tham số:
- V (Value): Giá trị thực tế tại địa chỉ bộ nhớ.
- E (Expected): Giá trị kỳ vọng (giá trị cũ mà luồng đã đọc).
- N (New): Giá trị mới muốn cập nhật.
Giá trị V chỉ được thay thế bằng N nếu V đang bằng E. Nếu không, thao tác sẽ thất bại.
// Minh họa phương thức native của CAS trong lớp Unsafe
public final native boolean compareAndSwapInt(Object obj, long offset, int expected, int update);
4. Những hạn chế của CAS và cách khắc phục
Vấn đề ABA
Giả sử một luồng đọc giá trị A, sau đó một luồng khác đổi A thành B rồi lại đổi ngược về A. Khi luồng đầu tiên thực hiện CAS, nó thấy giá trị vẫn là A và cho rằng dữ liệu chưa bị thay đổi. Đây gọi là vấn đề ABA.
Để giải quyết, Java cung cấp lớp AtomicStampedReference, gán thêm một nhãn thời gian hoặc số hiệu chỉnh vào tham chiếu để phân biệt các trạng thái của dữ liệu.
public boolean updateData(V oldRef, V newRef, int oldStamp, int newStamp) {
Pair<V> current = pair;
return oldRef == current.reference &&
oldStamp == current.stamp &&
((newRef == current.reference && newStamp == current.stamp) ||
casPair(current, Pair.of(newRef, newStamp)));
}
Vòng lặp vô tận (Spinning)
Nếu xung đột xảy ra liên tục, vòng lặp CAS sẽ chạy mãi mãi, gây áp lực cực lớn lên CPU. Do đó, cần cân nhắc sử dụng khóa bi quan nếu tỷ lệ tranh chấp quá cao.
Giới hạn phạm vi
CAS nguyên bản chỉ hoạt động trên một biến đơn lẻ. Nếu cần đảm bảo tính nguyên tử cho một nhóm biến, lập trình viên có thể đóng gói chúng vào một đối tượng duy nhất và sử dụng AtomicReference để thực hiện CAS trên tham chiếu của đối tượng đó.
5. Khái niệm Spinlock (Khóa tự quay)
Khác với khóa thông thường khiến luồng đi vào trạng thái ngủ, Spinlock khiến luồng liên tục chạy một vòng lặp kiểm tra điều kiện để lấy khóa. Việc này giúp tránh chi phí đánh thức luồng nếu thời gian giữ khóa của luồng khác là cực ngắn.