waitnotify là cơ chế phối hợp luồng có từ Java 1.0. Chúng vẫn nằm trên Object, nghĩa là mọi đối tượng Java đều có, và đó là một phần của vấn đề: chúng luôn sẵn ở đó, trông đơn giản, và rất khó dùng đúng.

Bài này viết tay một hàng đợi sản xuất – tiêu thụ, làm hỏng nó theo hai cách kinh điển để xem hỏng thật ra sao, rồi vứt hết đi và viết lại bằng sáu dòng.

Luật đầu tiên: phải đang giữ khoá

  IllegalMonitorStateException

Gọi wait() hay notify() khi không ở trong khối synchronized của chính đối tượng đó là ném ngoại lệ ngay. Không phải khoá nào cũng được — phải đúng khoá của đối tượng bạn gọi wait lên.

Điều này hợp lý khi nghĩ kỹ: wait() nhả khoá ra rồi mới ngủ, nên nó phải đang giữ khoá đã. Và đây là điểm hay bị bỏ sót — wait() nhả khoá, sleep() thì không. Một luồng sleep bên trong synchronized sẽ giam khoá suốt thời gian ngủ.

Bản viết tay

synchronized void put(int v) throws InterruptedException {
    while (q.size() == sucChua) wait();      // hàng đầy -> chờ
    q.add(v);
    notifyAll();
}

synchronized int take() throws InterruptedException {
    while (q.isEmpty()) wait();              // hàng rỗng -> chờ
    int v = q.poll();
    notifyAll();
    return v;
}

Mười dòng, và mỗi dòng đều cần thiết. Giờ ta phá nó theo đúng hai cách mà tôi đã gặp trong mã thật.

Lỗi một: if thay vì while

Đổi while (q.isEmpty()) wait(); thành if (q.isEmpty()) wait();. Bốn nhà sản xuất, bốn người tiêu thụ, mỗi bên 20.000 phần tử:

  dùng if:
    lần 1: lấy được 79.776/80.000 | số lần trạng thái sai: 80.183
    lần 2: lấy được 79.980/80.000 | số lần trạng thái sai: 75.674
    lần 3: lấy được 79.995/80.000 | số lần trạng thái sai: 64.289

  dùng while:
    lần 1: lấy được 80.000/80.000 | số lần trạng thái sai: 0
    lần 2: lấy được 80.000/80.000 | số lần trạng thái sai: 0
    lần 3: lấy được 80.000/80.000 | số lần trạng thái sai: 0

Mất hàng, và hơn sáu mươi nghìn lần hàng đợi ở trạng thái đáng lẽ không thể xảy ra — vượt sức chứa, hoặc lấy từ hàng rỗng.

Lý do: wait() trả về không có nghĩa là điều kiện đã đúng. Nó chỉ có nghĩa "bạn được đánh thức và đã lấy lại khoá". Trong quãng giữa lúc được đánh thức và lúc lấy lại được khoá, một luồng khác hoàn toàn có thể chen vào và lấy mất phần tử.

Với if, luồng kiểm một lần rồi chạy tiếp mù quáng. Với while, nó kiểm lại — và đó là toàn bộ khác biệt.

Còn một lý do thứ hai, độc lập: đánh thức giả. Đặc tả Java cho phép wait() trả về mà không ai gọi notify cả. Hiếm, nhưng hợp lệ, và while xử lý luôn cả trường hợp đó.

Luôn gọi wait() trong vòng while, không bao giờ trong if. Không có ngoại lệ nào cho quy tắc này. Nếu bạn thấy if (…) wait(); trong mã, đó là một lỗi đang chờ đến giờ.

Lỗi hai: notify() thay vì notifyAll()

Cái này tinh vi hơn, và tôi phải thử vài lần mới ép nó lộ ra.

