Java Internals & Concurrency/ReadWriteLock, StampedLock & AQS — khi đọc áp đảo ghi
10/39
Bài 10 / 39~13 phútConcurrency cơ bảnMiễn phí lượt xem

ReadWriteLock, StampedLock & AQS — khi đọc áp đảo ghi

ReadWriteLock cho nhiều reader chạy song song khi đọc áp đảo ghi, StampedLock thêm optimistic read gần như miễn phí, và AQS — bộ khung state CAS cộng hàng đợi park/unpark đứng sau mọi khóa của java.util.concurrent.

TL;DR: ReentrantLock mạnh hơn synchronized nhưng vẫn là khóa độc quyền: một thread giữ thì tất cả phải chờ, kể cả 99 thread chỉ muốn đọc. ReadWriteLock tách read khỏi write — nhiều reader chạy song song, writer độc quyền; cho phép downgrade write xuống read nhưng cấm upgrade ngược lại, vì một thread giữ read lock xin write lock sẽ tự treo. StampedLock đẩy thêm một bước với optimistic read: đọc không giành khóa, rồi validate kiểm tra có writer chen vào không — đổi lại nó không reentrant và không có Condition. Bên dưới tất cả là AQS: một biến state kiểu int cập nhật bằng CAS cộng một hàng đợi FIFO các thread được park/unpark.

1. Giới thiệu

Bài trước khép lại với một bộ khóa đã giàu khả năng hơn hẳn synchronized: tryLock để rút lui khỏi deadlock, timeout để tôn trọng SLA, lockInterruptibly để hủy task gọn gàng, Condition để đánh thức đúng nhóm thread. Nhưng nhìn kỹ lại, ReentrantLock vẫn chưa đụng đến một giới hạn cốt lõi: nó là một khóa độc quyền tuyệt đối. Một thread giữ khóa thì mọi thread khác phải chờ, bất kể chúng định đọc hay ghi.

Đặt giới hạn đó cạnh số liệu thực của TicketFlow: tồn kho vé bị đọc liên tục — mọi trang sự kiện, mọi dashboard, mọi lần client kiểm tra còn chỗ không — nhưng chỉ bị ghi mỗi khi thực sự có giao dịch đặt hoặc hủy. Tỷ lệ đọc trên ghi có thể là hàng trăm trên một. Với một khóa độc quyền, hàng trăm lần đọc vô hại đó vẫn phải xếp hàng sau nhau, dù hai reader đồng thời không bao giờ đụng độ. Đó là throughput bị bóp nghẹt không vì lý do gì.

Bài này đi tiếp ba bước trên cùng trục đó. ReadWriteLock tách "khóa để đọc" khỏi "khóa để ghi" cho reader chạy song song. StampedLock thêm chế độ optimistic read — đọc mà không giành khóa gì cả. Và để khép khối Synchronization, ta mở nắp xuống tầng dưới: AQS và LockSupport, bộ khung chung mà gần như mọi khóa và synchronizer của java.util.concurrent đều dựng trên.

2. ReadWriteLock: nhiều người đọc, một người ghi

ReentrantLock cũng như synchronized đều là khóa loại trừ tuyệt đối: một thread giữ khóa thì mọi thread khác phải chờ, bất kể chúng định đọc hay ghi. Với dữ liệu chỉ đọc thì điều này quá tay. Nhiều thread cùng đọc một dữ liệu không bao giờ đụng độ nhau - đọc không sửa state, nên hai reader đồng thời cho cùng kết quả như đọc tuần tự. Bắt chúng xếp hàng chỉ vì khóa loại trừ là bóp nghẹt throughput không cần thiết.

ReadWriteLock nới đúng chỗ đó. Nó tách thành hai khóa liên kết: một read lock và một write lock. Quy tắc gọn: nhiều thread có thể giữ read lock đồng thời chừng nào không có writer; nhưng write lock là loại trừ - khi một writer giữ nó, không reader lẫn writer nào khác vào được. Đây là mô hình nhiều-reader-một-writer, và nó thắng lớn đúng trong workload đọc nhiều ghi ít.

Một analogy quen thuộc là trang wiki nội bộ của công ty. Bao nhiêu người cùng mở xem một trang cũng được - không ai ảnh hưởng ai. Nhưng khi một người bấm Edit, trang khóa lại: người xem mới phải chờ, người muốn Edit khác cũng chờ, cho đến khi bản sửa được lưu.

