Goroutine rẻ đến mức người ta tạo chúng thoải mái — nhưng "rẻ" không phải "miễn phí", và có một loại bug âm thầm giết chết dịch vụ Go trong sản xuất: rò rỉ goroutine. Khi một goroutine bị kẹt vĩnh viễn (chờ nhận từ channel không ai gửi, chờ khóa không bao giờ nhả, chờ một event không bao giờ đến), nó không thoát — và bộ thu gom rác không dọn được một goroutine đang sống. Mỗi request rò một goroutine, số goroutine tăng mãi theo thời gian, ngốn bộ nhớ (mỗi cái vài KB stack, cộng những gì nó giữ) cho tới khi hết RAM và dịch vụ sập.

Điều nguy hiểm là nó âm thầm: dịch vụ chạy bình thường hàng giờ, rồi chậm dần, rồi OOM lúc 3 giờ sáng. Bài này gây một rò rỉ goroutine thật, chỉ ra dấu hiệu (NumGoroutine tăng không giảm), và dùng pprof goroutine để tìm chính xác dòng code gây rò.

Hàm rò rỉ: goroutine chờ channel mãi mãi

func xuLyRoRi() {
	ch := make(chan int) // không đệm, không ai gửi
	go func() {
		<-ch // CHỜ MÃI MÃI -> goroutine này không bao giờ thoát
	}()
	// hàm trả về nhưng goroutine bên trong kẹt vĩnh viễn -> RÒ RỈ
}

<-ch trên một channel không ai gửi sẽ chặn vĩnh viễn. Hàm xuLyRoRi trả về, nhưng goroutine bên trong không bao giờ thoát. Đây là mẫu rò rỉ phổ biến nhất trong thực tế: một goroutine chờ kết quả (từ channel, HTTP response, khóa) mà đường gửi/thoát không bao giờ xảy ra.

Hàm đúng: luôn có lối thoát

func xuLyDung() {
	ch := make(chan int, 1) // có đệm HOẶC
	go func() {
		select {
		case <-ch:
		case <-time.After(10 * time.Millisecond): // LỐI THOÁT
		}
	}()
}

Nguyên tắc vàng: mọi goroutine phải có đường thoát đảm bảo. Ở đây select với một nhánh timeout đảm bảo goroutine thoát dù ch không bao giờ có dữ liệu. Trong thực tế, lối thoát thường là context.Context bị huỷ (bài context trước), timeout, hoặc thiết kế sao cho bên gửi/nhận luôn xảy ra.

Ảnh chụp đoạn mã Go nền tối minh hoạ phát hiện rò rỉ goroutine sản xuất trong Go số goroutine tăng mãi không giảm, rò rỉ goroutine là gì và vì sao nguy hiểm goroutine bị kẹt vĩnh viễn chờ channel không ai gửi chờ khóa không bao giờ nhả không thoát GC không dọn được mỗi request rò 1 goroutine số goroutine tăng mãi theo thời gian ngốn bộ nhớ mỗi goroutine vài KB stack tới khi hết RAM sập dấu hiệu runtime NumGoroutine tăng đều không giảm lại, hàm rò rỉ goroutine chờ channel mãi mãi func xuLyRoRi ch bằng make chan int không đệm không ai gửi go func nhận ch chờ mãi mãi goroutine này không bao giờ thoát hàm trả về nhưng goroutine bên trong kẹt vĩnh viễn rò rỉ, hàm đúng luôn có lối thoát func xuLyDung ch bằng make chan int 1 có đệm hoặc go func select case nhận ch case nhận time After 10 mili giây lối thoát mọi goroutine phải có đường thoát context huỷ timeout hoặc đảm bảo bên gửi nhận luôn xảy ra, chẩn đoán đếm cộng pprof goroutine runtime NumGoroutine theo dõi số goroutine theo thời gian pprof Lookup goroutine WriteTo f 1 gom theo stack debug 1 gom goroutine cùng stack leak hiện thành 1 con số lớn

