Go Zero to Hero - Bài 70: Tránh rò rỉ bộ nhớ (Goroutine Leaks)
1. Bản chất của Goroutine Leak là gì?
Bộ thu gom rác (Garbage Collector - GC) của Go cực kỳ thông minh trong việc dọn dẹp các biến không còn sử dụng. Tuy nhiên, GC sẽ KHÔNG BAO GIỜ dọn dẹp một Goroutine vẫn đang chạy hoặc đang bị Block (khóa chờ).
Goroutine Leak xảy ra khi một Goroutine rơi vào trạng thái chờ đợi vĩnh viễn (đợi Channel, đợi I/O, hoặc lặp vô tận) mà không có bất kỳ cơ chế nào để đánh thức hoặc ép nó kết thúc. Nó trở thành một "thực thể bất tử" rác rưởi trong RAM của máy chủ.
2. Ba kịch bản "Sập Server" kinh điển nhất
Bẫy 1: Bị bỏ rơi ở Channel (Abandoned Channel)
Đây là lỗi phổ biến chiếm tới 80% các vụ rò rỉ. Nó xảy ra khi một Goroutine cố nhét dữ liệu vào (hoặc lấy ra từ) một Channel, nhưng phía bên kia đã... bỏ đi làm việc khác hoặc đã kết thúc.
Hãy xem kịch bản trạm AFC gọi API đối soát vé:
func processTicketAction() {
resultChan := make(chan string)
// Goroutine ngầm đi gọi API
go func() {
time.Sleep(3 * time.Second) // API lag mất 3 giây
// 🚨 KẸT VĨNH VIỄN TẠI ĐÂY!
// Sau 3 giây, nó cố nhét kết quả vào ống, nhưng hàm cha đã return mất rồi.
// Unbuffered Channel bắt buộc phải có người nhận, nếu không nó sẽ Block mãi mãi!
resultChan <- "Thành công"
}()
// Hàm cha chờ tối đa 1 giây
select {
case res := <-resultChan:
fmt.Println("Xử lý:", res)
case <-time.After(1 * time.Second):
fmt.Println("Quá thời gian, báo lỗi cho máy quẹt thẻ!")
return // Hàm cha kết thúc, bỏ mặc luồng con!
}
}
Cách giải quyết:
- Dùng Buffered Channel có kích thước bằng 1:
make(chan string, 1). Goroutine con ném kết quả vào khay rồi tự thoát (không bị Block), sau đó GC sẽ dọn sạch cả Channel lẫn Goroutine.- Hoặc dùng
contextđể báo cho Goroutine con biết mà hủy việc [cite: 18].
Bẫy 2: Lãng quên cancel() của Context
Khi dùng context.WithCancel hoặc context.WithTimeout, Go Runtime sinh ra các Goroutine ngầm để quản lý đồng hồ đếm ngược và tín hiệu hủy [cite: 18, 19].
Nếu bạn không gọi hàm cancel() ở cuối khối lệnh, những Goroutine giám sát đó sẽ bị treo lại trong bộ nhớ cho đến khi (hoặc nếu) Timeout xảy ra [cite: 18, 19].
func handleRequest() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
// 🚨 QUÊN LỆNH: defer cancel()
// Nếu db.Query chạy xong cực nhanh trong 10ms,
// cái context trên vẫn sẽ sống lù lù trong RAM thêm 4 phút 59 giây nữa!
db.Query(ctx, "SELECT * FROM tickets")
}
Bẫy 3: Vòng lặp vô tận không có lối thoát (No escape condition)
Nhiều Consumer/Worker lắng nghe dữ liệu từ RabbitMQ hoặc Kafka thường được viết bằng một vòng lặp for { } vô tận. Nếu bạn quên không cài cắm context.Done() hoặc không kiểm tra trạng thái đóng cửa của ứng dụng (Graceful Shutdown), những luồng này sẽ cắn chặt CPU và RAM mãi mãi.
3. "Kính chiếu yêu" phát hiện rò rỉ
Rò rỉ rất khó phát hiện ở môi trường Test vì bạn chạy code xong là tắt. Nó chỉ bùng phát trên Production sau vài ngày hoặc vài tuần.
Để truy vết, hãy gắn 2 công cụ đo lường sau vào dự án Backend của bạn:
Cách 1: Lấy tổng số lượng Goroutine theo thời gian thực
Sử dụng hàm runtime.NumGoroutine(). Bạn có thể phơi bày con số này qua một API nội bộ, sau đó dùng Prometheus và Grafana để vẽ biểu đồ.
Nếu biểu đồ số lượng Goroutine cứ tăng dần đều theo đường chéo hướng lên (chỉ tăng mà không bao giờ giảm), 100% hệ thống của bạn đang bị Leak!
import "runtime"
func healthCheckAPI(w http.ResponseWriter, r *http.Request) {
count := runtime.NumGoroutine()
fmt.Fprintf(w, "Số Goroutines đang chạy: %d", count)
}
Cách 2: Vũ khí hạng nặng net/http/pprof
Đây là thư viện profiling đỉnh cao được tích hợp sẵn trong Go. Nó cho phép bạn chụp X-quang toàn bộ RAM, CPU và Goroutine của máy chủ.
Bạn chỉ cần import nó vào hàm main:
import _ "net/http/pprof"
func main() {
// Mở một cổng nội bộ để xem X-quang
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// ... logic app ...
}
Khi Server đang chạy, bạn chỉ cần gõ lệnh sau vào Terminal, Go sẽ liệt kê chi tiết từng Goroutine đang bị kẹt ở dòng code số mấy:
go tool pprof http://localhost:6060/debug/pprof/goroutine
4. Nguyên tắc sống còn của một Senior Gopher
Để không bao giờ tạo ra những "bóng ma" trong hệ thống, hãy thuộc lòng câu thần chú này:
"Trước khi tung từ khóa
go, bạn phải biết chính xác Goroutine đó sẽ kết thúc (return) BẰNG CÁCH NÀO và KHI NÀO."
- Luôn truyền
contextvào mọi Goroutine có vòng lặp vô tận hoặc tốn thời gian [cite: 21]. - Nếu dùng Channel để hứng dữ liệu kết quả mà có kèm Timeout, ưu tiên dùng Buffered Channel.
- Luôn luôn
defer cancel()khi dùng Context [cite: 18, 19].
All rights reserved