Hình dung một goroutine như người giao hàng bạn phái đi với một gói và lời dặn "đưa cho ai đứng ở quầy". Nếu quầy bỏ trống — không ai nhận — người đó đứng đó mãi, ôm gói trong tay. 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 — như người giao hàng vẫn đứng ở quầy dù cả toà nhà đã quên mất anh ta.

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.

Đội dọn dẹp chỉ hốt phòng trống và đồ bỏ đi; nó không bao giờ đuổi một người đang "còn việc" về. 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. Cho người giao hàng một cái hộp để bỏ gói vào rồi đi, thay vì bắt đứng chờ người 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 — cả đám người giao hàng kẹt ở đúng một cái quầy.

Đâ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().

Nếu chỉ làm một thứ sau bài này, gắn một con số vào endpoint kiểm tra sức khoẻ rồi theo dõi nó trong ba mươi giây (và vài giờ sau nữa):

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.

Mẫu số chung

Một đơn vị đồng thời bị chặn vĩnh viễn là một kiểu rò rỉ mà bộ thu gom rác không giúp được — dưới mắt bộ chạy nó vẫn sống, là một gốc giữ lấy ngăn xếp của mình và mọi biến nó cầm. Đây không phải nét riêng của Go: một Thread của Java đỗ trên hàng đợi không ai nạp, một task asyncio của Python không ai chờ, một việc trong pool không bao giờ trả về — tất cả rò y hệt và tất cả vô hình với GC. Bài học: GC thu hồi dữ liệu không ai với tới, không bao giờ thu hồi thực thi bị kẹt — "rò bộ nhớ" và "rò luồng/task" là hai con bọ khác nhau cần hai công cụ khác nhau (trình phân tích heap so với một bản chụp goroutine/thread như pprof hay jstack). Phải biết mình đang truy cái nào.

Điều thứ hai: mọi tác vụ bạn sinh ra đều cần một đường kết thúc được bảo đảm và một cách để bị huỷ — đó đúng là tinh thần của đồng thời có cấu trúc (scope coroutine của Kotlin, StructuredTaskScope của Java, nursery của Trio, task group của Swift): vòng đời một tác vụ con bị chặn trong vòng đời cha, không ai bị bỏ rơi. Phiên bản làm tay của Go chính là bốn quy tắc ở trên — truyền ctx và select trên Done, đệm 1 cho kết quả mà người gọi có thể bỏ, ai tạo thì người đó dọn. Bản năng cần có: trước khi gõ go (hay new Thread, hay create_task), trả lời cho được "cái này kết thúc khi nào, và ai huỷ nó?" — một cú sinh ra mà không huỷ được là một rò rỉ đang chờ tải tới.

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

Bài tập làm thử

Bài 1 (đọc hiểu). Sau khi gọi hàm roRi() sau 100 lần, số goroutine tăng thêm bao nhiêu, và vì sao bộ thu gom rác (GC) không dọn được chúng dù hàm đã trả về từ lâu?

func roRi() {
	ch := make(chan int)
	go func() { ch <- 1 }()
}
Đáp án

Tăng thêm 100 goroutine (theo số liệu bài viết: trước 1 → sau khi gọi 100 lần: 101 goroutine). ch là channel không đệm, goroutine con chặn vĩnh viễn ở lệnh gửi ch <- 1 vì không ai còn nhận (biến ch cục bộ đã ra khỏi phạm vi sau khi roRi trả về). GC 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 vẫn được runtime coi là "đang chạy", giữ nguyên ngăn xếp và mọi biến nó đang cầm, nên không bao giờ được thu hồi.

Bài 2 (sửa lỗi). Hàm sau bị rò rỉ goroutine khi ctx hết giờ trước khi tinhLau() xong. Sửa lỗi bằng đúng mẫu bài viết đưa ra (channel có đệm 1):

func Lay(ctx context.Context) (int, error) {
	ch := make(chan int)
	go func() { ch <- tinhLau() }()
	select {
	case v := <-ch:
		return v, nil
	case <-ctx.Done():
		return 0, ctx.Err()
	}
}
Đáp án
func Lay(ctx context.Context) (int, error) {
	ch := make(chan int, 1) // đệm 1: goroutine gửi được rồi thoát dù không ai nhận
	go func() { ch <- tinhLau() }()
	select {
	case v := <-ch:
		return v, nil
	case <-ctx.Done():
		return 0, ctx.Err()
	}
}

Với channel không đệm, nếu ctx.Done() thắng trong select, hàm trả về ngay nhưng goroutine con vẫn kẹt mãi ở ch <- tinhLau() vì không còn ai đọc ch. Channel có đệm 1 cho goroutine con một "chỗ để đặt gói hàng" và thoát ra được dù không ai nhận.

Bài 3 (vận dụng). Viết đoạn mã dùng runtime.NumGoroutine() để đưa số lượng goroutine hiện tại vào một endpoint giám sát đơn giản, và mô tả dấu hiệu (hình dạng đồ thị theo thời gian) cho thấy có rò rỉ goroutine.

Đáp án
func handler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "goroutines=%d", runtime.NumGoroutine())
}

Dấu hiệu rò rỉ: đường biểu diễn số goroutine theo thời gian tăng đều/tuyến tính theo lưu lượng thay vì dao động quanh một mức ổn định. Gọi hai lần cách nhau một giờ dưới tải bình thường — nếu con số lần sau lớn hơn đáng kể, đó là chẩn đoán gần như chắc chắn có rò rỉ.

Bài 4 (bẫy/đánh đổi). Bài viết liệt kê bốn nguyên nhân rò rỉ goroutine. Với đoạn mã sau, xác định nguyên nhân nào trong bốn nguyên nhân đó đang xảy ra và sửa lại:

func XuLyHangLoat(items []Item) {
	var wg sync.WaitGroup
	for _, it := range items {
		wg.Add(1)
		go func(it Item) {
			if it.Loi() {
				return // quên gọi wg.Done()
			}
			defer wg.Done()
			xuLy(it)
		}(it)
	}
	wg.Wait()
}
Đáp án

Nguyên nhân thứ tư: "WaitGroup không bao giờ về 0" — nhánh lỗi (it.Loi()) trả về sớm và bỏ qua wg.Done() vì defer wg.Done() được khai sau điểm return sớm đó, nên với các item lỗi, Done() không bao giờ được gọi và wg.Wait() ở cuối chặn vĩnh viễn. Sửa bằng cách đặt defer wg.Done() làm dòng đầu tiên trong goroutine:

go func(it Item) {
	defer wg.Done()
	if it.Loi() {
		return
	}
	xuLy(it)
}(it)

Bài 5 (đọc hiểu/công cụ). Trong test, thêm đoạn mã sau vào có tác dụng gì, và tại sao bài viết gọi đây là "cách tốt nhất để bắt rò rỉ sớm"?

func TestMain(m *testing.M) {
	goleak.VerifyTestMain(m)
}
Đáp án

go.uber.org/goleak kiểm tra xem còn goroutine nào sống sót sau khi tất cả test trong file/package đã chạy xong, và làm cho bộ test đỏ (fail) nếu phát hiện goroutine còn sót lại. Đây là cách tốt nhất vì nó bắt rò rỉ ngay trong giai đoạn phát triển/CI — trước khi mã được triển khai — thay vì để nó âm thầm tích luỹ trong sản xuất sau vài ngày/giờ chạy thật rồi mới bị phát hiện qua theo dõi bộ nhớ hoặc sự cố.