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.

Ảnh chụp đoạn mã Go nền tối minh hoạ livelock và starvation hai họ hàng tinh vi của deadlock, deadlock mọi goroutine đứng livelock bận rộn mà không tiến starvation số ít bị bỏ đói mãi, một ba trạng thái kẹt khác nhau deadlock mọi goroutine bị chặn không ai chạy livelock mọi goroutine bận chạy nhưng không tiến starvation hệ thống tiến nhưng vài goroutine bị bỏ đói, hai livelock mẫu lịch sự nhường nhau a.Lock if không 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é một hướng mãi không qua livelock không bị chặn khác deadlock CPU 100% mà tiến độ bằng 0 khó ép tất định vì phụ thuộc timing trên runtime Go thường tự thoát nhưng risk có thật khi backoff đối xứng, ba starvation goroutine tham lam giữ khóa for mu.Lock lamViec mu.Unlock nhả rồi lấy lại ngay barging goroutine vừa nhả có thể chiếm lại khóa trước khi goroutine chờ được đánh thức goroutine chậm chân bị bỏ đói, bốn cách tránh livelock backoff ngẫu nhiên phá đồng bộ hoặc thứ tự khóa starvation hàng đợi công bằng FIFO Go mutex có chế độ chống đói chung tránh spin retry đối xứng ưu tiên channel nguyên thủy cao cấp

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:

Ảnh chụp bảng kết quả đo thật nền tối livelock khó ép starvation lộ 12x mất cân bằng go run Go 1.23 arm64 10 core, livelock mẫu nhường nhau trung thực khó tái hiện mẫu lịch sự nhường nhau GOMAXPROCS bằng 2 100000 việc thử bằng 102538 thành công bằng 100000 lãng phí 1,03x hầu như không livelock trung thực trên runtime Go mẫu này hiếm khi livelock thật chỉ 3% thử vô ích timing rất hiếm khi giữ đồng bộ hoàn hảo nhưng risk có thật với backoff đối xứng không randomize hai goroutine có thể mắc kẹt nhường nhau dấu hiệu CPU cao mà tiến độ bằng 0, starvation 3 goroutine tranh mutex 200ms số lần vào critical section G0 bằng 1142 G1 bằng 1673 G2 bằng 13814 chênh lệch max min bằng 12,1x Go mutex chống đói hoàn toàn min lớn hơn 0 không goroutine nào bằng 0 nhờ chế độ starvation sau 1ms chờ chuyển sang trao khóa FIFO nhưng ở chế độ thường barging goroutine vừa nhả có thể chiếm lại ngay mất cân bằng ngắn hạn lớn 12x, phát hiện ba loại khác nhau deadlock treo CPU bằng 0 detector SIGQUIT livelock CPU cao tiến bằng 0 profiler spin metric tiến độ starvation metric lệch nhiều đếm theo goroutine đo p99, cốt lõi livelock bận mà không tiến đo thật hiếm trên Go 1,03x lãng phí starvation số ít bị bỏ đói đo thật 12x mất cân bằng Go mutex chống đói hoàn toàn min lớn hơn 0 nhưng còn lệch ngắn hạn lớn tránh backoff ngẫu nhiên livelock FIFO công bằng starvation phát hiện khác deadlock CPU cao live metric lệch starve

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ề

  1. 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.
  2. 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.
  3. 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.