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.
Bài tập làm thử
Bài 1 (đọc hiểu). Đoạn mã sau chạy trên Go 1.23, mỗi goroutine chạy trong một lệnh go:
var muA, muB sync.Mutex
go func() { // goroutine 1
muA.Lock()
muB.Lock() // chờ B
muB.Unlock()
muA.Unlock()
}()
go func() { // goroutine 2
muB.Lock()
muA.Lock() // chờ A
muA.Unlock()
muB.Unlock()
}()
Nếu hai goroutine này khóa xen kẽ đúng lúc (G1 vừa muA.Lock() xong, G2 vừa muB.Lock() xong), chương trình có bị Go runtime tự phát hiện và panic "all goroutines are asleep" không? Vì sao?
Đáp án
Không. Đây là deadlock cục bộ (chỉ 2 trong số các goroutine kẹt, ví dụ goroutine main vẫn có thể đang chạy việc khác). Runtime Go chỉ phát hiện và panic khi mọi goroutine trong chương trình đều bị chặn. Ở đây có thể còn goroutine khác (main) đang chạy nên detector không kích hoạt — chương trình treo âm thầm, phải phát hiện bằng timeout hoặc SIGQUIT/GOTRACEBACK=all.
Bài 2 (sửa lỗi). Đoạn mã bên dưới vẫn còn nguy cơ chờ vòng tròn dù đã "sửa" bằng cách thêm try. Chỉ ra vì sao nó chưa đúng và sửa lại theo nguyên tắc bài đã nêu.
func worker1() {
muA.Lock()
muB.Lock()
// ...
muB.Unlock()
muA.Unlock()
}
func worker2() {
muB.Lock()
muA.Lock()
// ...
muA.Unlock()
muB.Unlock()
}
Đáp án
worker1 khóa theo thứ tự A rồi B, còn worker2 khóa theo thứ tự B rồi A — hai thứ tự ngược nhau nên vẫn có thể chờ vòng tròn (điều kiện Coffman thứ 4). Sửa: bắt cả hai hàm khóa theo cùng một thứ tự toàn cục, ví dụ luôn A trước B:
func worker2() {
muA.Lock()
muB.Lock()
// ...
muB.Unlock()
muA.Unlock()
}
Khi mọi goroutine luôn khóa A trước B, không còn chờ vòng tròn nên không còn deadlock, bất kể timing.
Bài 3 (vận dụng thực tế). Một service production của bạn thỉnh thoảng bị "treo" ở một endpoint cụ thể nhưng process vẫn phản hồi health-check bình thường (goroutine khác vẫn chạy). Bạn nghi ngờ có deadlock cục bộ giữa hai mutex. Nêu hai cách cụ thể để xác nhận và định vị đúng chỗ deadlock mà không cần sửa code trước.
Đáp án
- Gửi tín hiệu
SIGQUIT(Ctrl+) tới process, hoặc chạy với biến môi trườngGOTRACEBACK=all, để Go in ra stack trace của mọi goroutine — tìm các goroutine đang kẹt lâu ở trạng thái[sync.Mutex.Lock]hoặc[chan receive]. - Thêm timeout (context hoặc lock có hạn) quanh nghi ngờ, hoặc theo dõi
runtime.NumGoroutine()/pprof goroutine để thấy số goroutine bị chặn tăng và giữ nguyên theo thời gian — dấu hiệu deadlock chứ không phải xử lý chậm bình thường.
Lưu ý: chạy với -race không giúp gì ở đây vì nó chỉ bắt data race, không bắt deadlock.
Bài 4 (bẫy/đánh đổi). Một đồng nghiệp đề xuất: "Cứ bọc mọi Lock() bằng timeout 2 giây, không cần lo thứ tự khóa nữa — timeout sẽ tự giải cứu khi kẹt." Bạn có đồng ý là giải pháp đủ không? Nêu rủi ro của cách tiếp cận này.
Đáp án
Không đủ. Timeout chỉ che deadlock (giúp phát hiện và phục hồi bằng cách trả lỗi thay vì treo mãi), chứ không sửa nguyên nhân gốc (chờ vòng tròn do khóa ngược thứ tự). Nếu timeout nổ ra thường xuyên, đó là dấu hiệu deadlock vẫn tồn tại và đang bị che đi — cần điều tra và sửa bằng thứ tự khóa nhất quán (hoặc giảm số khóa, dùng channel), chứ không nên chỉ dựa vào timeout làm giải pháp lâu dài.
Bài 5 (đọc hiểu số liệu). Bài đo thật cho thấy trên cùng một chương trình: bản go run bình thường không có gì bất thường hiện ra ngay, nhưng khi bọc bằng timeout 500ms thì lệnh mới báo là bị treo. Giải thích tại sao phải dùng timeout mới "lộ" được deadlock cục bộ này, thay vì thấy panic ngay như deadlock toàn cục.
Đáp án
Vì detector nội trong runtime Go chỉ kiểm tra điều kiện "mọi goroutine đều ngủ" — ở deadlock cục bộ, goroutine main vẫn đang chạy (ví dụ đang select chờ), nên điều kiện đó không đúng và detector không panic. Chương trình vì thế không tự kết thúc mà treo vô thời hạn; công cụ timeout ở tầng shell là cách bên ngoài để ép chương trình dừng sau một khoảng thời gian, từ đó ta biết được nó đã treo (chứ tự bản thân Go không báo).
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.