Với hàng đợi sức chứa 5, notify() chạy đúng qua tất cả các lần thử của tôi. Phải hạ sức chứa xuống 1 — tức là ép hàng đợi thường xuyên rơi vào trạng thái mọi luồng đều phải chờ — thì nó mới hỏng:

  sức chứa 1, 8 sản xuất, 2 tiêu thụ, notify()    -> treo 5/5 lần
  sức chứa 1, 8 sản xuất, 2 tiêu thụ, notifyAll() -> treo 0/5 lần
  sức chứa 1, 2 sản xuất, 8 tiêu thụ, notify()    -> treo 5/5 lần
  sức chứa 1, 2 sản xuất, 8 tiêu thụ, notifyAll() -> treo 0/5 lần
  sức chứa 1, 6 sản xuất, 6 tiêu thụ, notify()    -> treo 5/5 lần
  sức chứa 1, 6 sản xuất, 6 tiêu thụ, notifyAll() -> treo 0/5 lần
  sức chứa 2, 8 sản xuất, 8 tiêu thụ, notify()    -> treo 4/5 lần
  sức chứa 2, 8 sản xuất, 8 tiêu thụ, notifyAll() -> treo 0/5 lần

Treo hẳn, vĩnh viễn.

Nguyên nhân: nhà sản xuất và người tiêu thụ chờ trên cùng một khoá, nhưng chờ hai điều kiện khác nhau. notify() đánh thức một luồng bất kỳ đang chờ — JVM không biết bạn muốn đánh thức ai.

Nên chuyện này xảy ra: một người tiêu thụ lấy xong phần tử, gọi notify(), và JVM đánh thức... một người tiêu thụ khác. Người đó tỉnh dậy, thấy hàng vẫn rỗng, quay lại chờ. Nhà sản xuất đang chờ hàng vơi thì không ai đánh thức. Tất cả cùng ngủ, mãi mãi.

Đây gọi là mất tín hiệu đánh thức, và chú ý điều đáng sợ nhất trong bảng trên: với sức chứa 5 nó không xảy ra lần nào. Cùng đoạn mã sai ấy chạy hoàn hảo trong thử nghiệm, rồi treo khi tải đổi.

Quy tắc an toàn: dùng notifyAll(), trừ khi bạn chắc chắn mọi luồng chờ trên khoá đó đều chờ cùng một điều kiện. notifyAll() tốn hơn vì đánh thức thừa, nhưng "chậm hơn một chút" đổi lấy "không treo" là món hời.

Nếu muốn cả đúng lẫn hiệu quả, ReentrantLock cho phép tạo nhiều Condition trên cùng một khoá — một cho "hàng đầy", một cho "hàng rỗng" — rồi đánh thức đúng nhóm cần thiết:

Condition khiVoi = k.newCondition();
Condition khiDay = k.newCondition();

Rồi vứt hết và viết lại

BlockingQueue<Integer> q = new ArrayBlockingQueue<>(5);

q.put(k);           // hàng đầy thì tự chờ
int v = q.take();   // hàng rỗng thì tự chờ
  xong=true, lấy được 80.000/80.000 — không một dòng wait/notify nào

Hai phương thức thay cho toàn bộ phần trên, và không có chỗ nào để mắc hai lỗi vừa rồi.

Đây là lời khuyên chính của bài, và tôi nói nó thẳng thắn: đừng viết wait/notify trong mã ứng dụng. Học để đọc hiểu mã cũ và để biết thứ bên dưới hoạt động ra sao thì rất đáng; còn viết mới thì BlockingQueue ngắn hơn, đúng hơn, và đã được kiểm chứng bởi rất nhiều người.

Chọn loại hàng đợi nào

Bốn luồng sản xuất, bốn luồng tiêu thụ, 200.000 phần tử:

  ArrayBlockingQueue(1000)  :  24 ms
  LinkedBlockingQueue(1000) :  27 ms
  LinkedBlockingQueue(∞)    :  10 ms
  SynchronousQueue          : 748 ms

