Hai người nấu ăn chung một bếp, mỗi người cần cả lọ muối lẫn lọ tiêu để nêm món của mình. Người thứ nhất chộp lọ muối, với tay lấy lọ tiêu. Đúng lúc đó người thứ hai đã cầm lọ tiêu, với tay lấy lọ muối. Giờ mỗi người giữ đúng cái người kia đang cần, và không ai chịu đặt xuống — cả hai đứng đơ, món ăn nguội dần. Đó là deadlock: không phải chậm, mà treo cứng vĩnh viễn. Và để ý điều này: nếu người thứ nhất tình cờ chộp cả hai lọ trước khi người kia bắt đầu, chẳng có chuyện gì xảy ra — bữa ăn trôi qua êm đẹp. Bế tắc chỉ nổ đúng vào khoảnh khắc hai tay với cùng lúc. Chính cái "chỉ đôi khi" đó làm deadlock thành loại bug đáng sợ nhất.

Cho tới giờ mọi cái giá của khóa ta đo đều là chậm — convoy, tranh chấp, đánh thức. Nhưng có một lỗi khóa tệ hơn chậm: treo cứng vĩnh viễn. Đó là deadlock (bế tắc) — khi hai luồng mỗi luồng giữ một khóa và chờ khóa luồng kia đang giữ, cả hai đứng im mãi mãi. Tôi tái hiện nó thật trong container, và điều làm tôi giật mình không phải bản thân cái treo, mà cách nó xuất hiện: không phải lần nào chạy cũng treo, và khi treo thì ở thời điểm khác nhau — một bài học sâu về vì sao "chạy thử thấy chạy" không bao giờ chứng minh code đồng thời đúng.

Chờ vòng tròn và bốn điều kiện

Deadlock kinh điển: có hai mutex m1 và m2. Luồng A cần cả hai, nên nó khóa m1 rồi khóa m2. Luồng B cũng cần cả hai, nhưng nó khóa m2 rồi m1 — thứ tự ngược (một người bốc muối trước, người kia bốc tiêu trước). Kịch bản chết người: A khóa được m1, đúng lúc đó B khóa được m2. Giờ A xin m2 (B đang giữ) → A chờ. B xin m1 (A đang giữ) → B chờ. Mỗi luồng giữ cái luồng kia cần, và không ai nhả — chờ vòng tròn. Cả hai treo mãi.

Lý thuyết chỉ ra deadlock cần đủ cả bốn điều kiện Coffman: loại trừ lẫn nhau (khóa độc quyền), giữ-và-chờ (giữ khóa này trong khi xin khóa khác), không tước quyền (không ai giật được khóa của luồng khác), và chờ vòng tròn (A chờ B chờ A). Điểm quan trọng cho việc sửa: phá bất kỳ một điều kiện nào là hết deadlock. Tôi đo ba kịch bản trong container gcc:13: bản lỗi (thứ tự ngược), và hai cách phá.

Đo (a): treo — nhưng không phải lần nào, không phải ngay

Bản lỗi: A khóa m1→m2, B khóa m2→m1, mỗi luồng lặp 200.000 vòng, có một cửa sổ nhỏ giữa hai lần khóa để mở rộng khả năng đua. Vì deadlock treo, tôi bọc phép đo bằng một bộ giám sát: nếu số vòng hoàn thành đứng yên thì kết luận đã bế tắc. Chạy bốn lần:

lần 1: done ĐỨNG YÊN ở 1065/200000  -> DEADLOCK
lần 2: done ĐỨNG YÊN ở 2173/200000  -> DEADLOCK
lần 3: done ĐỨNG YÊN ở 3089/200000  -> DEADLOCK
lần 4: done ĐỨNG YÊN ở 1566/200000  -> DEADLOCK

Nó luôn treo (vì cửa sổ đua tôi cố ý mở rộng) — nhưng chú ý điều then chốt: treo ở thời điểm khác nhau mỗi lần. Lần thì sau 1065 vòng, lần thì 3089. Chương trình chạy hàng nghìn vòng đúng đắn trước khi hai luồng vô tình rơi vào đúng khoảnh khắc đua tranh chết người (hai tay cùng với một lúc). Đây là bản chất nguy hiểm nhất của deadlock: nó phụ thuộc timing. Trong cửa sổ hẹp của code thật (không có sleep tôi cố tình chèn), một deadlock có thể ẩn mình qua hàng triệu lần chạy đúng, rồi treo đúng vào lúc tải cao nhất ở production. "Chạy thử thấy chạy" không hề chứng minh không có deadlock — nó chỉ chứng minh lần này các luồng chưa rơi vào đúng khoảnh khắc đó.