Đời thường (trang wiki)Cơ chế read-write lock
Nhiều người cùng mở xem một trangRead lock — share, không giới hạn số reader
Một người bấm Edit, mọi người khác phải chờWrite lock — độc quyền với cả reader lẫn writer
Lưu xong, chuyển ngay sang chế độ xem không rời trangDowngrade: giành read trước khi nhả write
Đang xem mà muốn sửa thì phải đóng chế độ xem, xin Edit lạiKhông có upgrade — nhả read rồi giành write như thao tác mới
💡 Cách nhớ

Reader share được với reader. Writer không share với bất kỳ ai — kể cả reader, kể cả writer khác, và kể cả "chính mình trong vai reader" (gốc rễ của lệnh cấm upgrade ở mục 2.1).

Đây chính là biến thể v1 mà capstone gợi mở. Monitor Pattern khóa loại trừ ở bài volatile & synchronized serialize cả những lần đọc thuần túy không liên quan gì đến nhau. ReentrantReadWriteLock giải phóng chúng:

public class InventoryService {
    private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
    private final Lock readLock  = rw.readLock();
    private final Lock writeLock = rw.writeLock();
    private final Map<String, Integer> remaining = new HashMap<>();   // @GuardedBy("rw")

    public int seatsLeft(String eventId) {        // doc nhieu: nhieu reader chay song song
        readLock.lock();
        try {
            return remaining.getOrDefault(eventId, 0);
        } finally {
            readLock.unlock();
        }
    }

    public Booking book(String eventId, String userId) {   // ghi it: loai tru
        writeLock.lock();
        try {
            int left = remaining.getOrDefault(eventId, 0);
            if (left <= 0) throw new SoldOutException(eventId);
            remaining.put(eventId, left - 1);
            return new Booking(eventId, userId, left);
        } finally {
            writeLock.unlock();
        }
    }
}

So với Monitor Pattern, lợi ích chỉ hiện thực khi đọc thật sự áp đảo ghi và mỗi lần đọc đủ dài để việc cho phép song song có ý nghĩa. Nếu ghi nhiều, hoặc thao tác đọc cực ngắn, chi phí bookkeeping của read-write lock - vốn nặng hơn một mutex thường - có thể ăn hết phần lợi, và một ReentrantLock đơn giản lại nhanh hơn. Đừng mặc định read-write lock là tốt hơn; nó là một đánh đổi, và chỉ đo đạc mới nói được nó có đáng không cho workload cụ thể.

ReentrantReadWriteLock cũng nhận cờ fairness qua constructor, giống ReentrantLock ở bài trước, và đánh đổi throughput thì y hệt - nhưng có thêm một sắc thái riêng của mô hình hai chế độ: trong chế độ unfair, một dòng reader đến liên tục có thể trì hoãn writer rất lâu, vì reader mới cứ việc nhập hội với các reader đang giữ khóa. Với TicketFlow, đó là tồn kho hiển thị mãi mà giao dịch đặt vé không chen vào ghi được. Chế độ fair buộc reader mới xếp sau writer đang chờ - trả giá throughput đọc để đổi lấy bảo đảm writer không bị bỏ đói.

2.1 Downgrade được, upgrade thì không

ReentrantReadWriteLock hỗ trợ lock downgrading: một thread đang giữ write lock có thể giành thêm read lock rồi nhả write lock, qua đó hạ cấp xuống quyền đọc mà không hề buông khóa giữa chừng. Điều này hữu ích khi vừa cập nhật xong và muốn tiếp tục đọc giá trị vừa ghi trong khi đã cho writer khác có cơ hội.

void updateThenRead(String eventId, int newLeft) {
    writeLock.lock();
    try {
        remaining.put(eventId, newLeft);
        readLock.lock();          // gianh read lock TRUOC khi nha write - khong co khe ho
    } finally {
        writeLock.unlock();       // ha cap: gio chi con giu read lock
    }
    try {
        // doc gia tri vua ghi, cac reader khac da co the vao cung
    } finally {
        readLock.unlock();
    }
}