Hình 1: Rò rỉ goroutine — goroutine kẹt vĩnh viễn (chờ channel không ai gửi) không thoát nên GC không dọn; hàm đúng luôn có lối thoát (timeout/context); chẩn đoán bằng runtime.NumGoroutine và pprof goroutine.

Đo thật: số goroutine tăng và không giảm

Mô phỏng 1000 "request", mỗi cái gọi xuLyRoRi (rò một goroutine), rồi 1000 request với xuLyDung:

goroutine ban đầu: 1
sau 1000 request (có rò rỉ): 1001   // rò 1000, kẹt vĩnh viễn
sau 1000 request (hàm đúng, đã thoát): 1001

Đọc kỹ: sau 1000 request rò rỉ, số goroutine nhảy từ 1 lên 1001 — 1000 goroutine kẹt. Sau 1000 request với hàm đúng, số vẫn là 1001 (không phải 2001): các goroutine đúng đã tự thoát nên không cộng thêm, nhưng 1000 goroutine rò từ trước vẫn còn nguyên — không bao giờ được dọn, kể cả sau runtime.GC(). Đây là dấu hiệu kinh điển: NumGoroutine tăng rồi đứng cao, không giảm lại = rò rỉ.

pprof goroutine: tìm chính xác chỗ rò

Đếm cho biết có rò, nhưng không cho biết ở đâu. pprof goroutine với debug=1 gom các goroutine có cùng stack lại:

$ pprof.Lookup("goroutine").WriteTo(f, 1)

1000 @ 0x7b5c8 0x15bd4 0x157b4 0xd8a64 0x829e4
#	0xd8a63	main.xuLyRoRi.func1+0x23	/work/t129/main.go:13

Kết quả lộ ngay: 1000 goroutine cùng một stack, tất cả kẹt tại main.xuLyRoRi.func1 dòng main.go:13 — chính là dòng <-ch. Đây là điểm mạnh cốt lõi: leak hiện thành một con số lớn bất thường trên một stack duy nhất. Nhìn vào là biết đúng dòng code cần sửa. Trên production, dịch vụ đã có endpoint /debug/pprof/goroutine (bài continuous profiling); chụp hai lần cách nhau và so, stack nào có con số tăng dần chính là chỗ rò.

Ảnh chụp bảng kết quả đo thật nền tối phát hiện rò rỉ goroutine sản xuất trong Go chạy bằng go run Go 1.23 arm64 runtime NumGoroutine cộng pprof goroutine, số goroutine tăng và không giảm lại goroutine ban đầu 1 sau 1000 request có rò rỉ 1001 rò 1000 kẹt vĩnh viễn sau 1000 request hàm đúng đã thoát 1001 1001 giữ nguyên 1000 goroutine rò không bao giờ được dọn hàm đúng thì goroutine tự thoát nên không cộng thêm NumGoroutine tăng rồi đứng cao bằng dấu hiệu rò rỉ rõ ràng, pprof goroutine gom theo stack lộ ngay chỗ rò pprof Lookup goroutine WriteTo f 1 1000 at 0x7b5c8 0x15bd4 0x157b4 0xd8a64 0x829e4 0xd8a63 main xuLyRoRi func1 cộng 0x23 work t129 main.go 13 1 at goroutine của pprof bình thường 1000 goroutine cùng stack đều kẹt tại main.go 13 dòng nhận ch chỉ đúng dòng gây rò chờ nhận channel mãi mãi, trên production endpoint pprof cộng so số theo thời gian dịch vụ thật có endpoint debug pprof goroutine curl host 6060 debug pprof goroutine debug 1 grep at sort rn stack nào có con số tăng dần qua các lần chụp bằng chỗ rò rỉ so 2 lần chụp cách nhau thấy goroutine loại nào tăng, cốt lõi rò rỉ goroutine kẹt vĩnh viễn channel khóa GC không dọn dấu hiệu NumGoroutine tăng đều không giảm RAM tăng theo thời gian tìm chỗ pprof goroutine debug 1 gom theo stack leak bằng con số lớn đo thật 1000 request rò 1000 goroutine kẹt tại main.go 13 sửa mọi goroutine phải có lối thoát context timeout đảm bảo gửi nhận đánh đổi channel không đệm dễ kẹt luôn nghĩ ai đóng thoát goroutine

