Goroutine rẻ, nhưng rò rỉ goroutine thì không. Bài này về loại lỗi mà bộ thu gom rác không cứu được.

Hiện tượng

func roRi() {
	ch := make(chan int)
	go func() { ch <- 1 }()    // không ai nhận
}
  trước 1 -> sau khi gọi 100 lần: 101 goroutine

Hàm roRi đã trả về từ lâu. Biến ch không còn ai tham chiếu. Nhưng goroutine bên trong vẫn sống, kẹt vĩnh viễn ở lệnh gửi.

Bộ thu gom rác không dọn goroutine. Nó chỉ dọn bộ nhớ không còn ai tham chiếu. Một goroutine đang chặn là một goroutine đang chạy theo cách nhìn của bộ chạy — nó giữ ngăn xếp của mình, giữ mọi biến nó đang cầm, và không bao giờ được thu hồi.

Trong dịch vụ web, mỗi request rò một goroutine nghĩa là sau một triệu request bạn có một triệu goroutine. Bài 31 đo được mỗi cái 576 byte — nhân lên là 576 MB, chưa kể dữ liệu chúng đang giữ.

Bốn nguyên nhân

Một: gửi vào channel không ai nhận. Đúng ví dụ trên. Xảy ra khi hàm trả về sớm — gặp lỗi, hết giờ — mà goroutine con vẫn đang cố gửi kết quả.

func Lay(ctx context.Context) (int, error) {
	ch := make(chan int)
	go func() { ch <- tinhLau() }()      // RÒ nếu hết giờ
	select {
	case v := <-ch: return v, nil
	case <-ctx.Done(): return 0, ctx.Err()   // trả về, goroutine kẹt
	}
}

Sửa bằng channel có đệm 1: goroutine gửi được rồi thoát, dù không ai nhận.

ch := make(chan int, 1)     // một ký tự, hết rò rỉ

Hai: nhận từ channel không ai đóng. for v := range ch chặn vĩnh viễn nếu không ai gọi close.

Ba: thiếu ctx.Done() trong pipeline. Bài 37 đã nói: hạ nguồn ngừng đọc thì thượng nguồn kẹt.

Bốn: WaitGroup không bao giờ về 0. Gọi Add mà nhánh lỗi bỏ qua Done. Đây là lý do defer wg.Done() phải là dòng đầu tiên.

Phát hiện: runtime.NumGoroutine()

Rẻ tới mức không có lý do gì để không đưa lên bảng giám sát:

metrics.Gauge("goroutines", runtime.NumGoroutine())

Đường biểu diễn phải dao động quanh một mức, không tăng đều. Tăng tuyến tính theo lưu lượng là chẩn đoán gần như chắc chắn.

Chẩn đoán: pprof

import _ "net/http/pprof"

go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) }()
curl 'localhost:6060/debug/pprof/goroutine?debug=1'

Nó in ra mọi goroutine đang sống, nhóm theo ngăn xếp cùng số lượng. Rò rỉ hiện ra ngay: một ngăn xếp với số đếm rất lớn và tất cả đứng ở cùng một dòng.

Đây là công cụ tương đương jstack ở bài 72 sê-ri Java, nhưng gọn hơn vì nó đã gom nhóm sẵn.

Nhớ chỉ mở cổng pprof trên localhost hoặc sau xác thực — nó lộ khá nhiều thông tin nội bộ.

Phát hiện trong test: goleak

func TestMain(m *testing.M) {
	goleak.VerifyTestMain(m)
}

go.uber.org/goleak kiểm goroutine còn sót sau khi test xong và làm test đỏ nếu có.

Đây là cách tốt nhất để bắt rò rỉ sớm — trong test, thay vì trong sản xuất sau ba ngày chạy. Nếu dự án của bạn có mã đồng thời, thêm bốn dòng này là đầu tư rẻ nhất.

Bốn quy tắc phòng

Mỗi goroutine phải có đường thoát rõ ràng. Trước khi gõ go, trả lời được câu: "goroutine này kết thúc khi nào?"

Truyền ctx vào mọi goroutine sống lâu, và select trên ctx.Done().

Dùng channel có đệm 1 cho kết quả khi người gọi có thể bỏ đi.

Ai tạo thì người đó dọn. Hàm khởi động goroutine chịu trách nhiệm dừng nó — thường bằng defer cancel().

Thử ba mươi giây

Thêm vào endpoint kiểm tra sức khoẻ của dịch vụ:

w.Write([]byte(fmt.Sprintf("goroutines=%d", runtime.NumGoroutine())))

Gọi nó hai lần cách nhau một giờ dưới tải bình thường. Nếu con số thứ hai lớn hơn đáng kể, bạn có rò rỉ — và pprof sẽ chỉ ra đúng dòng trong ba mươi giây nữa.

Ngày mai: context trong mã đồng thời — lan truyền huỷ qua nhiều tầng.