Chiều ngược lại - upgrading từ read lock lên write lock - thì API không hỗ trợ, và nếu cứ cố gọi writeLock().lock() trong khi đang giữ read lock, chính một thread duy nhất cũng tự treo mình. Lý do nằm ở quy tắc cấp write lock: write lock chỉ được trao khi mọi read lock đã nhả - kể cả read lock của chính thread đang xin. Thread đó vì thế chờ chính mình buông read lock, mà nó thì đang đứng chờ write lock nên không bao giờ buông - một vòng chờ khép kín trong đúng một thread.

Còn kịch bản hai reader cùng muốn nâng cấp là rationale thiết kế giải thích vì sao JDK không cung cấp một thao tác upgrade "chờ rồi nâng": nếu API cho phép giữ read lock và chờ đến khi các reader khác nhả rồi mới nâng cấp, hai reader cùng xin nâng cấp sẽ chờ nhau buông read lock mãi mãi. Một thao tác upgrade an toàn về nguyên tắc không thể tồn tại với ngữ nghĩa đó, nên cách đúng là nhả read lock, giành write lock, rồi kiểm tra lại điều kiện - vì giữa hai bước đó state có thể đã bị thread khác đổi.

3. StampedLock: optimistic read

StampedLock (Java 8) đẩy ý tưởng read-write thêm một bước, và mở ra một chế độ thứ ba mà cả synchronized lẫn ReentrantReadWriteLock đều không có: optimistic read. Tư tưởng vay đúng từ CAS ở bài Atomic & CAS - thay vì giành khóa rồi mới đọc, ta cứ đọc lạc quan như thể không ai ghi, rồi sau đó kiểm tra xem giả định đó có còn đúng không.

Mỗi thao tác khóa của StampedLock trả về một long gọi là stamp, dùng làm bằng chứng để nhả khóa hoặc để xác thực. tryOptimisticRead() không giành khóa gì cả - nó chỉ trả về một stamp ghi lại "phiên bản" hiện tại của khóa. Ta đọc dữ liệu vào biến cục bộ, rồi gọi validate(stamp): nếu không có writer nào chen vào kể từ lúc lấy stamp, validate trả về true và dữ liệu ta đọc là nhất quán; nếu có, nó trả về false và ta phải đọc lại, thường là rơi xuống một read lock thật.

public class StampedInventory {
    private final StampedLock sl = new StampedLock();
    private int remaining;