Hình 2: Đo thật — 1000 request rò rỉ đẩy NumGoroutine từ 1 lên 1001 và không giảm (kể cả sau GC); pprof goroutine gom theo stack lộ 1000 goroutine cùng kẹt tại main.xuLyRoRi.func1 main.go:13 (dòng <-ch) — chỉ đúng chỗ rò.

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

Channel không đệm rất dễ gây kẹt — luôn nghĩ "ai thoát goroutine này". Nguồn rò rỉ phổ biến nhất là gửi/nhận trên channel mà phía bên kia không xảy ra: gửi vào channel không ai đọc, đọc từ channel không ai gửi. Mỗi khi tạo một goroutine, hãy tự hỏi ngay: goroutine này thoát bằng cách nào, trong mọi trường hợp? Nếu không trả lời được rõ ràng (kể cả khi request bị huỷ, timeout, lỗi), bạn có mầm rò rỉ. context.Context truyền huỷ xuống là cơ chế chuẩn để đảm bảo lối thoát.

Rò rỉ khó thấy trong test, chỉ lộ ở quy mô/thời gian dài. Một rò rỉ một-goroutine-mỗi-request không gây triệu chứng trong unit test hay lúc chạy thử — nó cần nhiều request qua thời gian dài để tích tụ đủ ngốn RAM. Vì thế phải chủ động: theo dõi NumGoroutine như một metric (xuất qua Prometheus — bài metrics), đặt cảnh báo khi nó tăng đơn điệu, và có thư viện như go.uber.org/goleak để test phát hiện goroutine còn sót sau mỗi test.

Không phải mọi goroutine sống lâu là rò rỉ. Cẩn thận đọc kết quả: một số goroutine nên sống suốt đời chương trình (worker pool, background loop, server accept). Rò rỉ là goroutine lẽ ra phải thoát mà không thoát, và dấu hiệu là số tăng đơn điệu theo tải, không phải một con số cao ổn định. Đừng nhầm goroutine nền hợp lệ với rò rỉ — nhìn xu hướng (tăng mãi) và stack (kẹt ở chỗ lẽ ra phải xong).

Ba ý mang về

  1. Rò rỉ goroutine là goroutine kẹt vĩnh viễn mà GC không dọn được: mỗi request rò một cái, số goroutine tăng mãi tới khi hết RAM — đo thật, 1000 request rò rỉ đẩy NumGoroutine từ 1 lên 1001 và không giảm lại (kể cả sau GC), dấu hiệu rò rỉ rõ ràng.
  2. pprof goroutine (debug=1) chỉ đúng chỗ rò: gom goroutine cùng stack nên leak hiện thành một con số lớn — đo thật, 1000 goroutine cùng kẹt tại main.go:13 (dòng <-ch); trên production so hai lần chụp để thấy stack nào tăng dần.
  3. Phòng và phát hiện chủ động: mọi goroutine phải có lối thoát đảm bảo (context huỷ, timeout, đảm bảo gửi/nhận), theo dõi NumGoroutine như metric với cảnh báo tăng đơn điệu, dùng goleak trong test — và đừng nhầm goroutine nền hợp lệ (số cao ổn định) với rò rỉ (số tăng mãi).

Phần sau ta đọc sâu chính công cụ vừa dùng: đọc runtime.Stack và panic trace sâu trong Go — hiểu từng dòng của stack trace, trạng thái goroutine [running]/[chan receive], và cách đọc chúng để chẩn đoán nhanh.