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