    public int seatsLeft() {
        long stamp = sl.tryOptimisticRead();     // khong gianh khoa, chi chup phien ban
        int value = remaining;                   // doc lac quan vao bien cuc bo
        if (!sl.validate(stamp)) {               // co writer chen vao giua chung?
            stamp = sl.readLock();               // co -> roi xuong read lock that
            try {
                value = remaining;
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return value;
    }

    public void book() {
        long stamp = sl.writeLock();
        try {
            if (remaining <= 0) throw new SoldOutException("sold out");
            remaining--;
        } finally {
            sl.unlockWrite(stamp);
        }
    }
}

Trong đường đi nhanh - không có writer - seatsLeft không hề ghi vào bất kỳ shared state nào của khóa, kể cả việc tăng giảm bộ đếm reader mà ReentrantReadWriteLock phải làm. Không ghi nghĩa là không tạo cache contention giữa các core, nên với đọc cực kỳ nhiều, StampedLock có thể bỏ xa read-write lock thường. Đó là phần thưởng.

Đáng chú ý là StampedLock còn cung cấp họ method chuyển đổi giữa các chế độ, và tryConvertToWriteLock(stamp) chính là câu trả lời của nó cho bài toán upgrade mà ReentrantReadWriteLock cấm tiệt ở mục 2.1. Mấu chốt nằm ở chữ try: nó thử nâng cấp mà không chờ - nếu nâng được ngay (không ai khác giữ gì), nó trả về stamp mới ở chế độ write; nếu không, nó trả về 0 lập tức và ta tự xử lý - nhả read, giành write như thao tác mới, rồi kiểm tra lại điều kiện. Không có chờ thì không có vòng chờ, nên kiểu upgrade này không thể deadlock:

public void bookIfAvailable() {
    long stamp = sl.readLock();
    try {
        while (remaining > 0) {
            long ws = sl.tryConvertToWriteLock(stamp);   // thu nang cap, KHONG cho
            if (ws != 0L) {                              // thanh cong: stamp moi o che do write
                stamp = ws;
                remaining--;
                return;
            }
            sl.unlockRead(stamp);                        // that bai: nha read...
            stamp = sl.writeLock();                      // ...gianh write nhu thao tac moi
            // vong while kiem tra lai dieu kien - state co the da doi trong khe ho
        }
    } finally {
        sl.unlock(stamp);                                // unlock theo dung stamp dang giu
    }
}

Đây là pattern lấy từ chính Javadoc của StampedLock. So nó với lệnh cấm upgrade của ReentrantReadWriteLock sẽ thấy rõ ranh giới: thứ không thể tồn tại là upgrade chờ được ("giữ read và đợi các reader khác nhả"), còn upgrade thử-rồi-rút-lui thì hoàn toàn hợp lệ - cùng triết lý với tryLock ở bài trước.

Cạm bẫy thì sắc. Thứ nhất, StampedLock không reentrant: một thread đang giữ write lock mà gọi lại một method cũng giành write lock trên cùng object sẽ tự khóa chính mình - deadlock tức thì. Quen tay với tính reentrant của synchronizedReentrantLock rồi thì đây là cái bẫy dễ sập nhất. Thứ hai, optimistic read đòi hỏi một kỷ luật viết code chặt: vì dữ liệu đọc được có thể không nhất quán cho đến khi validate xác nhận, ta phải đọc hết vào biến cục bộ rồi mới validate, và tuyệt đối không hành động - nhất là không dereference một tham chiếu có thể đã bị ghi đè thành state hỏng - trước khi validate xong. Thứ ba, StampedLock không hỗ trợ Condition, và việc dùng nó với interrupt có những cạm bẫy riêng. Nó là công cụ chuyên dụng cho cấu trúc dữ liệu đọc-rất-nhiều, không phải khóa đa dụng để thay thế hai loại trên.

4. Nền tảng bên dưới: AQS và LockSupport

Một câu hỏi tự nhiên cho người đọc senior: tất cả những khóa này được xây trên cái gì? Câu trả lời gần như đồng nhất, và biết nó giúp đọc hiểu mã nguồn JDK lẫn các thư viện concurrency.

Phần lớn các đồng bộ hóa trong java.util.concurrent - ReentrantLock, ReentrantReadWriteLock, Semaphore, CountDownLatch - đều dựng trên một khung chung tên là AbstractQueuedSynchronizer, viết tắt AQS. AQS quản lý một thứ tưởng đơn giản nhưng đủ tổng quát: một số nguyên trạng thái đồng bộ - được cập nhật bằng CAS - cộng một hàng đợi FIFO các thread đang chờ. Mỗi lớp khóa chỉ cần định nghĩa "trạng thái này nghĩa là gì" và "khi nào thì giành/nhả được", còn AQS lo toàn bộ phần khó: xếp thread vào hàng, park chúng lại, đánh thức đúng thread khi tới lượt, xử lý cả fair lẫn unfair, cả interruptible lẫn timeout. Cùng một biến state kiểu int, mỗi lớp diễn giải một kiểu:

SynchronizerÝ nghĩa của state
ReentrantLockAcquisition count — 0 là tự do, mỗi lần tái nhập cộng 1 (ta đã gặp count này ở bài volatile & synchronized)
ReentrantReadWriteLockMột int chẻ đôi: 16 bit cao đếm số reader, 16 bit thấp đếm write reentrancy
SemaphoreSố giấy phép (permit) còn lại
CountDownLatchSố đếm còn lại trước khi cổng mở
flowchart TD
    T["Thread goi lock()"] --> C{"CAS state 0 -> 1<br/>thanh cong?"}
    C -->|"co"| H["Gianh duoc khoa ngay<br/>(khong block, khong xep hang)"]
    C -->|"khong"| Q["Tao node, noi vao cuoi<br/>hang doi FIFO cua AQS"]
    Q --> P["park() - thread ngu,<br/>cho duoc unpark"]
    H --> R["unlock(): state ve 0,<br/>unpark(thread o node dau hang doi)"]
    R --> W["Node dau hang doi tinh day"]
    W --> C

Sơ đồ trên là vòng đời một lần giành khóa qua AQS: đường nhanh là một cú CAS duy nhất trên state - không hàng đợi, không syscall; chỉ khi CAS thất bại thread mới phải trả giá xếp hàng và ngủ. Chính cấu trúc này giải thích các hành vi ta gặp ở bài trước: unfair lock cho phép thread mới đến CAS thẳng vào state mà không nhìn hàng đợi (barging), còn fair lock bắt nó kiểm tra hàng đợi trước - đắt hơn nhưng đúng thứ tự.

Vì sao phải xếp hàng và cho thread ngủ, thay vì cứ quay vòng CAS đến khi thành công? Vì spinning đốt CPU: một thread quay vòng chiếm trọn một core chỉ để hỏi đi hỏi lại "đến lượt tôi chưa", và khi critical section của người giữ khóa đủ dài, chi phí đó vượt xa chi phí một lần ngủ-rồi-tỉnh. AQS thực tế dùng cả hai: thử CAS thêm vài nhịp ngắn trước khi chịu park - đường giữa của hai thái cực mà ta đã so ở bài Atomic & CAS: khóa biến tranh chấp thành chờ đợi, CAS biến tranh chấp thành làm lại.

Còn việc thực sự cho một thread ngủ và đánh thức nó nằm thấp hơn nữa, ở LockSupport với cặp park()/unpark(thread). park tạm dừng thread hiện tại; unpark đánh thức một thread cụ thể. Khác biệt then chốt so với wait/notify: unpark có thể được gọi trước park và vẫn có hiệu lực - nó để lại một "giấy phép" (permit) khiến lần park kế tiếp trả về ngay, nhờ đó tránh được loại race "đánh thức trước khi kịp ngủ" vốn ám ảnh wait/notify. AQS dùng chính park/unpark để hiện thực hàng đợi của nó.

Không cần thuộc lòng API của hai lớp này để dùng khóa cho đúng. Nhưng biết rằng tất cả quy về CAS trên một biến trạng thái cộng một hàng đợi park/unpark sẽ làm những hành vi như fairness, barging hay chi phí đánh thức bớt bí ẩn hẳn, và là nền để sau này tự viết một synchronizer khi thư viện không có sẵn thứ bạn cần.

5. Chọn cơ chế nào

Sau bốn bài của khối Synchronization - volatile và synchronized, atomic và CAS, ReentrantLock và Condition, rồi read-write và stamped - ta đã có một dải công cụ từ nhẹ tới nặng. Câu hỏi thực dụng là khi nào dùng cái nào. Nguyên tắc chung là luôn chọn công cụ đơn giản nhất đủ cho bài toán, và cây quyết định dưới đây đi đúng theo thứ tự nên cân nhắc:

flowchart TD
    A{"Chi can visibility<br/>tren mot bien don?"} -->|"co"| V["volatile"]
    A -->|"khong"| B{"Read-modify-write<br/>tren dung mot bien?"}
    B -->|"co"| AT["Atomic* / CAS"]
    B -->|"khong"| C{"synchronized du?<br/>(khong can tryLock, timeout,<br/>fairness, nhieu Condition)"}
    C -->|"du"| SY["synchronized"]
    C -->|"thieu kha nang"| D{"Doc ap dao ghi,<br/>moi lan doc du dai?"}
    D -->|"khong"| RL["ReentrantLock"]
    D -->|"co"| E{"Doc cuc nhieu, chap nhan<br/>non-reentrant + khong Condition?"}
    E -->|"khong"| RW["ReentrantReadWriteLock"]
    E -->|"co"| ST["StampedLock"]

Cùng nội dung đó dưới dạng bảng tra cứu, kèm cột "không hợp khi" để nhắc mặt trái của mỗi nấc:

Cơ chếDùng khiKhông hợp khi
volatileMột cờ trạng thái hoặc tham chiếu, một writer hoặc ghi độc lập với giá trị cũ, không có invariant nhiều biếnCần read-modify-write nguyên tử, hoặc nhiều biến ràng buộc nhau
Atomic* / CASRead-modify-write nguyên tử trên đúng một biến; counter, accumulator, tham chiếu cập nhật lạc quanCần phối hợp nhiều biến trong một thao tác, hoặc cần chờ điều kiện
synchronizedMặc định cho mọi compound action cần loại trừ; gọn, an toàn, JVM tự nhảCần tryLock, timeout, interruptible, fairness, hoặc nhiều wait set
ReentrantLockKhi cần đúng một trong các khả năng synchronized thiếuKhi synchronized đã đủ — đừng đổi chỉ vì "nghe mạnh hơn"
ReentrantReadWriteLockĐọc áp đảo ghi, mỗi lần đọc đủ dài để song song có ý nghĩaGhi nhiều, hoặc thao tác đọc cực ngắn
StampedLockĐọc cực nhiều trên cấu trúc dữ liệu nhỏ, chấp nhận đổi sự tiện lấy throughputCần reentrancy hoặc Condition; code chưa kỷ luật về validate
💡 Cách nhớ

Leo thang từ nhẹ tới nặng và dừng ở nấc đầu tiên đủ dùng. Mỗi nấc đi lên mua thêm một khả năng cụ thể và trả bằng một bề mặt lỗi cụ thể: explicit lock trả bằng kỷ luật unlock, read-write trả bằng bookkeeping, stamped trả bằng reentrancy và kỷ luật validate.

Đọc bảng theo chiều từ trên xuống cũng là đọc theo thứ tự nên cân nhắc. Hỏi trước: có cần loại trừ không, hay chỉ cần visibility? Nếu chỉ visibility, volatile. Có phải read-modify-write một biến không? Nếu phải, Atomic*. Cần loại trừ cả một cụm thao tác? synchronized trước đã. Chỉ khi synchronized thiếu một khả năng cụ thể mới leo lên ReentrantLock, rồi mới đến read-write hay stamped khi hồ sơ đọc-ghi biện minh cho nó. Mỗi nấc thêm sức mạnh đều thêm một phần bề mặt để sai.

6. Pitfall tổng hợp

Nhầm 1: upgrade read lock lên write lock. Một thread duy nhất giữ read lock rồi xin write lock cũng tự treo - write lock chờ mọi read lock nhả, kể cả của chính nó.

readLock.lock();
try {
    if (remaining.get(eventId) > 0) {
        writeLock.lock();   // tu treo: write cho read cua CHINH thread nay nha
        // ...
    }
} finally {
    readLock.unlock();
}

✅ Nhả read lock trước, giành write lock như một thao tác mới, rồi kiểm tra lại điều kiện - state có thể đã đổi trong khe hở giữa hai khóa:

readLock.lock();
boolean available;
try {
    available = remaining.getOrDefault(eventId, 0) > 0;
} finally {
    readLock.unlock();                            // nha read TRUOC
}
if (available) {
    writeLock.lock();                             // gianh write nhu thao tac moi
    try {
        if (remaining.getOrDefault(eventId, 0) > 0) {   // kiem tra LAI dieu kien
            remaining.merge(eventId, -1, Integer::sum);
        }
    } finally {
        writeLock.unlock();
    }
}

Nhầm 2: gọi lại method giành khóa trên cùng StampedLock. StampedLock không reentrant - thread đang giữ write lock gọi tiếp một method cũng writeLock() trên cùng object là deadlock tức thì với chính mình.

public void book() {
    long stamp = sl.writeLock();
    try {
        audit();                  // audit() ben trong cung goi sl.writeLock() -> tu treo
    } finally {
        sl.unlockWrite(stamp);
    }
}

✅ Thiết kế để mỗi đường đi qua StampedLock chỉ giành khóa đúng một lần - tách phần việc cần khóa ra method private không tự giành khóa; nếu cấu trúc gọi lồng nhau là không tránh được, quay về ReentrantLock/ReentrantReadWriteLock.

Nhầm 3: hành động trên dữ liệu optimistic read trước khi validate. Dữ liệu đọc lạc quan có thể đang ở trạng thái dở dang do writer chen ngang - dereference một tham chiếu như vậy có thể thấy state hỏng.

✅ Đọc hết vào biến cục bộ, gọi validate(stamp), chỉ dùng dữ liệu khi validate trả về true; nếu false, rơi xuống readLock() và đọc lại.

Nhầm 4: mặc định read-write lock nhanh hơn mutex thường. Bookkeeping của ReentrantReadWriteLock nặng hơn ReentrantLock; với ghi nhiều hoặc đọc cực ngắn, nó chậm hơn.

✅ Chỉ chuyển sang read-write lock khi đo đạc cho thấy đọc áp đảo ghi và mỗi lần đọc đủ dài để song song có ý nghĩa.

7. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: paper AQS đáng đọc nhất trong danh sách — chỉ 12 trang nhưng giải thích trọn kiến trúc đứng sau gần như mọi synchronizer bạn dùng hằng ngày, và là tài liệu mẫu mực về cách trình bày một thiết kế concurrency.

8. Liên hệ các bài khác

  • Bài 06 — volatile & synchronized: Monitor Pattern và acquisition count của intrinsic lock — state của ReentrantLock trong AQS chính là phiên bản tường minh của acquisition count đó.
  • Bài 07 — Atomic & CAS: CAS là viên gạch của AQS — đường nhanh của mọi lần giành khóa là một cú CAS trên state; optimistic read của StampedLock cũng vay đúng tư tưởng lạc quan này.
  • Bài 08 — ReentrantLock & Condition: nửa đầu của câu chuyện explicit lock — tryLock, timeout, interruptible, fairness, Condition — mà bài này mở rộng theo trục read/write.
  • Bài 12 — Synchronizers: Semaphore, CountDownLatch, CyclicBarrier đều là các cách diễn giải khác nhau của cùng biến state AQS — đọc bài đó với sơ đồ AQS ở đây trong đầu.
  • Bài 13 — Executor & thread pool: lớp Worker bên trong ThreadPoolExecutor kế thừa thẳng AQS để khóa từng worker — bằng chứng AQS là khung dựng synchronizer, không chỉ là chi tiết hiện thực của các lock công khai.
  • Bài 16 — Virtual threads: LockSupport.park chính là điểm mà virtual thread "tháo" khỏi carrier thread — hiểu park/unpark ở đây là nền để hiểu vì sao virtual thread block rẻ.

9. Tóm tắt

  • ReadWriteLock tách read khỏi write: nhiều reader song song khi không có writer, writer độc quyền tuyệt đối — thắng lớn khi đọc áp đảo ghi và mỗi lần đọc đủ dài.
  • Downgrade write xuống read hợp lệ (giành read trước khi nhả write); upgrade read lên write bị cấm — một thread duy nhất cố upgrade cũng tự treo vì write chờ mọi read nhả, kể cả của chính nó.
  • StampedLock thêm optimistic read: tryOptimisticRead không giành khóa, đọc vào biến cục bộ, validate xác nhận không có writer chen vào — đường nhanh không ghi gì vào state của khóa nên không tạo cache contention.
  • StampedLock không reentrant và không có Condition — công cụ chuyên dụng, không phải khóa đa dụng.
  • AQS là khung chung của ReentrantLock, ReentrantReadWriteLock, Semaphore, CountDownLatch: một biến state kiểu int cập nhật bằng CAS cộng hàng đợi FIFO thread chờ.
  • LockSupport.park/unpark là tầng ngủ/đánh thức thấp nhất; permit cho phép unpark đến trước park mà không mất tín hiệu — điều wait/notify không làm được.
  • Chọn cơ chế từ nhẹ tới nặng: volatileAtomic*synchronizedReentrantLock → read-write → stamped; mỗi nấc thêm sức mạnh là thêm bề mặt để sai.

Khối Synchronization khép lại ở đây với một nhận xét quan trọng: tự dựng giao thức khóa có thể làm đúng, nhưng dễ sai và tốn công. Cách đạt thread safety bền nhất thường là không tự viết phần đồng bộ, mà giao nó cho những component đã được viết, kiểm thử và tối ưu sẵn - hướng delegation với các concurrent collection. Đó là nơi BookingService của TicketFlow tiến tới v2, và là chủ đề bài sau.

10. Tự kiểm tra

Tự kiểm tra
Q1
Một thread đang giữ read lock của ReentrantReadWriteLock gọi writeLock().lock() — chuyện gì xảy ra, và vì sao?
Thread đó tự treo vĩnh viễn. Write lock chỉ được cấp khi mọi read lock đã nhả, kể cả read lock của chính thread đang xin. Thread vì thế đứng chờ chính mình buông read lock, nhưng nó đang block trong lock() nên không bao giờ chạy đến chỗ unlock() — một vòng chờ khép kín trong đúng một thread, không cần thread thứ hai nào tham gia.
Q2
Vì sao JDK không cung cấp thao tác upgrade an toàn từ read lock lên write lock?
Vì ngữ nghĩa "giữ read lock và chờ các reader khác nhả rồi nâng cấp" tự mâu thuẫn khi có nhiều hơn một thread muốn nâng cấp. Hai reader cùng xin upgrade sẽ chờ nhau buông read lock mãi mãi — deadlock đôi không lối thoát. Thiết kế đúng là buộc lập trình viên nhả read lock, giành write lock như một thao tác mới, rồi kiểm tra lại điều kiện vì state có thể đã đổi trong khe hở. Đây là rationale thiết kế, không phải thiếu sót của JDK.
Q3
Vì sao optimistic read của StampedLock bắt buộc phải đọc hết vào biến cục bộ rồi mới validate, thay vì dùng dữ liệu ngay?
tryOptimisticRead() không giành khóa gì cả, nên trong lúc ta đọc, một writer hoàn toàn có thể đang ghi dở — dữ liệu ta thấy có thể là một state nửa vời, không nhất quán. validate(stamp) là bước xác nhận "không có writer nào chen vào từ lúc lấy stamp": chỉ khi nó trả về true, dữ liệu đã đọc mới được coi là nhất quán. Hành động trước khi validate — nhất là dereference một tham chiếu có thể đã bị ghi đè — là dùng dữ liệu chưa được xác thực, có thể dẫn đến crash hoặc kết quả sai khó tái hiện.
Q4
Vì sao tính non-reentrant của StampedLock đặc biệt nguy hiểm với người đã quen synchronized và ReentrantLock?
Vì hai cơ chế kia đều reentrant: thread đang giữ khóa gọi tiếp method cũng giành chính khóa đó thì chỉ tăng acquisition count rồi đi tiếp, nên thói quen "method khóa gọi method khóa" không gây hại. Với StampedLock, cùng cấu trúc gọi đó là deadlock tức thì: thread giữ write lock xin lại write lock trên cùng object sẽ chờ chính nó nhả — vĩnh viễn. Bug này thường ẩn trong refactor tưởng vô hại (tách một method lớn thành hai method đều giành khóa), và chỉ phát nổ ở runtime.
Q5
park/unpark của LockSupport khác wait/notify ở điểm then chốt nào, và permit đóng vai trò gì?
unpark(thread) có thể được gọi trước khi thread đó park() mà tín hiệu không bị mất: nó để lại một permit, khiến lần park kế tiếp trả về ngay. notify thì ngược lại — nếu được gọi khi chưa có ai wait, tín hiệu biến mất không dấu vết, sinh ra loại race "đánh thức trước khi kịp ngủ". Ngoài ra unpark nhắm đích một thread cụ thể, còn notify chọn ngẫu nhiên trong wait set; và park không đòi hỏi đang giữ khóa nào. Chính các tính chất này khiến AQS chọn park/unpark làm tầng ngủ/đánh thức.
Q6
AQS quản lý những gì mà cả ReentrantLock, Semaphore lẫn CountDownLatch đều dựng được trên nó?
AQS quản lý đúng hai thứ: một biến state kiểu int cập nhật bằng CAS, và một hàng đợi FIFO các thread đang chờ (park). Mỗi synchronizer chỉ cần định nghĩa ý nghĩa của state và điều kiện giành/nhả: với ReentrantLock đó là acquisition count, với Semaphore là số permit còn lại, với CountDownLatch là số đếm còn lại. Toàn bộ phần khó — xếp hàng, park/unpark đúng thread, fair/unfair, timeout, interruptible — AQS làm một lần cho tất cả.
Q7
Khi nào ReentrantReadWriteLock lại chậm hơn một ReentrantLock thường, dù nghe có vẻ luôn ưu việt hơn?
Khi ghi nhiều, hoặc khi mỗi lần đọc cực ngắn. Read-write lock phải làm bookkeeping nặng hơn mutex thường — đếm reader, phối hợp hai chế độ khóa — nên mỗi lần giành/nhả đắt hơn. Nếu writer xuất hiện thường xuyên, reader liên tục bị chặn và phần "song song hóa đọc" không còn gì để thu; nếu mỗi lần đọc chỉ vài chục nano giây, chi phí bookkeeping nuốt luôn phần lợi. Quy tắc: chỉ chuyển sang read-write lock khi đo đạc xác nhận đọc áp đảo ghi và critical section đọc đủ dài.

Bài tiếp theo: Delegation & concurrent collections

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên