"Go có garbage collector nên không rò bộ nhớ được" — một hiểu lầm nguy hiểm. GC chỉ thu hồi những object không còn ai tham chiếu. Nếu code của bạn vô tình giữ tham chiếu tới thứ đáng lẽ phải thả, GC hoàn toàn bất lực — bộ nhớ cứ tăng dù GC chạy liên tục. Đó chính là rò rỉ bộ nhớ trong ngôn ngữ có GC. Bài này đo một rò rỉ thật, cho thấy các con số tăng đều bất chấp GC, và dạy cách phát hiện.

Bốn mẫu rò kinh điển

Rò trong Go hầu như luôn là một trong bốn mẫu — tất cả đều là "giữ tham chiếu quá lâu":

  1. Rò goroutine: một goroutine kẹt vĩnh viễn (chờ trên channel không ai gửi, deadlock nhẹ). Nó giữ stack của mình và mọi biến nó bắt (capture) — mãi mãi.
  2. Map/slice toàn cục phình: thêm phần tử mà không bao giờ xoá cái cũ. Cache không giới hạn là thủ phạm số một.
  3. Slice của mảng lớn: giữ một slice nhỏ (big[:10]) nhưng backing array khổng lồ đằng sau không được thả — vì slice vẫn trỏ vào nó.
  4. Timer/Ticker quên Stop: time.Ticker không Stop() giữ tham chiếu và một goroutine nội bộ sống mãi.

Ảnh chụp đoạn mã Go nền tối minh hoạ phát hiện rò rỉ bộ nhớ Go có GC vẫn rò được, GC chỉ thu hồi object không còn ai tham chiếu nếu vô tình giữ tham chiếu mãi GC bất lực bộ nhớ tăng đều dù GC chạy, rò goroutine kinh điển mỗi request đẻ goroutine chờ mãi func xuLyRequest data byte ch make chan struct go func nhận từ ch chờ mãi goroutine kẹt vĩnh viễn runtime KeepAlive data giữ data 64KB sống cùng goroutine quên đóng gửi ch goroutine không bao giờ thoát, bốn mẫu rò kinh điển trong Go rò goroutine kẹt mãi giữ stack cộng biến bắt, map slice toàn cục phình thêm mà không xoá phần tử cũ, slice của mảng lớn giữ slice nhỏ nhưng backing array khổng lồ không thả, timer ticker quên Stop giữ tham chiếu và goroutine, cách phát hiện theo dõi xu hướng NumGoroutine và HeapInuse tăng đơn điệu dù GC là cờ đỏ chụp hai heap profile so bằng go tool pprof base tìm cái gì tăng xem goroutine profile để biết goroutine kẹt ở đâu

Hình 1: Rò goroutine kinh điển — mỗi request đẻ một goroutine chờ trên channel không ai gửi, giữ luôn data. Bốn mẫu rò và ba cách phát hiện ở khối dưới.

Đo thật: các con số tăng đều dù GC

Ta mô phỏng một server rò goroutine: mỗi "request" đẻ một goroutine chờ trên channel không bao giờ được gửi, và giữ 64KB data. Chạy 5 vòng, mỗi vòng gọi runtime.GC(), đo NumGoroutine, HeapInuse, StackInuse:

Ảnh chụp bảng kết quả đo thật nền tối rò goroutine tăng đều dù GC Go 1.23, mỗi vòng xử lý 1000 request mỗi cái đẻ 1 goroutine kẹt cộng giữ 64KB có runtime GC, vòng 1 goroutine 1001 HeapInuse 63 MB StackInuse 2 MB, vòng 2 2001 126 MB 4 MB, vòng 3 3001 189 MB 6 MB, vòng 4 4001 252 MB 8 MB, vòng 5 5001 316 MB 10 MB, cả ba tăng tuyến tính dù đã gọi GC mỗi vòng goroutine cộng 1000 mỗi vòng heap cộng 63MB mỗi vòng stack cộng 2MB mỗi vòng, cốt lõi Go có GC nhưng vẫn rò được vì GC chỉ dọn thứ không ai giữ mỗi goroutine kẹt trên channel không ai gửi giữ stack của nó và 64KB data mãi mãi ba con số cùng tăng tuyến tính là dấu hiệu rò không thể nhầm NumGoroutine tăng HeapInuse tăng dù GC StackInuse tăng

Hình 2: Cả ba con số tăng tuyến tính qua 5 vòng dù GC chạy mỗi vòng: goroutine 1001→5001, HeapInuse 63→316 MB, StackInuse 2→10 MB. Đây là chữ ký của rò rỉ.

Kết quả rõ ràng: mỗi vòng thêm 1000 goroutine (1001 → 5001), 63 MB heap (63 → 316), và 2 MB stack (2 → 10). Điều quan trọng: runtime.GC() được gọi mỗi vòng nhưng không dọn được gì — vì mỗi goroutine vẫn sống (kẹt trên <-ch), và data vẫn được goroutine giữ qua KeepAlive. GC làm đúng việc của nó (dọn thứ không ai giữ), nhưng ở đây mọi thứ đều có người giữ. Đó là bản chất của rò trong ngôn ngữ có GC.

