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:

  1. Loại trừ lẫn nhau: tài nguyên giữ độc quyền (như mutex).
  2. Giữ và chờ: giữ một tài nguyên trong khi chờ cái khác.
  3. Không cưỡng đoạt: không ép nhả khóa được.
  4. 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.

Ảnh chụp đoạn mã Go nền tối minh hoạ deadlock trong Go bốn điều kiện phát hiện và phòng, deadlock các goroutine chờ nhau vòng tròn không ai tiến phá một trong bốn điều kiện là hết deadlock, một bốn điều kiện Coffman cần cả bốn 1 loại trừ lẫn nhau tài nguyên giữ độc quyền mutex 2 giữ và chờ giữ một chờ cái khác 3 không cưỡng đoạt không ép nhả khóa được 4 chờ vòng tròn A chờ B B chờ A về A phá bất kỳ điều kiện nào không deadlock, hai 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 chờ B muB.Lock muA.Lock chờ A G1 giữ A chờ B G2 giữ B chờ A chờ vòng tròn kẹt, ba phòng thứ tự khóa nhất quán phá điều kiện 4 cả hai goroutine khóa theo cùng thứ tự A rồi B work bằng func muA.Lock muB.Lock luôn A trước B không có chờ vòng tròn muB.Unlock muA.Unlock 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ốn các cách phòng khác TryLock lock timeout phá giữ và chờ nhả nếu không lấy được dùng channel thay mutex tránh khóa lồng nhau context timeout mọi thao tác chờ có hạn không treo mãi gộp nhiều khóa thành 1 bớt số khóa bớt cơ hội vòng tròn

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:

Ảnh chụp bảng kết quả đo thật nền tối detector bắt deadlock toàn cục không bắt deadlock cục bộ go run Go 1.23 arm64 10 core, một deadlock toàn cục runtime tự phát hiện thiếu ch mà không ai gửi mọi goroutine ngủ fatal error all goroutines are asleep deadlock goroutine 1 chan receive main.main runtime Go tự phát hiện khi tất cả goroutine bị chặn không ai chạy được panic ngay với thông báo rõ đây là lưới an toàn tích hợp, hai deadlock cục bộ 2 mutex ngược detector không bắt DEADLOCK hai goroutine kẹt nhau A chờ B B chờ A detector không bắt được phải dùng timeout 500ms mới lộ detector chỉ bắt khi mọi goroutine ngủ ở đây main vẫn chạy trong select nên 2 goroutine kẹt nhau không kích hoạt detector deadlock cục bộ này âm thầm treo phải tự phát hiện bằng timeout hoặc dump stack, ba phòng bằng thứ tự khóa nhất quán hết deadlock KHONG deadlock thứ tự khóa nhất quán A rồi B ở cả hai an toàn phá chờ vòng tròn, chẩn đoán deadlock khi treo thật SIGQUIT Ctrl backslash in stack mọi goroutine GOTRACEBACK bằng all dump đầy đủ khi crash tìm goroutine sync.Mutex.Lock chan receive kẹt lâu trừ race bài trước bắt data race không bắt deadlock, cốt lõi deadlock chờ vòng tròn A chờ B B chờ A không ai tiến detector Go bắt deadlock toàn cục mọi goroutine ngủ không bắt deadlock cục bộ vài goroutine kẹt số khác chạy phòng thứ tự khóa nhất quán phá vòng tròn cộng timeout chẩn đoán SIGQUIT GOTRACEBACK all dump stack goroutine

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ề

  1. 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.
  2. Detector của Go chỉ bắt deadlock TOÀN CỤC, bỏ lọt deadlock cục bộ: đo thật, <-ch khô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ặc SIGQUIT/GOTRACEBACK=all dump stack để phát hiện.
  3. 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ớ -race bắ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.