ArrayBlockingQueue — mảng vòng, sức chứa cố định, một khoá cho cả hai đầu. Lựa chọn mặc định tốt: sức chứa có giới hạn nghĩa là khi bên tiêu thụ chậm lại, bên sản xuất bị chặn lại theo — đó là áp lực ngược, và nó là tính năng chứ không phải hạn chế.

LinkedBlockingQueue — danh sách liên kết, hai khoá tách riêng cho đầu vào và đầu ra, nên sản xuất và tiêu thụ ít dẫm chân nhau hơn khi nhiều luồng.

LinkedBlockingQueue không giới hạn nhanh nhất — và đó là cái bẫy. Nó nhanh vì bên sản xuất không bao giờ phải chờ. Nhưng nếu bên tiêu thụ chậm hơn bên sản xuất, hàng đợi lớn dần cho tới khi hết bộ nhớ. Con số 10 ms ở trên chính là hình ảnh của một quả bom hẹn giờ chạy nhanh. Luôn đặt sức chứa.

SynchronousQueue chậm hơn ba mươi lần vì nó không chứa gì cả — mỗi lần trao là một cuộc hẹn, người đưa phải chờ đúng người nhận. Nghe vô dụng, nhưng đó chính là thứ Executors.newCachedThreadPool() dùng: không nhận được ngay thì tạo luồng mới. Nó là công cụ trao tay trực tiếp, không phải bộ đệm.

Ngoài ra còn PriorityBlockingQueue (lấy theo thứ tự ưu tiên, không giới hạn sức chứa) và DelayQueue (phần tử chỉ lấy được sau khi hết hạn chờ — nền tảng của các bộ lập lịch).

Bốn cặp phương thức, bốn hành vi khi hàng đầy

Đây là bảng tôi phải tra lại nhiều lần cho tới khi thuộc:

Khi hàng đầy / rỗng
put / take chặn cho tới khi được
offer / poll trả về false / null ngay
offer(v, 3, SECONDS) / poll(3, SECONDS) chờ có hạn rồi bỏ cuộc
add / remove ném ngoại lệ

Trong mã sản xuất, tôi gần như luôn dùng put/take, hoặc bản có hạn chờ khi cần tắt máy gọn gàng. add ném IllegalStateException khi hàng đầy — hiếm khi là thứ bạn muốn.

Dừng người tiêu thụ cho gọn

Một luồng đang nằm trong take() sẽ chờ mãi. Làm sao bảo nó nghỉ?

Cách gọn nhất là viên thuốc độc — một giá trị đặc biệt nghĩa là "hết việc rồi":

for (int i = 0; i < soNguoiTieuThu; i++) q.put(THUOC_DOC);   // mỗi người một viên
  cả 4 người tiêu thụ tự dừng: true, xử lý 50.000 phần tử

Điểm cần nhớ: phải đủ một viên cho mỗi người tiêu thụ, vì mỗi viên chỉ dừng được một luồng. Và phải bỏ vào sau toàn bộ dữ liệu thật, nếu không có người dừng sớm khi vẫn còn việc.

Cách thứ hai là interrupt()take() phản ứng với ngắt và ném InterruptedException. Đây là lúc bài học ở bài 67 trả công: nếu ai đó viết catch (InterruptedException e) {} trống trong vòng lặp tiêu thụ, luồng sẽ không chịu dừng.

Thử ba mươi giây

Tìm trong dự án của bạn từ khoá wait(notify.

Với mỗi chỗ tìm được, kiểm hai điều: wait() có nằm trong while không, và có phải notifyAll() không. Sai một trong hai thì bạn đang mang một trong hai lỗi ở trên — và như bảng đo cho thấy, cả hai đều chạy hoàn hảo cho tới đúng ngày tải thay đổi.

Ngày mai: ExecutorService và pool luồng — thôi tự tạo Thread, các kiểu pool dựng sẵn, và chỗ mỗi kiểu sẽ hỏng.