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.

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
  1. Gửi tín hiệu SIGQUIT (Ctrl+) tới process, hoặc chạy với biến môi trường GOTRACEBACK=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].
  2. 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.