synchronized gọn và khó dùng sai: khoá tự nhả khi thoát khối, kể cả khi có ngoại lệ. Với phần lớn trường hợp, nó là lựa chọn đúng.

Bài này về bốn thứ nó không làm được, và về hai cái bẫy khi dùng thứ thay thế.

Đổi lại, bạn phải tự nhả khoá

ReentrantLock k = new ReentrantLock();
k.lock();
try {
    // ...
} finally {
    k.unlock();      // BẮT BUỘC, và phải nằm trong finally
}

Quên unlock() là khoá bị giữ vĩnh viễn và mọi luồng khác treo. Đặt unlock() ngoài finally thì chỉ cần một ngoại lệ là hỏng y như vậy.

Đây là toàn bộ cái giá của ReentrantLock. synchronized không bao giờ mắc lỗi này vì JVM lo hộ. Nên lời khuyên của tôi: dùng synchronized mặc định, chỉ đổi sang ReentrantLock khi cần một trong những thứ dưới đây — chứ không phải vì nghe nói nó nhanh hơn.

Một chi tiết dễ vấp: lock() phải gọi ngoài khối try. Đặt nó bên trong thì khi lock() ném lỗi, finally vẫn chạy unlock() trên một khoá bạn chưa hề lấy được — và nhận IllegalMonitorStateException che mất lỗi thật.

Một: thoát khỏi deadlock

Bài 68 dựng một deadlock kinh điển: hai luồng lấy hai khoá theo thứ tự ngược nhau, cả hai treo vĩnh viễn, không có gì cứu được.

Với tryLock có thời hạn thì có:

if (x.tryLock(50, TimeUnit.MILLISECONDS)) {
    try {
        if (y.tryLock(50, TimeUnit.MILLISECONDS)) {
            try { /* làm việc */ } finally { y.unlock(); }
        } else boCuoc.incrementAndGet();     // nhả x ra rồi thử lại từ đầu
    } finally { x.unlock(); }
}
  cả hai luồng đều KẾT THÚC, số lần phải bỏ cuộc: 182
  (với synchronized thì hai luồng này treo vĩnh viễn)

Điểm mấu chốt nằm ở chữ nhả x ra. Lấy được khoá thứ nhất mà không lấy được khoá thứ hai thì trả lại khoá thứ nhất rồi thử lại từ đầu. Không luồng nào giữ khư khư một nửa, nên chu trình chờ không hình thành được.

182 lần bỏ cuộc là công phí, nhưng đổi lại chương trình chạy xong. Đây là đánh đổi đáng giá ở những chỗ thứ tự khoá không kiểm soát được hoàn toàn.

tryLock() không tham số còn khác một điểm quan trọng: nó bỏ qua hàng đợi công bằng và chộp khoá ngay nếu đang rảnh. Cần đúng ngữ nghĩa xếp hàng thì dùng tryLock(0, TimeUnit.SECONDS).

Hai: chờ khoá mà vẫn ngắt được

    bị ngắt trong lúc chờ khoá -> thoát được

lock()synchronized không phản ứng với interrupt(). Luồng đang chờ khoá cứ chờ, dù bạn có gọi interrupt() bao nhiêu lần.

lockInterruptibly() thì ném InterruptedException và cho luồng thoát ra.

Chuyện này quan trọng đúng lúc bạn cần tắt ứng dụng. Một luồng đang chờ khoá bằng synchronized sẽ không chịu dừng, và bạn quay lại đúng vấn đề "ứng dụng không thoát được" ở bài 67.

Ba: khoá công bằng — và cái giá của nó

new ReentrantLock(true) bật chế độ công bằng: khoá được trao theo thứ tự xin, không ai chen ngang.

Nghe rất hợp lý. Đây là giá phải trả, đo trong 2 giây với 8 luồng liên tục lấy và nhả khoá:

  không công bằng : tổng 337.198.862 lần | ít nhất 27.112.640 | nhiều nhất 52.480.226 | lệch 1,9 lần
  công bằng       : tổng     127.501 lần | ít nhất     15.860 | nhiều nhất     16.329 | lệch 1,0 lần