Ba cách phát hiện

1. Theo dõi xu hướng NumGoroutine và HeapInuse. Đây là tín hiệu đầu tiên và rẻ nhất. Nếu hai con số này tăng đơn điệu theo thời gian (không bao giờ giảm về đường cơ sở) dù tải ổn định, gần như chắc chắn có rò. NumGoroutine tăng mãi = rò goroutine; HeapInuse tăng mãi dù GC = object bị giữ. Xuất chúng ra metrics (Prometheus) là cách giám sát production tốt nhất.

2. So hai heap profile bằng pprof -base. Chụp heap profile ở thời điểm A và B (cách nhau vài phút dưới tải), rồi go tool pprof -base A.pprof B.pprof. Nó chỉ khác biệt — những gì đã tăng giữa hai lần chụp, tức chính là nguồn rò. Đây là cách tìm chính xác hàm nào đang giữ bộ nhớ tăng dần.

3. Xem goroutine profile để tìm nơi kẹt. Với rò goroutine, /debug/pprof/goroutine?debug=1 (hoặc pprof.Lookup("goroutine")) liệt kê tất cả goroutine đang sống kèm stack trace. Nếu thấy hàng nghìn goroutine kẹt ở cùng một dòng (như <-ch), bạn đã tìm ra thủ phạm và vị trí chính xác.

Ứng dụng thực tế

Rò goroutine là loại rò phổ biến nhất trong Go. Vì goroutine dễ tạo (go f()), người ta hay quên đảm bảo chúng thoát. Mọi goroutine phải có đường thoát rõ ràng: channel được đóng, context bị hủy, hoặc điều kiện dừng. Luôn tự hỏi "goroutine này thoát bằng cách nào?" khi viết go func.

Dùng context để goroutine luôn có đường thoát. Truyền context.Context và select trên ctx.Done() cùng với công việc — khi context bị hủy, goroutine thoát thay vì kẹt. Đây là mẫu chuẩn chống rò goroutine trong server.

Giám sát NumGoroutine trong production. Đặt một metric runtime.NumGoroutine() và cảnh báo khi nó tăng bất thường. Một dịch vụ khỏe mạnh có số goroutine dao động quanh một mức ổn định; tăng đơn điệu là dấu hiệu sớm của rò trước khi OOM.

Đánh đổi cần cân nhắc

Phân biệt "rò thật" với "tăng bình thường". Không phải mọi tăng trưởng bộ nhớ là rò. Một cache có chủ đích, một buffer pool đang ấm lên, hay heap tăng tới goal GOGC — đều là tăng bình thường rồi ổn định. Rò là tăng không giới hạn, không bao giờ ổn định. Cần quan sát đủ lâu để phân biệt.

Chụp profile có chi phí — cân nhắc trên production. Heap profile và goroutine profile tốn CPU/bộ nhớ tạm khi chụp. Trên dịch vụ tải cao, chụp thưa (vài phút một lần) thay vì liên tục. Endpoint /debug/pprof nên được bảo vệ (không expose ra Internet).

Rò có thể ở thư viện, không chỉ code của bạn. Đôi khi rò goroutine đến từ thư viện bên thứ ba (client HTTP không đóng response body, driver database giữ kết nối). Goroutine profile chỉ ra stack đầy đủ nên bạn thấy được rò đến từ package nào — kể cả không phải code mình viết.

Ba ý mang về

  1. Go có GC vẫn rò được: GC chỉ dọn object không ai giữ — đo thật, rò goroutine làm NumGoroutine tăng 1001→5001, HeapInuse tăng 63→316 MB đều đặn dù runtime.GC() chạy mỗi vòng, vì mọi thứ đều có goroutine kẹt đang giữ.
  2. Bốn mẫu rò kinh điển: rò goroutine (kẹt mãi), map/slice toàn cục phình, slice giữ backing array lớn, và timer/ticker quên Stop — tất cả là "giữ tham chiếu quá lâu".
  3. Ba cách phát hiện: theo dõi xu hướng NumGoroutine/HeapInuse tăng đơn điệu (cờ đỏ đầu tiên), so hai heap profile bằng pprof -base để tìm cái gì tăng, và xem goroutine profile để tìm nơi goroutine kẹt — luôn cho goroutine đường thoát qua context.

Phần sau ta xét một cơ chế runtime liên quan tới dọn dẹp tài nguyên mà nhiều người dùng sai: Phần sau mổ xẻ finalizer và runtime.SetFinalizer — nó chạy khi nào, vì sao không nên dựa vào nó để giải phóng tài nguyên, và các cạm bẫy khiến finalizer không bao giờ chạy.