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).

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
- Mỗi lần gửi:
select { case out <- v: case <-ctx.Done(): return }— không bao giờ gửi mù. - Mỗi giai đoạn:
defer close(out)— giai đoạn saurangebiết hết. - 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ấyDone()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:

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ấyctx.Done()và return. - Không ctx (chỉ break): goroutine trước=1, sau=3 — rò 2 goroutine. Consumer break → không ai đọc →
binhphuongkẹt ởout <- v*v→sinhkẹ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ề
- 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ênout <- 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. - 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ạndefer 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). - 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ằngruntime.NumGoroutine()tăng dần, pprof goroutine, hoặcgoleaktrong 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.