Đọc kỹ hai con số tổng: 337 triệu so với 127 nghìn. Khoá công bằng làm được ít hơn hai nghìn sáu trăm lần.

Với khối lượng công việc cố định, cùng chênh lệch ấy hiện ra ở thời gian chạy: 14 ms so với 3902 ms.

Đổi lại, phân phối hoàn hảo: mọi luồng nhận xấp xỉ 16.000 lượt, lệch 1,0 lần. Còn chế độ mặc định thì luồng may mắn nhất được gấp 1,9 lần luồng kém may.

Nguyên nhân của khoảng cách khổng lồ này: chế độ không công bằng cho phép một luồng vừa nhả khoá xong thì lấy lại ngay nếu nó vẫn đang chạy trên CPU. Không phải đánh thức luồng khác, không phải chuyển ngữ cảnh — và chuyển ngữ cảnh chính là thứ đắt. Chế độ công bằng cấm chuyện đó, nên mỗi lần trao khoá là một lần đánh thức một luồng đang ngủ.

Gần như đừng bao giờ bật chế độ công bằng. Nó chỉ đáng khi thời gian giữ khoá dài (nên chi phí đánh thức không đáng kể) bạn thật sự quan sát thấy có luồng bị bỏ đói. Bật nó "cho chắc" là tự cắt thông lượng đi hàng nghìn lần.

Chú ý thêm: lệch 1,9 lần ở chế độ mặc định không phải là bỏ đói. Không luồng nào đứng ngoài cả — chỉ là chia không đều. Bỏ đói thật là khi có luồng gần như không bao giờ tới lượt, và điều đó hiếm hơn nhiều so với người ta lo.

Bốn: nhìn thấy trạng thái khoá

  isLocked()              : true
  getHoldCount()          : 2      <- đếm số lần tái nhập
  isHeldByCurrentThread() : true
  getQueueLength()        : 0

synchronized là hộp đen. ReentrantLock cho bạn hỏi ai đang giữ, bao nhiêu luồng đang xếp hàng, đã tái nhập mấy lần.

getQueueLength() đặc biệt hữu ích khi đưa lên bảng giám sát: con số này lớn dần nghĩa là khoá đang trở thành chỗ nghẽn, và bạn biết trước khi người dùng phàn nàn.

Và đừng quên lý do đã nói ở bài 68: trên Java 21, ReentrantLock không ghim luồng ảo còn synchronized thì có. Với mã chạy luồng ảo, đây có thể là lý do quan trọng nhất trong cả danh sách này.

ReadWriteLock: khi nào đáng, khi nào không

Ý tưởng rất hấp dẫn: nhiều luồng đọc cùng lúc thì chẳng hại gì, chỉ cần loại trừ khi có ai ghi. Cho tải đọc-là-chính, nghe như thắng miễn phí.

Tôi đo với 8 luồng đọc song song, thay đổi thời gian giữ khoá mỗi lần đọc:

  thời gian đọc   ReentrantLock  ReadWriteLock   nhanh hơn
  ~  1 micro giây          15 ms          23 ms     0,7 lần
  ~  3 micro giây          10 ms           4 ms     2,5 lần
  ~ 23 micro giây          11 ms           2 ms     5,5 lần
  ~ 92 micro giây          41 ms           5 ms     8,2 lần

Dòng đầu tiên là dòng quan trọng nhất: với thao tác đọc rất ngắn, ReadWriteLock chậm hơn khoá thường.

Vì bản thân nó phức tạp hơn — phải đếm số luồng đọc đang giữ, phải phối hợp với luồng ghi đang chờ. Khi việc bên trong khoá chỉ tốn một micro giây, chi phí quản lý đó lớn hơn cả thứ nó tiết kiệm được.

Càng giữ khoá lâu thì càng đáng, và tới 92 micro giây thì đạt 8,2 lần — xấp xỉ đúng số luồng đọc, tức là đã lấy lại gần trọn khả năng song song.

