「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)
}

最佳实践总结

  1. 明确所有权:确定哪个 goroutine 负责关闭 channel
  2. 使用同步原语:sync.WaitGroup 用于等待完成,sync.Once 用于确保单次关闭
  3. 优先使用 context:用于跨 goroutine 的生命周期管理
  4. 避免重复关闭:这是导致 panic 的最常见原因
  5. 关闭后不再发送:设计时要确保关闭后不会有 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?你是如何发现和解决这些问题的?欢迎在评论区分享你的实战经验!

wx

关注公众号

©2017-2023 鲁ICP备17023316号-1 Powered by Hugo