Sửa lỗi 'panic: send on closed channel' trong Go Goroutines

intermediate🔷 Go2026-03-21| Go 1.18+, Linux / macOS / Windows, mọi chương trình sử dụng goroutine và channel

Error Message

panic: send on closed channel
#goroutine#channel#concurrency#panic

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.go và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ế.

Related Error Notes