Quy tắc tôi rút ra: đọc phải đủ dài mới bù được chi phí. Đọc một trường, tra một khoá trong map, so sánh vài giá trị — đừng dùng. Duyệt một cấu trúc lớn, tính toán trên một ảnh chụp dữ liệu, dựng lại một đối tượng phức tạp — thì dùng.

Hai điều nữa về ReentrantReadWriteLock:

Không nâng cấp được từ khoá đọc lên khoá ghi. Đang giữ khoá đọc mà gọi writeLock().lock() là tự khoá chính mình, vĩnh viễn. Chiều ngược lại — hạ từ ghi xuống đọc — thì được.

Luồng ghi có thể bị bỏ đói nếu luồng đọc đến liên tục. Bản của Java chặn dần luồng đọc mới khi có luồng ghi đang chờ, nên chuyện này đỡ hơn, nhưng vẫn nên biết.

StampedLock: đọc mà không lấy khoá

Java 8 thêm StampedLock với một chế độ mà hai loại trên không có — đọc lạc quan:

long tem = sl.tryOptimisticRead();      // không khoá gì cả
long v = doc();                         // đọc dữ liệu
if (!sl.validate(tem)) {                // có ai ghi trong lúc đó không?
    long st = sl.readLock();            // có -> đọc lại cho chắc
    try { v = doc(); } finally { sl.unlockRead(st); }
}

Trong phép đo có cả luồng ghi xen vào, so với khoá độc quyền:

  không khoá (mốc dưới, KHÔNG an toàn) : 194 ms
  ReentrantLock (độc quyền)            : 277 ms
  ReadWriteLock                        : 192 ms
  StampedLock, đọc lạc quan            : 189 ms

Đọc lạc quan chạm đúng mốc "không khoá gì" — nó không hề lấy khoá ở đường chạy thuận lợi, chỉ kiểm lại sau khi đọc xong.

Cái giá là mã phức tạp hơn hẳn và có ba luật khắt khe: StampedLock không tái nhập (gọi lồng nhau là tự khoá mình), phần đọc lạc quan phải chịu được việc nhìn thấy dữ liệu không nhất quán trước khi validate bác bỏ nó, và bạn phải nhớ chính xác cái tem để nhả đúng.

Tôi chỉ dùng nó ở chỗ đã đo được là điểm nghẽn thật. Với mọi chỗ khác, ReadWriteLock hoặc synchronized đủ và ít cơ hội sai hơn nhiều.

Chọn cái nào

Tình huống Dùng
Mặc định, khối ngắn synchronized
Cần tryLock, thời hạn chờ, hoặc ngắt được ReentrantLock
Chạy luồng ảo và có thể chặn lâu trong khoá ReentrantLock
Đọc là chính mỗi lần đọc tốn đáng kể ReadWriteLock
Đã đo được đây là điểm nghẽn, đọc cực nhiều StampedLock
Chỉ đếm hoặc cập nhật một biến Atomic* / LongAdder, khỏi khoá

Và câu hỏi nên hỏi trước tất cả: có cách nào không cần chia sẻ trạng thái không? Một ConcurrentHashMap, một BlockingQueue, hay đơn giản là đối tượng bất biến — thường gọn hơn và đúng hơn mọi phương án khoá.

Thử ba mươi giây

Lấy một chỗ trong mã bạn đang dùng ReadWriteLock, đo xem mỗi lần giữ khoá đọc mất bao lâu.

Nếu dưới vài micro giây, đổi sang synchronized và đo lại. Theo bảng ở trên, nhiều khả năng bạn vừa làm nó nhanh hơn đơn giản hơn — ReadWriteLock chỉ trả công khi phần việc bên trong đủ lớn để bù chi phí của chính nó.

Ngày mai quay lại deadlock một cách bài bản: dựng một cái có chủ đích, tìm ra nó bằng jstack trên tiến trình đang treo, rồi sửa bằng thứ tự khoá nhất quán.