Đây chính là bài học của spurious wakeup ở biến điều kiện: với đồng thời, đúng nghĩa là đúng theo hợp đồng/phân tích, không phải theo cái chạy được hôm nay. Một cái treo phụ thuộc timing là loại bug tệ nhất để gỡ, vì nó không tái hiện được theo yêu cầu.

Đo (b) và (c): hai cách phá, cùng hiệu quả

Cách sửa thứ nhất — áp một thứ tự khóa toàn cục. Nếu mọi luồng luôn khóa theo cùng thứ tự (cả A và B đều m1 trước m2), thì không thể có chờ vòng tròn: ai giữ m1 sẽ lấy được m2 (vì không luồng nào giữ m2 mà lại đi xin m1). Nói cách khác: cả bếp thống nhất "luôn bốc muối trước, tiêu sau". Đo:

(b) thứ tự nhất quán (cả A,B: m1->m2) : 0 treo, hoàn thành 200.000 vòng, 22,4 ms

Không treo lần nào, hoàn thành sạch (checksum khớp 200.000). Chỉ đổi thứ tự khóa trong B — không thêm code, không đổi thuật toán — deadlock biến mất hoàn toàn. Đây là cách phá điều kiện thứ tư (chờ vòng tròn) và là cách chuẩn trong thực tế: quy định một thứ tự khóa toàn cục và tuân thủ khắp nơi.

Cách thứ hai — trylock + backoff. A giữ m1 rồi thử (trylock) m2; nếu không lấy được ngay, nó nhả m1 rồi thử lại từ đầu. Cách này phá điều kiện "giữ-và-chờ": không luồng nào ôm một khóa trong khi chờ khóa khác (với không được lọ kia thì đặt luôn lọ đang cầm xuống). Đo:

(c) trylock + backoff : 0 treo, 200.000 vòng, 4,2 ms, 8032 lần retry

Cũng không treo, và thú vị là nhanh hơn (4,2 ms so với 22,4 ms) — vì trylock không chặn-và-ngủ mà lùi ra thử lại, tránh được chi phí đánh thức. Cái giá là 8032 lần retry (những lần phải nhả khóa và thử lại), một dạng lãng phí công. Hai cách đều đúng; thứ tự khóa đơn giản và không tốn retry, trylock linh hoạt hơn khi không áp được thứ tự cố định.

(Môi trường đo: container gcc:13 trên host ARM AArch64, mỗi luồng 200.000 vòng, có sleep cực ngắn chèn giữa hai lần khóa để mở rộng cửa sổ đua. Con số tuyệt đối và thời điểm treo đổi mỗi lần chạy; điều bền vững: thứ tự khóa mâu thuẫn thì treo được, thứ tự nhất quán hoặc trylock thì không.)

Hai niềm tin đối nghịch, cùng sai

Tôi vào đo với hai niềm tin. Thứ nhất, ngây thơ: "cần khóa nhiều mutex thì cứ khóa thôi, thứ tự nào chẳng được". Sai — thứ tự mâu thuẫn giữa các luồng tạo chờ vòng tròn, và chương trình treo. Thứ hai, và là đo hớ nguy hiểm hơn: "chạy thử thấy nó chạy trơn thì chắc không có deadlock". Đo lật tan điều này một cách trực quan — bản lỗi chạy đúng 1065, 2173, 3089, 1566 vòng ở bốn lần khác nhau trước khi treo. Nếu tôi chỉ chạy nó một lần và nó tình cờ hoàn thành sớm (hoặc treo muộn ngoài thời gian quan sát), tôi đã có thể kết luận sai.

