Java Internals & Concurrency/Condition — nhiều wait set và await trong vòng while
19/75
Bài 19 / 75~11 phútConcurrency cơ bảnMiễn phí lượt xem

Condition — nhiều wait set và await trong vòng while

Một Lock tạo nhiều Condition độc lập — đánh thức đúng nhóm thread chờ đúng điều kiện; await nhả khóa (khác sleep) và luôn nằm trong vòng while vì spurious wakeup.

TL;DR: Condition là phiên bản explicit của wait set, thay wait/notify của intrinsic lock. Điểm mạnh quyết định: một Lock tạo được nhiều Condition độc lập, mỗi cái một hàng chờ riêng — nên signal đánh thức đúng nhóm thread chờ đúng điều kiện, thay vì notifyAll dựng dậy tất cả rồi phần lớn ngủ lại. Cái giá là hai kỷ luật không được quên: await/signal chỉ gọi khi đang giữ lock, và await luôn nằm trong vòng while — vì signal không trao khóa thẳng cho thread chờ (kẻ khác chen được), và vì spurious wakeup. await khác sleep ở chỗ nó nhả khóa trong lúc chờ rồi tự giành lại.

1. Vì sao một khóa cần nhiều hàng chờ?

Intrinsic lock đi kèm bộ ba wait/notify/notifyAll — cơ chế guarded block ở bài wait/notify — để một thread chờ đến khi điều kiện thành đúng rồi được thread khác đánh thức. Nhưng nó chỉ có đúng một wait set: mọi thread chờ vì bất kỳ lý do gì đều nằm chung một chỗ, và notifyAll đánh thức tất cả dù phần lớn sẽ thấy điều kiện chưa thỏa rồi ngủ lại — lãng phí, và đôi khi là nguồn lỗi tinh vi.

Condition là phiên bản explicit của wait set trên một ReentrantLock, và điểm mạnh là một Lock tạo được nhiều Condition độc lập: await() thay wait(), signal() thay notify(), signalAll() thay notifyAll(). Quy tắc bất di bất dịch: phải đang giữ lock mới được gọi await/signal, và await tự nhả lock trong lúc chờ rồi tự giành lại trước khi trả về. Hãy hình dung waitlist cho sự kiện đã hết vé: có vé hủy quay lại kho thì đánh thức đúng người đang chờ vé, không dựng dậy thread chờ vì lý do khác.

private final ReentrantLock lock = new ReentrantLock();
private final Condition seatAvailable = lock.newCondition();   // wait set rieng cho "co ve"
private int remaining;                                         // @GuardedBy("lock")
public Booking bookOrWait(String eventId, String userId) throws InterruptedException {
    lock.lock();
    try {
        while (remaining == 0) seatAvailable.await();   // luon while (khong phai if); await nha lock roi gianh lai
        remaining--;
        return new Booking(eventId, userId, remaining);
    } finally { lock.unlock(); }
}

public void release() {                                 // co ve huy, quay lai kho
    lock.lock();
    try { remaining++; seatAvailable.signal(); }        // danh thuc dung MOT nguoi dang cho ve
    finally { lock.unlock(); }
}

Hành trình của thread chờ vé qua await/signal đi qua hai hàng đợi khác nhau — condition queue (nơi nằm chờ điều kiện) và entry queue (nơi xếp hàng giành lại khóa):

Thread chuyển từ condition queue sang entry queue sau signal, rồi mới tranh lại khoá

Để ý bước cuối: signal không trao khóa thẳng cho thread đang chờ, nó chỉ chuyển thread đó sang hàng đợi giành khóa. Giữa lúc được signal và lúc thực sự chạy lại, một thread thứ ba có thể chen vào lấy mất vé. Đó là lý do thứ nhất khiến vòng while quanh await không phải tùy chọn. Lý do thứ hai là spurious wakeup — thread tỉnh dậy mà không ai signal, hiện tượng chính Javadoc của await thừa nhận. Cả hai dẫn về cùng kỷ luật: kiểm tra lại điều kiện sau mỗi lần await trả về — việc while làm còn if thì không.

2. Hai điều kiện trên một khóa: bounded buffer

Sức mạnh thật sự lộ ra khi có nhiều điều kiện chờ trên cùng một khóa. Một bounded buffer cần hai: producer chờ khi buffer đầy, consumer chờ khi buffer rỗng. Với một wait set duy nhất, mọi notify đánh thức lẫn lộn cả hai phía. Với notFullnotEmpty, producer chỉ đánh thức consumer và ngược lại.

💡 Tự điền trước khi xem

Trong skeleton dưới, hai chỗ /* TODO */ là điều kiện await và Condition để signal. Tự quyết trước: put() khi buffer đầy thì chờ trên Condition nào, và sau khi thêm phần tử thì đánh thức phía nào? take() thì ngược lại ra sao? Viết ra lựa chọn của bạn rồi so với đáp án ngay dưới.

private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull  = lock.newCondition();   // producer cho o day
private final Condition notEmpty = lock.newCondition();   // consumer cho o day
public void put(T item) throws InterruptedException {
    lock.lock();
    try {
        while (count == items.length) notFull.await();   // buffer day -> producer ngu
        enqueue(item);                                   // items[putIndex++], count++
        notEmpty.signal();                               // danh thuc dung MOT consumer
    } finally { lock.unlock(); }
}

