Deadlock là ác mộng kinh điển của lập trình đồng thời: các goroutine chờ nhau vòng tròn, không ai tiến được, chương trình treo mãi. Go có một lưới an toàn thú vị — runtime tự phát hiện một số deadlock và panic ngay. Nhưng có một sự thật quan trọng ít người biết: nó chỉ bắt được deadlock toàn cục, và bỏ lọt loại deadlock nguy hiểm nhất trong thực tế. Bài này đo thật ranh giới đó, và chỉ cách phòng.
Bốn điều kiện Coffman
Deadlock cần đồng thời cả bốn điều kiện (Coffman). Phá bất kỳ một cái là hết deadlock:
- Loại trừ lẫn nhau: tài nguyên giữ độc quyền (như mutex).
- Giữ và chờ: giữ một tài nguyên trong khi chờ cái khác.
- Không cưỡng đoạt: không ép nhả khóa được.
- Chờ vòng tròn: A chờ B, B chờ A (... quay về A).
Điều kiện thứ 4 — chờ vòng tròn — thường là cái dễ phá nhất, và là gốc của mẫu deadlock phổ biến nhất.

Hình 1: Bốn điều kiện Coffman, deadlock 2 mutex ngược thứ tự (chờ vòng tròn), và phòng bằng thứ tự khóa nhất quán phá điều kiện 4.
Deadlock kinh điển: 2 mutex ngược thứ tự
// goroutine 1: A rồi B // goroutine 2: B rồi A
muA.Lock() muB.Lock()
muB.Lock() // chờ B muA.Lock() // chờ A
Goroutine 1 giữ A và chờ B; goroutine 2 giữ B và chờ A. Cả hai kẹt cứng — chờ vòng tròn. Đây là bug đồng thời phổ biến nhất, và nó phụ thuộc thời điểm (timing) nên khó tái hiện: chỉ xảy ra khi hai goroutine khóa xen kẽ đúng lúc.
Đo thật: detector bắt gì, bỏ gì
Deadlock toàn cục — runtime tự phát hiện:
// <-ch mà không ai gửi → MỌI goroutine ngủ
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.main()
Khi tất cả goroutine bị chặn (không ai chạy được), runtime Go phát hiện và panic ngay với thông báo rõ. Đây là lưới an toàn tích hợp — rất hữu ích cho lỗi channel đơn giản.
Deadlock cục bộ (2 mutex ngược) — detector KHÔNG bắt:

Hình 2: Deadlock toàn cục — runtime panic "all goroutines are asleep". Deadlock cục bộ 2 mutex — detector KHÔNG bắt (main còn chạy), phải dùng timeout 500ms mới lộ. Phòng bằng thứ tự khóa nhất quán.
Đo thật, deadlock 2 mutex chỉ lộ ra khi ta thêm timeout 500ms — detector không kích hoạt vì goroutine main vẫn chạy (đang trong select). Detector chỉ bắt khi MỌI goroutine ngủ; deadlock cục bộ (vài goroutine kẹt, số khác chạy) âm thầm treo mà không có cảnh báo nào. Đây chính là loại nguy hiểm trong production — một phần hệ thống chết mà process vẫn "sống".
Phòng: thứ tự khóa nhất quán
Cách phòng gốc rễ là phá điều kiện chờ vòng tròn: luôn khóa theo cùng một thứ tự:
// CẢ HAI goroutine khóa theo CÙNG thứ tự: A rồi B
work := func() {
muA.Lock()
muB.Lock() // luôn A trước B → không có chờ vòng tròn
muB.Unlock(); muA.Unlock()
}
Đo thật: với thứ tự nhất quán, không còn deadlock. Quy tắc vàng: định một thứ tự toàn cục cho mọi khóa và LUÔN khóa theo thứ tự đó. Không có vòng tròn → không deadlock, bất kể timing.
Các cách phòng và chẩn đoán khác
Phá "giữ và chờ" bằng TryLock/timeout. Nếu không thể đảm bảo thứ tự, dùng mu.TryLock() (Go 1.18+) hoặc lock có timeout: không lấy được thì nhả khóa đang giữ và thử lại. Phá điều kiện 2.
Dùng channel hoặc gộp khóa. Nhiều deadlock đến từ khóa lồng nhau. Dùng channel để điều phối (thay vì khóa lồng), hoặc gộp nhiều khóa thành một khóa lớn hơn (bớt số khóa → bớt cơ hội vòng tròn). context với timeout trên mọi thao tác chờ đảm bảo không treo mãi.
Chẩn đoán khi treo thật. Vì detector không bắt deadlock cục bộ, khi process treo: gửi SIGQUIT (Ctrl+) để in stack mọi goroutine, hoặc chạy với GOTRACEBACK=all. Tìm goroutine kẹt lâu ở [sync.Mutex.Lock] hoặc [chan receive] — chúng chỉ ra chỗ deadlock. Lưu ý: -race (bài memory model) bắt data race, KHÔNG bắt deadlock — hai loại bug khác nhau.
Đánh đổi cần cân nhắc
Detector toàn cục hữu ích nhưng đừng dựa vào nó. "all goroutines are asleep" là món quà cho lỗi channel đơn giản lúc dev, nhưng nó không phát hiện được deadlock thật trong service đang chạy (luôn có goroutine khác — HTTP handler, GC — đang chạy). Đừng nghĩ "Go tự bắt deadlock" — nó chỉ bắt trường hợp hẹp.
Thứ tự khóa nhất quán khó duy trì khi hệ thống lớn. Trên codebase lớn với nhiều khóa ở nhiều package, đảm bảo mọi nơi khóa cùng thứ tự là kỷ luật khó. Cân nhắc: giảm số khóa (thiết kế ít chia sẻ trạng thái hơn), dùng khóa hạt to hơn, hoặc công cụ phát hiện lock-order violation (một số có sẵn cho C/C++, Go ít hơn). Đơn giản hóa thiết kế đồng thời thường hiệu quả hơn kỷ luật khóa.
Timeout che deadlock, không sửa nó. Dùng timeout để phát hiện và phục hồi (trả lỗi thay vì treo) là tốt cho độ bền, nhưng timeout thường xuyên nổ ra là dấu hiệu deadlock chưa được sửa — chỉ đang bị che. Điều tra gốc rễ (thứ tự khóa) thay vì chỉ tăng timeout.
Ba ý mang về
- Deadlock là chờ vòng tròn — cần cả bốn điều kiện Coffman: loại trừ lẫn nhau, giữ-và-chờ, không cưỡng đoạt, và chờ vòng tròn; phá bất kỳ một điều kiện là hết deadlock, và chờ vòng tròn (A chờ B, B chờ A) là gốc của mẫu 2-mutex-ngược-thứ-tự phổ biến nhất.
- Detector của Go chỉ bắt deadlock TOÀN CỤC, bỏ lọt deadlock cục bộ: đo thật,
<-chkhông ai gửi khiến runtime panic "all goroutines are asleep", nhưng 2 mutex ngược thứ tự (main còn chạy) không kích hoạt detector — phải dùng timeout hoặcSIGQUIT/GOTRACEBACK=alldump stack để phát hiện. - Phòng gốc rễ bằng thứ tự khóa nhất quán: đo thật khóa cùng thứ tự (A rồi B ở mọi goroutine) phá chờ vòng tròn → hết deadlock; bổ sung bằng TryLock/timeout, dùng channel, giảm số khóa — và nhớ
-racebắt data race chứ KHÔNG bắt deadlock.
Phần sau ta xem hai họ hàng gần của deadlock cũng làm hệ thống "kẹt" nhưng theo cách tinh vi hơn: Phần sau mổ xẻ livelock (các goroutine bận rộn mà không tiến) và starvation (một goroutine bị bỏ đói mãi) — vì sao chúng khó phát hiện hơn deadlock, và cách tránh.