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):
sequenceDiagram
participant C as Consumer (bookOrWait)
participant L as Lock + Condition
participant P as Producer (release)
C->>L: lock() - gianh khoa
C->>C: kiem tra: remaining == 0
C->>L: await() - nha khoa, vao condition queue
Note over C: ngu trong condition queue
P->>L: lock() - gianh khoa (vua duoc nha)
P->>P: remaining++
P->>L: signal() - chuyen 1 thread cho<br/>tu condition queue sang entry queue
P->>L: unlock()
Note over C: tinh day o entry queue, gianh lai khoa
L-->>C: await() tra ve (dang giu khoa)
C->>C: while kiem tra lai: remaining > 0 ?
C->>L: unlock()Để ý 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 notFull và notEmpty, producer chỉ đánh thức consumer và ngược lại.
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 signal và signalAll: 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
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
Conditiontrong §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à
Conditionlà 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()màConditionxây lên trên;newCondition()chỉ gọi được trên mộtLock. - Bài 15 — Blocking queues:
ArrayBlockingQueuehiện thực đúng pattern haiConditioncủa §2, đóng gói sẵn cho bạn.
6. Tóm tắt
Conditionlà wait set explicit thaywait/notify; mộtLocktạo được nhiềuConditionđộc lập — điều intrinsic lock (một wait set) không có.await/signalchỉ gọi khi đang giữ lock;awaitnhả khóa trong lúc chờ (khácsleep) rồi tự giành lại trước khi trả về.- Luôn
awaittrong vòngwhile:signalchỉ 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
ConditionnotFull/notEmpty, mỗi bênsignalphía đối diện —signalnhầm phía có thể treo hệ thống. - Tách
Conditiontheo điều kiện rồisignaltốt hơn gộp chung rồisignalAll— tránh cả đánh thức thừa lẫn mất tín hiệu.
7. Tự kiểm tra
Q1Condition.await() khác Thread.sleep() ở những điểm nào?▸
await() nhả khóa trong lúc chờ và tự giành lại trước khi trả về, còn sleep() ôm nguyên mọi khóa đang giữ mà ngủ — thread khác không thể vào critical section để thay đổi điều kiện. Thứ hai, await được đánh thức có chủ đích bằng signal khi điều kiện có thể đã thỏa, còn sleep chỉ tỉnh khi hết giờ — chờ điều kiện bằng sleep là polling, vừa chậm vừa tốn. Thứ ba, await bắt buộc gọi khi đang giữ lock gắn với Condition, còn sleep gọi ở đâu cũng được.Q2Vì sao điều kiện chờ phải kiểm tra trong vòng while quanh await(), dùng if thì sai ở đâu?▸
signal không trao khóa thẳng cho thread đang chờ mà chỉ chuyển nó 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 tài nguyên, khiến điều kiện sai trở lại. Hai, spurious wakeup: thread có thể tỉnh dậy mà không hề có ai signal, hiện tượng được Javadoc của await chính thức cho phép. Với if, cả hai trường hợp đều khiến code đi tiếp trên một điều kiện không còn đúng; while bắt kiểm tra lại sau mỗi lần tỉnh, chỉ đi tiếp khi điều kiện thật sự thỏa.Q3Một Lock nhiều Condition hơn gì so với một wait set duy nhất của synchronized?▸
notifyAll — dựng dậy tất cả, phần lớn kiểm điều kiện thấy chưa thỏa rồi ngủ lại (lãng phí context switch). Nhiều Condition cho phép tách waiter theo điều kiện: mỗi hàng chờ gom đúng một loại, nên signal một thread là trúng người tiến được. Kết quả là ít đánh thức thừa và ít rủi ro lỗi tinh vi hơn — đây là lý do chính để chọn Condition thay wait/notify.Q4Trong bounded buffer, vì sao put() signal notEmpty còn take() signal notFull? Signal nhầm phía thì sao?▸
put 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 lấy một phần tử nên buffer chắc chắn "không đầy" — đánh thức producer chờ notFull. Nếu signal nhầm phía (put gọi notFull), thread được dựng dậy kiểm tra điều kiện của mình thấy vẫn chưa thỏa rồi ngủ lại, còn thread thật sự tiến được thì không ai gọi — buffer không đầy không rỗng nhưng cả hai phía cùng treo.Q5Chuyện gì xảy ra nếu gọi await() hoặc signal() mà không đang giữ lock?▸
IllegalMonitorStateException ngay lập tức. Condition gắn với một Lock cụ thể (tạo qua lock.newCondition()), và cả await lẫn signal đều đòi thread gọi phải đang giữ chính khóa đó — giống hệt ràng buộc wait/notify phải nằm trong khối synchronized trên đúng object. Lý do: await cần nhả khóa một cách nguyên tử khi vào condition queue, và điều đó chỉ định nghĩa được nếu thread đang thật sự giữ khóa. Vì thế mọi await/signal luôn nằm giữa lock() và unlock().Q6Khi nào signal đủ, khi nào bắt buộc signalAll?▸
Condition gom đúng một loại waiter chờ đúng một điều kiện (như notFull/notEmpty), signal đánh thức một thread là đủ — thread nào tỉnh cũng tiến lên được, không sợ trúng nhầm. Nhưng nếu 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 signalAll: signal chỉ đánh thức một thread, nếu trúng thread mà điều kiện chưa thỏa thì nó ngủ lại còn thread tiến được không ai gọi — tín hiệu mất hút, hệ thống có thể treo. Cách gọn hơn là tách Condition theo điều kiện rồi signal, tránh luôn cả nhu cầu 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
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