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.

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.