Deadlock (bài trước) là loại "kẹt" dễ nhận nhất: mọi goroutine đứng im. Nhưng có hai họ hàng tinh vi hơn, khó phát hiện hơn vì chương trình trông như đang chạy: livelock (goroutine bận rộn mà không tiến) và starvation (một số goroutine bị bỏ đói mãi). Bài này đo thật cả hai, và trung thực về việc chúng khó tái hiện thế nào — điều bản thân nó là bài học quan trọng.
Ba trạng thái "kẹt" khác nhau
- Deadlock: mọi goroutine bị chặn, không ai chạy — CPU về 0.
- Livelock: mọi goroutine bận chạy nhưng không tiến — CPU cao mà tiến độ = 0.
- Starvation: hệ thống có tiến, nhưng vài goroutine bị bỏ đói (không được chạy phần việc của mình).
Điểm chung: goroutine không làm được việc hữu ích. Điểm khác: cách chúng biểu hiện và phát hiện.

Hình 1: Ba trạng thái kẹt. Livelock (mẫu nhường nhau), starvation (giữ khóa tham lam), và cách tránh mỗi loại.
Livelock: mẫu "lịch sự nhường nhau"
Livelock kinh điển đến từ việc cố tránh deadlock một cách vụng về:
a.Lock()
if !b.TryLock() { // không lấy được cái thứ 2...
a.Unlock() // ...NHẢ cái thứ 1 rồi thử lại (quá lịch sự)
continue // → hai goroutine cứ nhường nhau, không ai qua
}
Giống hai người trong hành lang cùng né sang một hướng, rồi cùng né lại — mãi không qua được nhau. Khác deadlock: không ai bị chặn, CPU chạy 100% mà tiến độ = 0.
Nhưng đây là phần trung thực quan trọng: livelock rất khó ép tất định. Đo thật mẫu này (GOMAXPROCS=2, 100.000 việc): thử=102.538, thành công=100.000 → chỉ 1,03x lãng phí (3% thử vô ích). Trên runtime Go, timing rất hiếm khi giữ đồng bộ hoàn hảo, nên hai goroutine thường tự thoát. Nhưng risk có thật: với backoff đối xứng không randomize, chúng có thể mắc kẹt. Dấu hiệu nhận biết: CPU cao mà tiến độ bằng 0.
Starvation: goroutine bị bỏ đói
Starvation dễ đo hơn nhiều. Cho 3 goroutine cùng tranh một mutex trong 200ms, đếm số lần mỗi cái vào critical section:

Hình 2: Livelock trung thực (hiếm trên Go, 1,03x lãng phí). Starvation lộ 12x mất cân bằng (G0=1.142 vs G2=13.814). Bảng phát hiện ba loại.
Kết quả: G0=1.142, G1=1.673, G2=13.814 — chênh lệch 12,1 lần. Điều đáng chú ý về Go mutex: nó chống đói HOÀN TOÀN (min > 0, không goroutine nào bằng 0) nhờ chế độ starvation: sau 1ms một goroutine chờ khóa, mutex chuyển sang trao khóa theo FIFO. NHƯNG ở chế độ thường (barging), goroutine vừa nhả khóa có thể chiếm lại ngay trước khi goroutine đang chờ được đánh thức — nên vẫn có mất cân bằng ngắn hạn lớn (12x). Go đảm bảo không ai chết đói hoàn toàn, không đảm bảo công bằng tuyệt đối.
Phát hiện ba loại (khác nhau)
- Deadlock: treo, CPU=0 — detector toàn cục hoặc
SIGQUIT(bài trước). - Livelock: CPU cao, tiến độ=0 — profiler cho thấy spin/retry nóng, hoặc metric tiến độ (throughput) tụt về 0.
- Starvation: metric lệch nhiều — đếm công việc theo từng goroutine/client, đo p99 (đuôi độ trễ) thấy vài request bị bỏ rất lâu.
Ứng dụng thực tế
Randomize backoff để tránh livelock. Bất cứ nơi nào có retry sau xung đột (lock, CAS, gọi mạng), thêm jitter ngẫu nhiên vào thời gian chờ. Nếu mọi goroutine chờ đúng cùng khoảng, chúng đồng bộ và va nhau mãi; jitter phá đồng bộ. Đây là lý do exponential backoff luôn kèm jitter.
Đo công bằng, không chỉ throughput. Một service có throughput tổng cao vẫn có thể bỏ đói một số client (starvation). Theo dõi p99/p999 độ trễ và phân bố công việc theo client — throughput trung bình che giấu starvation. 12x chênh lệch như đo trên là dấu hiệu cần cân nhắc hàng đợi công bằng.
Ưu tiên nguyên thủy cao cấp. Nhiều livelock/starvation đến từ tự viết vòng lặp spin/retry với lock thô. Channel, errgroup, hàng đợi công bằng đã xử lý các vấn đề này. Chỉ tự viết retry loop khi thật cần, và luôn kèm backoff có jitter.
Đánh đổi cần cân nhắc
Livelock hiếm nhưng khó gỡ khi xảy ra. Vì phụ thuộc timing, livelock có thể không bao giờ xuất hiện lúc test rồi bùng ra ở production dưới tải cụ thể — và không có công cụ tự bắt như deadlock detector. Phòng bằng thiết kế (tránh retreat đối xứng, thêm jitter) đáng giá hơn nhiều so với gỡ lỗi sau.
Chế độ chống đói của Go mutex có giá. Chế độ starvation (FIFO handoff) đảm bảo công bằng nhưng chậm hơn chế độ barging (throughput cao hơn nhưng kém công bằng). Go tự chuyển đổi để cân bằng — bạn không điều khiển được. Nếu cần đảm bảo công bằng nghiêm ngặt, dùng hàng đợi FIFO tường minh (channel), không dựa vào mutex.
Công bằng vs throughput là đánh đổi thật. Một hệ thống hoàn toàn công bằng (FIFO nghiêm) thường có throughput thấp hơn hệ thống cho phép barging (mất cân bằng ngắn hạn). Chọn theo yêu cầu: dịch vụ người dùng cần công bằng (p99 quan trọng); xử lý batch nội bộ có thể chấp nhận mất cân bằng để throughput cao. Không có lựa chọn đúng tuyệt đối.
Ba ý mang về
- Livelock là bận rộn mà không tiến — khác deadlock (đứng im): mẫu "lịch sự nhường nhau" (nhả khóa rồi thử lại) có thể khiến hai goroutine mắc kẹt né nhau; nhưng đo thật trung thực, nó hiếm xảy ra trên runtime Go (chỉ 1,03x lãng phí) vì timing hiếm khi đồng bộ hoàn hảo — risk có thật, dấu hiệu là CPU cao mà tiến độ = 0.
- Starvation bỏ đói số ít — Go mutex chống đói hoàn toàn nhưng không công bằng tuyệt đối: đo thật 3 goroutine tranh mutex cho 12x mất cân bằng, nhưng min > 0 (không ai chết đói) nhờ chế độ starvation (sau 1ms → FIFO); chế độ barging thường cho throughput cao đổi lấy mất cân bằng ngắn hạn.
- Ba loại "kẹt" phát hiện khác nhau: deadlock (CPU=0, detector/SIGQUIT), livelock (CPU cao tiến=0, profiler), starvation (metric lệch, đo p99) — phòng bằng backoff có jitter ngẫu nhiên (livelock) và hàng đợi công bằng (starvation), ưu tiên nguyên thủy cao cấp hơn tự viết spin/retry.
Phần sau ta quay lại xây dựng — một mẫu đồng thời hoàn chỉnh dùng context để hủy sạch: Phần sau mổ xẻ pipeline có hủy và dọn tài nguyên — cách dựng pipeline nhiều giai đoạn với context để khi hủy, mọi goroutine dừng sạch và không rò tài nguyên.