Hai niềm tin gặp nhau ở một bài học: deadlock là thuộc tính của cấu trúc khóa (thứ tự có mâu thuẫn không), không phải của một lần chạy — nên không lần chạy thử nào chứng minh được sự vắng mặt của nó. Cách duy nhất để chắc chắn là phân tích: liệt kê thứ tự các luồng lấy khóa, và kiểm xem có chu trình không. Nếu có, deadlock có thể xảy ra, dù bạn chưa từng thấy. Đúng tinh thần đo lường vi mô đúng cách và cả sê-ri này: với đồng thời, "đo thấy chạy" chỉ là điều kiện cần, không phải điều kiện đủ — tính đúng đến từ việc tuân thủ hợp đồng (ở đây: một thứ tự khóa nhất quán), không từ việc thử may rủi.

Về đến code hằng ngày

Điều đầu tiên: quy định một thứ tự khóa toàn cục và tuân thủ nó. Bất cứ khi nào code cần giữ nhiều khóa cùng lúc, hãy định một thứ tự (theo địa chỉ, theo id, theo tên) và luôn lấy khóa theo thứ tự đó ở mọi nơi. Đây là biện pháp phòng deadlock phổ biến và hiệu quả nhất — nó phá chờ vòng tròn tận gốc, không cần cơ chế phát hiện phức tạp lúc chạy.

Điều thứ hai: khi không áp được thứ tự cố định, dùng trylock + backoff (hoặc gom về một khóa lớn hơn). Nếu bạn không biết trước cần khóa nào (ví dụ chuyển tiền giữa hai tài khoản chọn động), trylock cho phép nhả và thử lại thay vì ôm khóa chờ — chấp nhận vài lần retry để đổi lấy không bao giờ treo. Với chuyển tiền giữa hai tài khoản, mẹo kinh điển là luôn khóa tài khoản có id nhỏ hơn trước — chính là áp thứ tự toàn cục.

Chỗ này đáng biết là các tầng khác nhau đối xử với deadlock rất khác nhau, và điều đó định hình cách bạn viết code. Database thì chủ động phát hiện: Postgres, MySQL/InnoDB, SQL Server đều có bộ dò deadlock chạy nền — khi phát hiện chu trình chờ, nó hủy một transaction (chọn "nạn nhân") và trả lỗi deadlock, nên code ứng dụng dùng DB phải sẵn sàng bắt lỗi đó và thử lại transaction; và mẹo phòng vẫn là cập nhật các dòng theo thứ tự khóa chính nhất quán. JVM phát hiện nhưng không tự chữa: jstack hay ThreadMXBean.findDeadlockedThreads() chỉ ra chính xác luồng nào kẹt khóa nào — công cụ vàng khi một service Java "đơ" không rõ lý do. Go thì runtime bắt được trường hợp mọi goroutine cùng ngủ và panic ngay fatal error: all goroutines are asleep - deadlock!, nhưng deadlock cục bộ giữa vài goroutine thì nó không thấy — nên lời khuyên thứ tự khóa vẫn nguyên giá trị (và go vet cảnh báo một số ca sao chép mutex). Còn mutex thô trong C/C++ thì không ai báo gì cả — chỉ treo im lặng, đúng cái tôi đo. Mẫu số chung: dù công cụ có phát hiện hay không, phòng bằng thứ tự khóa nhất quán luôn rẻ hơn chữa bằng dò tìm lúc chạy.

Muốn tự soi thì nhìn một đoạn code của bạn có giữ hai khóa trở lên cùng lúc, và hỏi: mọi nơi trong code có lấy chúng theo cùng một thứ tự không? Nếu chỗ này khóa A rồi B, chỗ kia khóa B rồi A, bạn có một deadlock tiềm ẩn — dù nó chưa từng treo trong test. Vẽ ra: luồng nào giữ khóa nào và chờ khóa nào; nếu có một chu trình (A chờ B, B chờ A), deadlock có thể xảy ra. Đừng để câu "nhưng nó chạy tốt cả tháng nay" ru ngủ — như đo được, một deadlock có thể chạy đúng hàng nghìn (hàng triệu) lần rồi treo đúng lúc tệ nhất. Vài phút kiểm thứ tự khóa đó đáng giá hơn hàng giờ gỡ một cái treo ở production không tài nào tái hiện — vì với deadlock, phòng bằng thứ tự dễ hơn chữa bằng may mắn.