「Go语言面试题」16 - 实战经验:避免 panic!如何安全地关闭一个正在被多个goroutine读写的channel?

引言
fatal error: all goroutines are asleep - deadlock!
或者 panic: send on closed channel
- 这些错误信息是否让你感到头疼?在 Go 并发编程中,channel 的关闭时机和方式是个看似简单实则暗藏玄机的问题。特别是当多个 goroutine 同时读写同一个 channel 时,不当的关闭操作轻则导致 panic,重则引发难以调试的并发 bug。
为什么关闭 channel 如此棘手?
在深入解决方案之前,我们先看一个典型的错误示例:
func main() {
ch := make(chan int, 10)
// 启动多个生产者
for i := 0; i < 3; i++ {
go func() {
for j := 0; j < 10; j++ {
ch <- j
}
close(ch) // 错误:多个生产者尝试关闭
}()
}
// 消费者
for v := range ch {
fmt.Println(v)
}
}
这种代码会导致多个 goroutine 尝试关闭同一个 channel,从而引发 panic。那么正确的做法是什么呢?
方案一:单一所有者原则(最推荐)
核心思想:channel 的关闭操作应该由其唯一的所有者负责。通常这个所有者是创建 channel 的 goroutine 或者是专门的管理者。
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 func(id int) {
defer wg.Done()
for j := 0; j < 5; j++ {
select {
case ch <- id*100 + j:
case <-done: // 收到关闭信号
return
}
}
}(i)
}
// 启动消费者
go func() {
for v := range ch {
fmt.Printf("Received: %d\n", v)
}
}()
// 等待所有生产者完成
wg.Wait()
close(ch) // 安全关闭:由主 goroutine 负责
close(done)
time.Sleep(time.Second) // 等待消费者处理完毕
}
方案二:使用 sync.Once 确保只关闭一次
当确实需要从多个地方触发关闭操作时,可以使用 sync.Once
来保证关闭操作只执行一次。
func main() {
ch := make(chan int, 10)
var once sync.Once
// 多个可能触发关闭的 goroutine
for i := 0; i < 3; i++ {
go func(id int) {
for j := 0; j < 5; j++ {
ch <- id*100 + j
}
if id == 0 { // 某个特定条件触发关闭
once.Do(func() {
close(ch)
})
}
}(i)
}
for v := range ch {
fmt.Printf("Received: %d\n", v)
}
}
方案三:基于 context 的优雅关闭
在生产环境中,我们通常使用 context 来管理 goroutine 的生命周期:
func producer(ctx context.Context, ch chan<- int, id int) {
for j := 0; j < 10; j++ {
select {
case ch <- id*100 + j:
case <-ctx.Done():
return
}
}
}
func main() {
ch := make(chan int, 10)
ctx, cancel := context.WithCancel(context.Background())
// 启动多个生产者
for i := 0; i < 3; i++ {
go producer(ctx, ch, i)
}
// 消费者
go func() {
for v := range ch {
fmt.Printf("Received: %d\n", v)
}
}()
// 模拟运行一段时间后关闭
time.Sleep(time.Millisecond * 100)
cancel() // 通知生产者停止
close(ch) // 关闭 channel
time.Sleep(time.Second)
}
最佳实践总结
- 明确所有权:确定哪个 goroutine 负责关闭 channel
- 使用同步原语:sync.WaitGroup 用于等待完成,sync.Once 用于确保单次关闭
- 优先使用 context:用于跨 goroutine 的生命周期管理
- 避免重复关闭:这是导致 panic 的最常见原因
- 关闭后不再发送:设计时要确保关闭后不会有 goroutine 尝试发送数据
完整示例
package main
import (
"context"
"fmt"
"sync"
"time"
)
func main() {
ch := make(chan int, 10)
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
// 启动 3 个生产者
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 5; j++ {
select {
case ch <- id*100 + j:
time.Sleep(time.Millisecond * 50)
case <-ctx.Done():
return
}
}
}(i)
}
// 启动消费者
go func() {
for v := range ch {
fmt.Printf("Consumed: %d\n", v)
}
fmt.Println("Channel closed, consumer exiting")
}()
// 等待所有生产者完成
wg.Wait()
cancel() // 通知可能还在运行的 goroutine
close(ch) // 安全关闭 channel
time.Sleep(time.Second)
fmt.Println("Program exited gracefully")
}
思考题
在你的项目实践中,是否遇到过因为 channel 关闭不当导致的 bug?你是如何发现和解决这些问题的?欢迎在评论区分享你的实战经验!