public T take() throws InterruptedException {
    lock.lock();
    try {
        while (count == 0) notEmpty.await();             // buffer rong -> consumer ngu
        T item = dequeue();                              // items[takeIndex++], count--
        notFull.signal();                                // danh thuc dung MOT producer
        return item;
    } finally { lock.unlock(); }
}

Mấu chốt là mỗi bên signal phía đối diện: put vừa thêm một phần tử nên buffer chắc chắn "không rỗng" — đánh thức consumer đang chờ notEmpty. take vừa lấy một phần tử nên buffer chắc chắn "không đầy" — đánh thức producer đang chờ notFull. Nếu signal nhầm phía (put đánh thức notFull), thread được dựng dậy kiểm tra điều kiện của nó thấy vẫn chưa thỏa rồi ngủ lại, còn thread thật sự có thể tiến lên thì không ai gọi — hệ thống có thể treo dù buffer không đầy không rỗng.

Đó là tách wait set mà synchronized không làm được. Đoạn trên gần như nguyên văn skeleton của ArrayBlockingQueue trong JDK — gặp lại nó ở bài blocking queues, bạn sẽ thấy quen từng dòng.

3. await không phải sleep, và signal không phải signalAll

await dễ bị nhầm với Thread.sleep vì cả hai đều "cho thread ngủ", nhưng khác ở điểm cốt tử: await nhả khóa trong lúc chờ rồi tự giành lại, còn sleep ôm nguyên mọi khóa đang giữ mà ngủ. Một thread sleep trong critical section chặn mọi thread khác đúng bằng thời gian nó ngủ; await thì nhường khóa — đó là điều cho phép release() ở §1 giành được khóa để cộng vé và signal.

Còn giữa signalsignalAll: khi mỗi Condition gom đúng một loại waiter chờ đúng một điều kiện (như notFull/notEmpty ở trên), signal đánh thức một thread là đủ — thread nào tỉnh cũng tiến lên được. Nhưng nếu bạn gộp nhiều loại waiter chờ các điều kiện khác nhau vào cùng một Condition, phải dùng signalAll: signal chỉ đánh thức một thread, và nếu trúng thread mà điều kiện của nó chưa thỏa, nó ngủ lại còn thread thật sự tiến được thì không ai gọi — tín hiệu "mất hút". Tách Condition theo điều kiện (rồi signal) hầu như luôn tốt hơn gộp chung (rồi signalAll), vì tránh được cả đánh thức thừa lẫn rủi ro mất tín hiệu.

4. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

  • Condition (Java 21 API) — spurious wakeup được thừa nhận chính thức, kèm yêu cầu "await phải trong vòng lặp" và chính ví dụ bounded buffer hai condition.
  • ArrayBlockingQueue (Java 21 API) — hiện thực thật của pattern hai Condition trong §2.
  • Java Concurrency in Practice (Goetz et al.), chương 14 — condition queue, condition predicate, và vì sao luôn kiểm lại điều kiện trong vòng lặp.

Ghi chú: Javadoc của Condition đáng đọc trọn vẹn — vừa là spec vừa là bài giảng ngắn về wait set tách rời.

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

  • Bài 09 — wait/notify & guarded blocks: wait set của intrinsic lock mà Condition là phiên bản explicit, nhiều-wait-set; cùng kỷ luật "chờ trong vòng while".
  • Bài 11 — ReentrantLock: nửa đầu của explicit lock — kỷ luật lock()/unlock()Condition xây lên trên; newCondition() chỉ gọi được trên một Lock.
  • Bài 15 — Blocking queues: ArrayBlockingQueue hiện thực đúng pattern hai Condition của §2, đóng gói sẵn cho bạn.

6. Tóm tắt

  • Condition là wait set explicit thay wait/notify; một Lock tạo được nhiều Condition độc lập — điều intrinsic lock (một wait set) không có.
  • await/signal chỉ gọi khi đang giữ lock; await nhả khóa trong lúc chờ (khác sleep) rồi tự giành lại trước khi trả về.
  • Luôn await trong vòng while: signal chỉ chuyển thread sang hàng đợi giành khóa (kẻ khác chen được), cộng spurious wakeup.
  • Bounded buffer dùng hai Condition notFull/notEmpty, mỗi bên signal phía đối diện — signal nhầm phía có thể treo hệ thống.
  • Tách Condition theo điều kiện rồi signal tốt hơn gộp chung rồi signalAll — tránh cả đánh thức thừa lẫn mất tín hiệu.

7. Tự kiểm tra

Tự kiểm tra
0/6 câu đã trả lời
  1. Q1
    Condition.await() khác Thread.sleep() ở những điểm nào?
  2. Q2
    Vì sao điều kiện chờ phải kiểm tra trong vòng while quanh await(), dùng if thì sai ở đâu?
  3. Q3
    Một Lock nhiều Condition hơn gì so với một wait set duy nhất của synchronized?
  4. Q4
    Trong bounded buffer, vì sao put() signal notEmpty còn take() signal notFull? Signal nhầm phía thì sao?
  5. Q5
    Chuyện gì xảy ra nếu gọi await() hoặc signal() mà không đang giữ lock?
  6. Q6
    Khi nào signal đủ, khi nào bắt buộc signalAll?

Bài tiếp theo: ReadWriteLock, StampedLock & AQS — khi đọc áp đảo ghi

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Bài tiếp theo

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