Lỗi gặp phải
Chương trình Go của bạn bị crash với thông báo:
goroutine 1 [running]:
main.main()
/home/user/app/main.go:25 +0x68
goroutine 6 [running]:
main.worker()
/home/user/app/main.go:14 +0x44
panic: send on closed channel
Một goroutine đã cố gắng đẩy giá trị vào một channel đã bị đóng. Đọc từ channel đã đóng là hoàn toàn ổn — Go trả về giá trị zero và tiếp tục. Nhưng gửi thì khác. Bất kỳ lệnh gửi nào vào channel đã đóng đều gây panic ngay lập tức, không có ngoại lệ.
Nguyên nhân gốc rễ
Bốn tình huống thường xuyên gây ra panic này:
- Một goroutine đóng channel trong khi goroutine khác vẫn đang gửi dữ liệu vào đó.
- Channel bị đóng hai lần (cũng gây panic, nhưng với thông báo
panic: close of closed channel). - Goroutine worker tiếp tục chạy sau khi coordinator đã gọi
close(ch). - Timeout hoặc hủy thao tác làm sập một pipeline trong khi các producer đang gửi dở.
Ví dụ tái hiện tối giản — rất dễ nhận ra:
package main
func main() {
ch := make(chan int)
close(ch) // channel đã bị đóng ở đây
ch <- 1 // panic: send on closed channel
}
Trong các codebase thực tế, vấn đề thường tinh vi hơn. Channel bị đóng trước khi các goroutine kịp khởi động:
package main
import (
"fmt"
"sync"
)
func main() {
ch := make(chan int, 10)
var wg sync.WaitGroup
// Đóng quá sớm — các worker chưa kịp khởi chạy
close(ch)
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
ch <- id // panic: send on closed channel
}(i)
}
wg.Wait()
fmt.Println("done")
}
Sửa nhanh: Recover từ Panic (Không dùng cho Production)
Go không có hàm isClosed() tích hợp sẵn. Bạn có thể bọc lệnh gửi trong defer recover() để bắt panic:
func safeSend(ch chan int, val int) (closed bool) {
defer func() {
if r := recover(); r != nil {
closed = true
}
}()
ch <- val
return false
}
Cách này có tác dụng. Nhưng đây chỉ là giải pháp vá víu cho vấn đề thực sự: quyền sở hữu channel không rõ ràng. Hãy thiết kế lại cho đúng thay vì dùng cách này.
Sửa triệt để: Bên gửi phụ trách đóng channel
Quy tắc bất thành văn của Go: chỉ bên gửi mới được đóng channel, không bao giờ là bên nhận. Đóng channel sau khi đã gửi xong tất cả, và chỉ đóng một lần duy nhất.
Pattern 1: Một Producer duy nhất
Một goroutine sở hữu toàn bộ channel. Nó gửi xong rồi đóng. Đơn giản và gọn gàng:
package main
import "fmt"
func produce(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // an toàn: chỉ goroutine này gửi dữ liệu
}
func main() {
ch := make(chan int)
go produce(ch)
for v := range ch {
fmt.Println(v)
}
}
Pattern 2: Nhiều Producer — Coordinator + sync.WaitGroup
Năm goroutine cùng gửi vào một channel? Không goroutine nào trong số đó nên tự đóng channel. Giao việc đó cho một coordinator:
package main
import (
"fmt"
"sync"
)
func main() {
ch := make(chan int, 10)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
ch <- id // an toàn: channel vẫn đang mở
}(i)
}
// Goroutine này chờ cả 5 sender xong rồi mới đóng
go func() {
wg.Wait()
close(ch)
}()
for v := range ch {
fmt.Println(v)
}
}
Pattern 3: Hủy thao tác với context.Context
Cần dừng worker sớm — chẳng hạn sau deadline 300ms — hãy dùng context.Context. Đừng đóng data channel từ phía receiver:
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, ch chan<- int, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("worker %d: đang dừng\n", id)
return
case ch <- id:
time.Sleep(100 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
ch := make(chan int, 5)
go worker(ctx, ch, 1)
go worker(ctx, ch, 2)
for {
select {
case v := <-ch:
fmt.Println("nhận được", v)
case <-ctx.Done():
fmt.Println("main: hoàn tất")
return
}
}
}
Mỗi worker theo dõi ctx.Done() và tự thoát. Channel vẫn mở cho đến khi tất cả bên gửi rời đi — không thể xảy ra panic.
Pattern 4: Done Channel (Tín hiệu dừng)
Không dùng context? Một channel done riêng biệt cũng làm được tương tự. Đóng nó để phát tín hiệu dừng đến tất cả worker cùng lúc:
package main
import (
"fmt"
"sync"
)
func worker(done <-chan struct{}, ch chan<- int, id int, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-done:
return
case ch <- id:
}
}
}
func main() {
ch := make(chan int, 10)
done := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go worker(done, ch, i, &wg)
}
// Nhận 10 giá trị
for i := 0; i < 10; i++ {
fmt.Println(<-ch)
}
// Phát tín hiệu dừng đến tất cả worker, chờ chúng kết thúc, rồi đóng data channel
close(done)
wg.Wait()
close(ch) // an toàn: tất cả bên gửi đã thoát
}
Bắt Panic trong Tests
Viết regression test để xác nhận code cũ đã gây panic và bản sửa của bạn hoạt động đúng:
func TestSafeSend(t *testing.T) {
ch := make(chan int)
close(ch)
// Không được panic sau khi sửa
closed := safeSend(ch, 42)
if !closed {
t.Error("expected closed=true")
}
}
Dùng Race Detector sớm
Flag -race không trực tiếp phát hiện panic: send on closed channel, nhưng nó phát lộ các data race dẫn đến lỗi này. Hãy chạy nó trước khi panic xuất hiện trên production:
go run -race main.go
go test -race ./...
Với dự án thực có hơn 20 goroutine, race detector thường xuyên phát hiện các lỗi timing mà review thủ công bỏ sót. Hãy tích hợp nó vào CI pipeline của bạn.
Kiểm tra sau khi sửa
Sau khi áp dụng bản sửa, hãy kiểm tra theo danh sách sau:
- Chạy
go test -race ./...— không có race condition nào được báo cáo. - Kiểm tra căng thẳng với
go run main.govài lần — không có output panic. - Nếu bạn dùng
context, kiểm tra xem worker có ghi log "đang dừng" khi bị hủy thay vì bị crash không. - Chạy
go vet ./...để phát hiện lỗi sử dụng channel tĩnh trước khi chạy thực tế.
Tóm tắt
- Bên gửi đóng channel, không bao giờ là bên nhận — quy tắc đơn giản này ngăn chặn hầu hết các channel panic.
- Nhiều bên gửi? Dùng coordinator goroutine + sync.WaitGroup để đóng sau lần gửi cuối cùng.
- Cần dừng sớm? Dùng context.Context hoặc done channel — không đóng data channel từ phía nhận.
- Luôn chạy test với -race. Các lỗi timing trông ổn ở local sẽ lộ ra dưới tải thực tế.

