Pipeline là mẫu Go đẹp: nối các giai đoạn xử lý bằng channel, mỗi giai đoạn một goroutine đọc từ channel trước và ghi vào channel sau. Nhưng nó ẩn một cái bẫy chết người: khi bạn dừng giữa chừng (consumer break sớm, hoặc một giai đoạn lỗi), các goroutine phía trước có thể kẹt mãi mãi trên thao tác gửi channel — rò goroutine. Bài này đo thật cái rò đó và chỉ ba quy tắc để pipeline luôn sạch.

Vấn đề: consumer ngừng đọc, producer kẹt

Xét một pipeline không có cơ chế hủy:

func sinh() <-chan int {
	out := make(chan int)
	go func() {
		for i := 0; ; i++ { out <- i }  // block MÃI nếu không ai đọc
	}()
	return out
}

Nếu consumer đọc vài giá trị rồi break, goroutine sinh kẹt cứng ở out <- i — channel không có ai nhận, nên lệnh gửi block vĩnh viễn. Goroutine đó không bao giờ thoát, và runtime không bao giờ dọn nó (khác biến trên heap — goroutine kẹt không phải rác GC thu được).

Ảnh chụp đoạn mã Go nền tối minh hoạ pipeline có huỷ và dọn tài nguyên đừng rò goroutine, pipeline nhiều giai đoạn nối bằng channel khi hủy giữa chừng mọi giai đoạn phải dừng sạch nếu không goroutine kẹt mãi, một vấn đề consumer ngừng đọc producer kẹt mãi SAI gửi không có select ctx func sinh chan int out bằng make chan int go func for i bằng 0 i cộng cộng out nhận i block mãi nếu không ai đọc return out consumer break sớm goroutine sinh kẹt ở out nhận i rò, hai sửa mỗi giai đoạn tôn trọng ctx cộng đóng channel func sinh ctx context.Context chan int out bằng make chan int go func defer close out báo giai đoạn sau hết for i bằng 0 i cộng cộng select case out nhận i case thiếu ctx.Done return hủy dừng sạch return out, ba ba quy tắc pipeline sạch 1 mỗi lần gửi select case out nhận v case thiếu ctx.Done return 2 mỗi giai đoạn defer close out giai đoạn sau range biết hết 3 hủy lan qua cùng ctx mọi giai đoạn cùng thấy Done, bốn sử dụng ctx cancel bằng context.WithCancel nhan bằng binhphuong ctx sinh ctx nối các giai đoạn qua cùng ctx for v range nhan if đủ cancel break hủy mọi giai đoạn dừng for range nhan đọc nốt để giai đoạn thoát sạch defer cancel luôn nên gọi để không rò context kiểm rò goroutine bằng runtime.NumGoroutine hoặc pprof

Hình 1: Pipeline có huỷ. Vấn đề (gửi không có select ctx → kẹt), cách sửa (select với ctx.Done + defer close), ba quy tắc, và cách dùng với cancel.

Sửa: mỗi giai đoạn tôn trọng ctx

func sinh(ctx context.Context) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)        // báo giai đoạn sau: HẾT
		for i := 0; ; i++ {
			select {
			case out <- i:
			case <-ctx.Done(): return  // hủy → dừng SẠCH
			}
		}
	}()
	return out
}

Mỗi giai đoạn nhận ctx, và mỗi lần gửi dùng select với <-ctx.Done(). Khi hủy, giai đoạn thấy Done() và return — thoát sạch thay vì kẹt. defer close(out) để giai đoạn sau (dùng range) biết channel đã hết.

Ba quy tắc pipeline sạch

  1. Mỗi lần gửi: select { case out <- v: case <-ctx.Done(): return } — không bao giờ gửi mù.
  2. Mỗi giai đoạn: defer close(out) — giai đoạn sau range biết hết.
  3. Hủy lan qua CÙNG ctx: mọi giai đoạn chia sẻ một context, nên cancel() một lần làm tất cả cùng thấy Done() và dừng.

Đo thật: có hủy sạch, không hủy rò

Cho pipeline 2 giai đoạn, tiêu thụ 5 giá trị rồi dừng, đếm goroutine trước và sau:

Ảnh chụp bảng kết quả đo thật nền tối pipeline có huỷ không rò không huỷ rò 2 goroutine go run runtime.NumGoroutine Go 1.23 arm64 10 core pipeline 2 giai đoạn, tiêu thụ 5 giá trị rồi dừng có hủy vs không hủy cách CÓ ctx cộng select Done cộng close goroutine trước 1 goroutine sau 1 kết quả KHÔNG rò sạch KHÔNG ctx chỉ break goroutine trước 1 goroutine sau 3 kết quả RÒ 2 goroutine bản có hủy cancel làm mọi giai đoạn thấy ctx.Done và return goroutine về 1 sạch bản không hủy consumer break hai goroutine sinh và bình phương kẹt mãi ở out nhận v không ai đọc rò 2 goroutine vĩnh viễn, vì sao rò goroutine kẹt trên gửi channel consumer break không ai đọc thiếu nhan binhphuong kẹt ở out nhận v nhân v đầy không ai nhận sinh kẹt ở out nhận i 2 goroutine chờ mãi mãi không bao giờ được GC dọn goroutine kẹt trên channel không bao giờ bị thu hồi khác biến trên heap rò goroutine tích lũy theo thời gian hết bộ nhớ đây là bug rò phổ biến nhất trong Go, phát hiện rò goroutine runtime.NumGoroutine đếm tăng dần theo thời gian bằng rò pprof goroutine debug pprof goroutine xem stack kẹt goleak uber kiểm rò trong test, cốt lõi pipeline sạch mỗi giai đoạn select gửi với ctx.Done cộng defer close đo thật có hủy 1 sang 1 sạch không hủy 1 sang 3 rò 2 nguyên nhân goroutine kẹt trên out nhận v khi consumer ngừng đọc hủy lan cùng một ctx xuyên mọi giai đoạn cùng dừng phát hiện NumGoroutine tăng dần pprof goleak

Hình 2: Có ctx + select Done + close: goroutine 1 → 1 (sạch). Không ctx (chỉ break): goroutine 1 → 3 (rò 2). Nguyên nhân goroutine kẹt trên gửi channel, và cách phát hiện.

  • Có ctx + select Done + close: goroutine trước=1, sau=1 — không rò. cancel() làm mọi giai đoạn thấy ctx.Done() và return.
  • Không ctx (chỉ break): goroutine trước=1, sau=3 — rò 2 goroutine. Consumer break → không ai đọc → binhphuong kẹt ở out <- v*v → sinh kẹt ở out <- i → 2 goroutine chờ mãi mãi.

Goroutine kẹt trên channel không bao giờ bị thu hồi. Rò goroutine tích lũy theo thời gian → hết bộ nhớ. Đây là bug rò phổ biến nhất trong Go.

Ứng dụng thực tế

Mọi hàm trả channel nên nhận context. Nếu một hàm chạy goroutine ghi vào channel bạn trả về, hãy cho nó ctx và select trên Done() ở mọi lần gửi. Người gọi có thể không đọc hết (lỗi, timeout, đủ dữ liệu) — không có ctx là rò chắc chắn.

Luôn defer cancel(). context.WithCancel/WithTimeout trả một cancel — luôn defer cancel() ngay cả khi pipeline chạy hết bình thường. Không gọi cancel rò chính context (một loại rò tài nguyên khác). go vet cảnh báo nếu bạn quên.

Đọc nốt channel sau khi hủy nếu cần. Sau cancel() + break, giai đoạn cuối có thể còn một giá trị đang chờ gửi. Vòng for range nhan {} để rút cạn giúp giai đoạn cuối thoát sạch (nó select thấy Done sau đó). Tùy thiết kế mà cần hay không.

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

Select trên mọi gửi thêm chút chi phí và code. Mỗi select { case out<-v: case <-ctx.Done() } phức tạp hơn out<-v thuần và chậm hơn nhẹ. Với pipeline throughput cực cao, chi phí select đáng cân nhắc — nhưng rò goroutine luôn tệ hơn, nên đừng bỏ ctx để "tối ưu". An toàn trước.

Rò goroutine im lặng cho tới khi hết bộ nhớ. Khác panic hay lỗi rõ ràng, rò goroutine tích lũy âm thầm — service chạy tốt hàng giờ rồi OOM. Vì vậy phòng (dùng ctx đúng) quan trọng hơn chữa. Thêm goleak (uber-go/goleak) vào test để bắt rò lúc CI, và theo dõi NumGoroutine/pprof trên production.

Không phải mọi goroutine cần context. Goroutine sống ngắn, tự kết thúc (không block vô hạn) không cần ctx. Ctx là cho goroutine có thể block chờ (gửi channel, đọc mạng, chờ khóa). Đừng rải ctx khắp nơi máy móc — dùng nơi có nguy cơ block thật.

Ba ý mang về

  1. Pipeline dừng giữa chừng rò goroutine nếu producer không tôn trọng hủy: đo thật, consumer break sớm khiến 2 goroutine (sinh, binhphuong) kẹt mãi trên out <- v (không ai đọc) → goroutine 1 → 3, rò 2 vĩnh viễn; goroutine kẹt trên channel không bao giờ được GC dọn.
  2. Ba quy tắc pipeline sạch: mỗi lần gửi dùng select { case out<-v: case <-ctx.Done(): return }, mỗi giai đoạn defer close(out), và hủy lan qua cùng một ctx xuyên mọi giai đoạn — đo thật với đủ ba quy tắc, cancel() làm goroutine về sạch (1 → 1).
  3. Rò goroutine là bug im lặng, phòng hơn chữa: luôn defer cancel(), cho mọi hàm trả channel một context, phát hiện bằng runtime.NumGoroutine() tăng dần, pprof goroutine, hoặc goleak trong test — nhưng chỉ dùng ctx nơi goroutine thật sự có thể block.

Bài tập làm thử

Bài 1 (đọc hiểu). Cho hàm sinh không có cơ chế hủy:

func sinh() <-chan int {
	out := make(chan int)
	go func() {
		for i := 0; ; i++ { out <- i }
	}()
	return out
}

func main() {
	ch := sinh()
	for i := 0; i < 5; i++ {
		fmt.Println(<-ch)
	}
	// break, không đọc thêm nữa
}

Sau khi main kết thúc vòng lặp và không đọc ch nữa, goroutine bên trong sinh sẽ ở trạng thái nào? Nó có bao giờ được dọn dẹp không?

Đáp án

Goroutine đó kẹt vĩnh viễn tại lệnh out <- i vì không còn ai nhận từ channel out nữa (lệnh gửi trên channel không buffer sẽ block mãi nếu không có người nhận). Nó không bao giờ được runtime dọn dẹp — khác với bộ nhớ trên heap không còn tham chiếu (được GC thu hồi), một goroutine đang block không phải là rác mà GC nhận diện được; đây chính là hiện tượng rò goroutine (goroutine leak).

Bài 2 (sửa lỗi). Sửa hàm sinh dưới đây để nó tôn trọng việc hủy qua context và không rò goroutine khi consumer dừng đọc giữa chừng.

func sinh() <-chan int {
	out := make(chan int)
	go func() {
		for i := 0; ; i++ {
			out <- i
		}
	}()
	return out
}
Đáp án
func sinh(ctx context.Context) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for i := 0; ; i++ {
			select {
			case out <- i:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

Ba điểm sửa theo đúng ba quy tắc của bài: (1) mỗi lần gửi dùng select với nhánh <-ctx.Done() để không gửi mù; (2) defer close(out) để giai đoạn sau (dùng range) biết khi nào hết dữ liệu; (3) nhận ctx từ bên ngoài để hủy có thể lan tới từ một cancel() chung.

Bài 3 (vận dụng thực tế). Bạn đang thiết kế một pipeline 3 giai đoạn (sinh → lọc → tổng hợp), mỗi giai đoạn là một hàm trả về channel và chạy trong goroutine riêng. Người gọi cuối cùng có thể dừng đọc sớm vì đã tìm được đủ kết quả. Nêu quy tắc bắt buộc để cả 3 giai đoạn dừng sạch, không rò goroutine.

Đáp án

Cả 3 giai đoạn phải nhận và chia sẻ cùng một context: khi người gọi cuối cùng gọi cancel() (thường qua defer cancel() ngay khi tạo context), mọi giai đoạn đều thấy ctx.Done() đóng gần như đồng thời. Mỗi giai đoạn phải dùng select { case out <- v: case <-ctx.Done(): return } ở mọi lần gửi ra channel của mình, và defer close(out) khi thoát để giai đoạn sau (đang range trên channel) biết dừng theo dây chuyền.

Bài 4 (đọc số liệu đo thật). Bài đo thật cho pipeline 2 giai đoạn, tiêu thụ 5 giá trị rồi dừng: bản có ctx+select+close cho số goroutine trước/sau là 1/1; bản không có ctx (chỉ break) cho số goroutine trước/sau là 1/3. Giải thích ý nghĩa con số "rò 2 goroutine" và chỉ đích danh hai goroutine bị rò trong ví dụ pipeline sinh → binhphuong.

Đáp án

"Rò 2 goroutine" nghĩa là sau khi consumer dừng đọc, có 2 goroutine không bao giờ thoát và tồn tại vĩnh viễn trong chương trình (số goroutine tăng từ 1 lên 3, tức thêm 2). Trong ví dụ, đó là goroutine của hàm sinh (kẹt ở out <- i vì không ai đọc) và goroutine của hàm binhphuong (kẹt ở out <- v*v vì sinh đã ngừng gửi/consumer không đọc nữa) — cả hai chờ mãi mãi trên thao tác gửi channel không có người nhận.

Bài 5 (bẫy/đánh đổi). Một lập trình viên nói: "Thêm select với ctx.Done() vào mọi lần gửi channel làm code chậm hơn và dài hơn — với pipeline throughput cực cao, tôi sẽ bỏ nó để tối ưu tốc độ." Bạn có đồng ý không? Vì sao?

Đáp án

Không nên bỏ. Đúng là select thêm chút chi phí runtime và làm code phức tạp hơn so với gửi thuần out <- v, nhưng cái giá của việc bỏ nó là rò goroutine — một lỗi im lặng, tích lũy dần theo thời gian cho tới khi hết bộ nhớ (OOM), nghiêm trọng hơn nhiều so với việc chậm đi một chút. Nguyên tắc đúng là "an toàn trước": chỉ cân nhắc bỏ select nếu có bằng chứng cụ thể chi phí đó là nút thắt thật sự (qua profiling), và ngay cả khi đó vẫn cần cơ chế hủy khác thay thế, không được bỏ hoàn toàn.

Phần sau ta xem một mẫu phối hợp đồng thời khác — khi nhiều goroutine phải cùng đến một điểm rồi mới đi tiếp: Phần sau mổ xẻ barrier — cách đồng bộ nhiều goroutine tại một rào chắn (dùng sync.WaitGroup, channel, hoặc sync.Cond), và khi nào cần mẫu này.