Goroutine Leak trong Go — Kẻ giết RAM thầm lặng

Phong Hy

Goroutine rẻ — stack chỉ ~2KB lúc khởi động — nên dân Go ai cũng tạo thoải mái. Nhưng goroutine không tự hủy khi function return: nó chỉ kết thúc khi function chạy hết. Nếu function bị block vĩnh viễn, goroutine đó sống mãi, kéo theo toàn bộ heap nó đang giữ. Đó là lý do goroutine leak nguy hiểm hơn memory leak thường: không có reference nào để GC dọn, RAM cứ chảy máu âm thầm.

Triệu chứng kinh điển

App chạy production được 3 tuần, rồi memory tăng vùn vụt, response chậm dần, cuối cùng OOM. Bạn soi code không thấy chỗ nào giữ pointer. Nhưng runtime.NumGoroutine() lúc 2 giờ chiều là 120, tới 5 giờ chiều đã 12.000. Chúc mừng, bạn vừa dính goroutine leak.

Pattern 1: Gửi vào channel không ai nhận

func worker(results chan<- Result) {
    for _, job := range jobs {
        result, err := process(job)
        if err != nil {
            continue // quên gửi kết quả lỗi
        }
        results <- result
    }
}

Nếu consumer đã thoát sớm hoặc không ai drain results nữa, câu results <- result sẽ block vĩnh viễn → goroutine leak. Channel không buffered thì gửi mà không có người nhận là kẹt ngay.

Fix: đảm bảo consumer luôn drain, hoặc dùng select với context để thoát được:

select {
case results <- result:
case <-ctx.Done():
    return
}

Pattern 2: Nhận từ channel không bao giờ có dữ liệu

ch := make(chan Data)
go func() {
    data, err := apiCall()
    if err != nil {
        return // quên gửi gì vào ch
    }
    ch <- data
}()

data := <-ch // block vĩnh viễn nếu apiCall lỗi

Goroutine chính kẹt ở <-ch chờ một value không bao giờ tới. Ngược lại, nếu đảo vai trò thì goroutine con kẹt ở ch <- data vì không ai nhận. Cả hai hướng đều leak.

Fix: goroutine con luôn gửi kết quả (kể cả error), và bên nhận luôn có cửa thoát:

select {
case data := <-ch:
    return data
case <-ctx.Done():
    return zero
}

Pattern 3: Worker pool không bao giờ shutdown

for i := 0; i < 10; i++ {
    go func() {
        for job := range jobs { // jobs không bao giờ được close
            doWork(job)
        }
    }()
}

Nếu không ai close(jobs), 10 goroutine này ngồi chờ vĩnh viễn. Pattern này hay gặp nhất trong server: mỗi request mở một worker pool mới mà quên close channel khi xong việc. Theo thời gian, số goroutine mồ côi tăng theo số request.

Cách bắt leak trước khi nó cắn

  1. pprofgo tool pprof http://localhost:6060/debug/pprof/goroutine xem goroutine nào đang block lâu. Đây là công cụ đầu tiên khi nghi ngờ.
  2. goleak (của Uber) — chạy trong test:
func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m)
}

Gắn vào mọi package có goroutine, test sẽ fail ngay khi có goroutine rò rỉ sau khi test xong. 3. Alert runtime — đẩy runtime.NumGoroutine() vào Prometheus, đặt alert khi vượt ngưỡng bình thường. Leak không bao giờ tự khỏi, chỉ có alert mới cứu bạn khỏi đêm OOM.

Kinh nghiệm thực tế

Leak tệ nhất mình từng gặp là service xử lý webhook: mỗi webhook đến mở một goroutine gọi API bên ngoài, mà API đó timeout 60 giây, còn goroutine thì không có context. Traffic cao → hàng nghìn goroutine đứng hình → heap phình to → OOM lúc 3 giờ sáng. Fix chỉ 5 dòng: thêm context.WithTimeoutselect với ctx.Done().

Quy tắc vàng mình áp dụng khi review code Go: goroutine nào cũng phải trả lời được câu hỏi "khi nào nó được hủy?". Nếu goroutine không nhận context, không có channel nào để đóng, không có timeout — coi như nó là bug tiềm ẩn, ký duyệt là nợ nần.

Kết

Goroutine leak không có garbage collector nào dọn giúp. Đầu tư 30 phút gắn goleak vào test + alert NumGoroutine sẽ cứu bạn khỏi những đêm dài truy OOM. Goroutine rẻ, nhưng goroutine bị leak thì đắt lắm.