Goroutine Leak: Lỗi Âm Thầm Ăn Hết RAM Trong Golang
1. Tổng quan
Goroutine là thứ làm Go “ngầu” — nhẹ, dễ tạo, chỉ vài KB mỗi cái. Nhưng chính vì nhẹ nên bạn dễ tạo ra chúng mà quên… dọn dẹp. Goroutine leak là tình trạng goroutine bị block vĩnh viễn, không bao giờ kết thúc, và cứ thế tích tụ theo thời gian cho đến khi RAM cạn hoặc hệ thống sập.
Bài này đọc xong bạn sẽ biết: những pattern gây leak phổ biến nhất, cách dùng pprof và runtime metrics để phát hiện, và cách fix bằng context, buffered channel, select với timeout.
2. Goroutine leak là gì
Khác với memory leak thông thường (object không bị GC vì vẫn còn reference), goroutine leak là một goroutine bị block mãi mãi ở một điểm nào đó — thường là chờ đọc/ghi channel không bao giờ xảy ra. Goroutine đó không chết, không bị GC, và stack của nó (cùng mọi biến closure nó giữ) tồn tại vĩnh viễn trong heap.
Vấn đề nguy hiểm nhất: chương trình không crash ngay. Nó chạy bình thường, request vẫn trả kết quả đúng, nhưng RSS (resident memory) tăng dần theo từng request. Sau vài giờ hoặc vài ngày, container bị OOMKilled.
3. Pattern gây leak phổ biến #1: Gửi vào channel không ai nhận
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
resultCh := make(chan string)
go func() {
result := slowHTTPCall(url) // giả sử tốn 5s
resultCh <- result // (1) gửi vào channel không buffer
}()
select {
case res := <-resultCh:
return res, nil
case <-time.After(timeout):
return "", fmt.Errorf("timeout after %v", timeout)
}
}
Nếu timeout (ví dụ 1s) nhỏ hơn thời gian slowHTTPCall chạy (5s), hàm fetchWithTimeout trả về lỗi timeout và thoát ngay ở nhánh case <-time.After. Nhưng goroutine con ở dòng (1) vẫn đang chạy. Khi nó chạy xong sau 5s, nó cố gửi result vào resultCh — không ai còn đọc channel này nữa vì hàm cha đã return. Vì resultCh không buffer, gửi vào channel không ai nhận sẽ block mãi mãi. Goroutine đó leak vĩnh viễn.
Gọi hàm này 1000 lần/giây với timeout ngắn, bạn có 1000 goroutine leak mỗi giây.
3.1. Cách fix: buffered channel
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
resultCh := make(chan string, 1) // (1) buffer size 1
go func() {
result := slowHTTPCall(url)
resultCh <- result // không bao giờ block vì đã có buffer
}()
select {
case res := <-resultCh:
return res, nil
case <-time.After(timeout):
return "", fmt.Errorf("timeout after %v", timeout)
}
}
Dòng (1) cấp buffer 1 cho channel. Khi goroutine con gửi result, nó ghi vào buffer rồi thoát ngay — không cần ai đọc. Goroutine kết thúc bình thường, không leak. Đây là fix nhanh nhưng chưa triệt để: slowHTTPCall vẫn chạy hết 5s dù caller không còn quan tâm kết quả, tốn CPU/network vô ích.
4. Pattern leak #2: Quên hủy goroutine khi context bị cancel
func worker(ch <-chan int) {
go func() {
for v := range ch { // (1) chờ mãi nếu ch không đóng
process(v)
}
}()
}
Nếu không ai close(ch), goroutine ở dòng (1) chờ đọc channel vĩnh viễn. Pattern này thường gặp khi worker được tạo trong một request scope nhưng channel nguồn gắn với lifecycle khác (ví dụ global queue không bao giờ đóng).
4.1. Fix bằng context.Context
func worker(ctx context.Context, ch <-chan int) {
go func() {
for {
select {
case <-ctx.Done(): // (1) nhận signal hủy
return
case v, ok := <-ch:
if !ok { // (2) channel đã đóng
return
}
process(v)
}
}
}()
}
Dòng (1) lắng nghe ctx.Done() — khi caller gọi cancel(), goroutine thoát ngay dù ch chưa có gì để đọc. Dòng (2) xử lý trường hợp channel bị đóng bình thường. Đây là pattern chuẩn: mọi goroutine chạy dài nên nhận context.Context và luôn có đường thoát qua ctx.Done().
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
worker(ctx, dataCh)
5. Pattern leak #3: sync.WaitGroup với goroutine không bao giờ gọi Done()
func processAll(items []Item) {
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(it Item) {
defer wg.Done()
if err := validate(it); err != nil {
return // quên gọi Done() trước khi return? Không, defer vẫn chạy
}
save(it)
}(item)
}
wg.Wait() // (1) chờ tất cả goroutine gọi Done()
}
Ở ví dụ này defer wg.Done() an toàn vì defer luôn chạy kể cả khi return sớm. Nhưng leak thật sự xảy ra khi goroutine block bên trong trước khi kịp return — ví dụ save(it) gọi một API không timeout và treo mãi. Khi đó wg.Wait() ở dòng (1) cũng bị block vô hạn, kéo theo goroutine gọi processAll cũng leak theo.
Lưu ý:
defer wg.Done()chỉ cứu bạn khỏi lỗi quên gọiDone(). Nó không cứu bạn khỏi goroutine bị treo bên trong body. Luôn đặt timeout cho mọi I/O (HTTP client, DB query, gRPC call).
6. Pattern leak #4: Rò rỉ qua time.Ticker không Stop()
func startMonitor() {
ticker := time.NewTicker(5 * time.Second)
go func() {
for range ticker.C { // (1) chạy vĩnh viễn
checkHealth()
}
}()
}
time.NewTicker tạo một goroutine nội bộ trong runtime để bơm tick vào ticker.C. Nếu không bao giờ gọi ticker.Stop(), cả goroutine đọc ở dòng (1) và goroutine nội bộ của ticker sống mãi — kể cả khi startMonitor được gọi lại nhiều lần (ví dụ mỗi lần reload config), bạn tích lũy nhiều ticker chạy song song.
6.1. Fix: gắn lifecycle với context
func startMonitor(ctx context.Context) {
ticker := time.NewTicker(5 * time.Second)
go func() {
defer ticker.Stop() // (1) luôn dọn dẹp ticker
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
checkHealth()
}
}
}()
}
Dòng (1) đảm bảo ticker.Stop() được gọi khi goroutine thoát, dù thoát qua đường nào. ctx.Done() cho phép caller chủ động dừng monitor khi không cần nữa.
7. Phát hiện goroutine leak bằng pprof
Cách tốt nhất để phát hiện leak không phải đọc code bằng mắt mà là đo trực tiếp.
import (
"net/http"
_ "net/http/pprof" // (1) tự động đăng ký các route debug
)
func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// ... phần còn lại của app
}
Dòng (1) import net/http/pprof với tên trống (_) — chỉ cần side-effect đăng ký route /debug/pprof/*, không cần gọi hàm nào từ package này.
Chạy app rồi gọi:
curl http://localhost:6060/debug/pprof/goroutine?debug=1
goroutine profile: total 15234
14998 @ 0x43f1 0x454a 0x4a2b ...
# 0x4a2b main.fetchWithTimeout.func1+0x5b /app/main.go:12
236 @ 0x43f1 0x454c ...
Số 14998 ở dòng đầu là dấu hiệu rõ ràng: gần 15 nghìn goroutine đang treo ở đúng một điểm trong code (main.go:12). Đây chính xác là goroutine leak — một pattern lặp lại hàng loạt, không phải traffic bình thường.
7.1. Theo dõi số lượng goroutine theo thời gian
func logGoroutineCount() {
ticker := time.NewTicker(10 * time.Second)
for range ticker.C {
log.Printf("goroutines: %d", runtime.NumGoroutine())
}
}
runtime.NumGoroutine() trả về số goroutine đang sống. Nếu con số này tăng liên tục mà không bao giờ giảm dù traffic ổn định, đó là chỉ báo leak rất đáng tin cậy. Nên expose metric này qua Prometheus (go_goroutines — thực ra client Prometheus Go đã có sẵn metric này).
8. Viết test để bắt leak sớm
Thư viện go.uber.org/goleak giúp viết test tự động phát hiện leak thay vì chờ phát hiện ở production.
<!-- dùng go.mod thay cho Maven -->
go get go.uber.org/goleak
package fetcher_test
import (
"testing"
"time"
"go.uber.org/goleak"
)
func TestFetchWithTimeout_NoLeak(t *testing.T) {
defer goleak.VerifyNone(t) // (1) kiểm tra sau khi test chạy xong
_, err := fetchWithTimeout("http://slow-endpoint.local", 100*time.Millisecond)
if err == nil {
t.Fatal("expected timeout error")
}
time.Sleep(200 * time.Millisecond) // (2) chờ goroutine con có cơ hội hoàn tất
}
Dòng (1) đăng ký kiểm tra chạy sau khi test kết thúc: goleak so sánh danh sách goroutine đang sống tại thời điểm đó với baseline, nếu còn goroutine “lạ” (không phải của runtime/test framework), test sẽ fail và in ra stack trace của goroutine leak. Dòng (2) cho goroutine con đủ thời gian hoàn tất trước khi kiểm tra — nếu thiếu dòng này, test có thể false positive.
Nếu bạn dùng bản fetchWithTimeout không buffer channel ở mục 3, test này sẽ fail với output dạng:
--- FAIL: TestFetchWithTimeout_NoLeak (0.30s)
goleak.go:39: found unexpected goroutines:
[Goroutine 18 in state chan send, with fetcher.fetchWithTimeout.func1 on top of the stack:
goroutine 18 [chan send]:
fetcher.fetchWithTimeout.func1()
/app/fetcher.go:9 +0x65
]
Stack trace chỉ đúng vào dòng gây leak — rất hữu ích khi debug.
9. Checklist tránh goroutine leak
- Mọi goroutine chạy I/O dài nên nhận
context.Contextvà có nhánh<-ctx.Done(). - Channel không buffer dùng để gửi kết quả về caller có khả năng bị bỏ rơi — cân nhắc buffer size 1 hoặc dùng
context. time.Ticker,time.Timerphải đượcStop()khi không còn dùng.sync.WaitGroup.Wait()chỉ an toàn nếu mọi goroutine con có đường thoát chắc chắn (timeout cho I/O bên trong).- Viết test với
goleak.VerifyNone(t)cho các hàm tạo goroutine, đặc biệt là hàm có timeout/cancel logic. - Theo dõi
runtime.NumGoroutine()hoặc metricgo_goroutinestrên dashboard production.
Lưu ý: Không phải goroutine sống lâu nào cũng là leak. Một worker pool chạy suốt đời ứng dụng là thiết kế hợp lệ. Vấn đề chỉ phát sinh khi số lượng goroutine tăng không giới hạn theo traffic mà không giảm tương ứng.
10. Kết luận
Goroutine leak nguy hiểm vì nó không gây lỗi ngay — ứng dụng vẫn trả kết quả đúng trong khi RAM tăng dần phía sau. Nguyên nhân gốc rễ hầu như luôn là: gửi vào channel không ai nhận, hoặc goroutine thiếu đường thoát khi caller đã bỏ cuộc (timeout, cancel). Cách phòng ngừa đơn giản: luôn truyền context.Context cho goroutine chạy dài, buffer channel kết quả, và viết test với goleak để bắt lỗi ngay ở giai đoạn code review thay vì chờ production OOMKilled.