Cho tới giờ mọi cái giá của khóa ta đo đều là chậmconvoy, 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.

Deadlock: chờ vòng tròn, thuốc là thứ tự

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

Deadlock kinh điển: có hai mutex m1m2. 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 m1thứ tự ngượ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

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. Đâ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). Đ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. Đ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ột lần tôi đo hớ: "chạy thử thấy chạy" không chứng minh gì

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 phá 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.

Bài học đo lường: 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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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.

Hệ quả thứ hai: khi không áp được thứ tự cố định, dùng trylock + backoff (hoặc lock toàn bộ dưới 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. Hoặc đơn giản hóa: gom về một khóa duy nhất nếu hiệu năng cho phép (không có nhiều khóa thì không có vòng tròn).

Hệ quả thứ ba là tinh thần đo lường: deadlock không tái hiện đều — phải phân tích, đừng tin phép thử. Con số mang theo: deadlock cần đủ 4 điều kiện Coffman và một chờ vòng tròn từ thứ tự khóa MÂU THUẪN (A: m1->m2, B: m2->m1) — nó TREO nhưng phụ thuộc timing: 4 lần chạy treo ở vòng 1065/2173/3089/1566 khác nhau, chạy đúng hàng nghìn vòng trước khi bế tắc, nên 'chạy thử thấy chạy' KHÔNG chứng minh hết deadlock; thuốc là áp MỘT thứ tự khóa toàn cục (phá chờ vòng tròn, 0 treo) hoặc trylock+backoff (phá giữ-và-chờ, 0 treo, 8032 retry). Phá 1 trong 4 điều kiện là đủ.

Thử ba mươi giây

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. Ba mươi giây 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.