如何在 Go 中优雅地同步终止多个 goroutine

本文介绍使用共享退出通道(quit channel)协调多个 goroutine 的生命周期,确保任一 goroutine 异常或正常退出时,其余 goroutine 能及时响应并安全退出,避免资源泄漏和僵尸协程。

在构建 WebSocket 服务等长连接场景中,常见模式是为每个连接启动两个 goroutine:一个负责读取客户端消息(readFromSocket),另一个负责向客户端写入消息(writeToSocket)。若其中一个因网络断开、解码错误或连接关闭而提前退出,另一个可能仍在阻塞等待(如 range p.writeChan 持续监听已关闭但未清空的通道),导致资源无法释放、cleanup() 不被完全执行,甚至引发 panic。

根本问题在于:goroutine 之间缺乏双向通信与协同退出机制。仅靠关闭 writeChan 或 closeEventChan 并不能主动中断另一个 goroutine 的阻塞操作(如 conn.ReadJSON 或 range 循环)。Go 中推荐的解决方案是引入一个共享的、只读的 quit 通道,作为统一的“停止信号源”。

✅ 正确做法:基于 select + quit chan struct{} 的协作式退出

将 quit 通道作为参数注入每个工作 goroutine,在关键循环中通过 select 同时监听业务事件与退出信号:

func (p *Player) writeToSocket(quit <-chan struct{}) {
    defer func() { p.closeEventChan <- true }() // 统一通知主协程已退出
    for {
        select {
        case <-quit:
            return // 收到退出指令,立即返回
        case m, ok := <-p.writeChan:
            if !ok {
                return // writeChan 已关闭,无更多消息
            }
            if p.conn == nil || reflect.DeepEqual(network.Packet{}, m) {
                return
            }
            if err := p.conn.WriteJSON(m); err != nil {
                return // 写入失败,主动退出
            }
        }
    }
}

func (p *Player) readFromSocket(quit <-chan struct{}) {
    defer func() { p.closeEventChan <- true }()
    for {
        select {
        case <-quit:
            return
        default:
            if p.conn == nil {
                return
            }
            var m network.Packet
            if err := p.conn.ReadJSON(&m); err != nil {
                return // 读取失败(如 EOF、超时、解码错误),退出
            }
            // 处理 m,例如转发至业务逻辑或广播通道...
        }
    }
}
? 注意:readFromSocket 中避免使用 for range p.writeChan 或无限 for {},必须用 select 配合 quit,否则无法响应外部中断。

? 主协程协调:广播退出 + 等待收尾

EventLoop 负责创建 quit 通道、启动 worker,并在首个 worker 退出后广播终止信号,再等待所有 worker 完成清理:

func (p *Player) EventLoop() {
    l4g.Info("Starting player %s event loop", p)
    quit := make(chan struct{})
    go p.readFromSocket(quit)
    go p.writeToSocket(quit)

    // 等待任意一个 goroutine 发送退出通知
    <-p.closeEventChan

    // 广播退出信号:关闭 quit 通道 → 所有 select <-quit 分支立即触发
    close(quit)

    // 等待剩余 goroutine 完成退出(此处共 2 个,已收 1 个,还需收 1 个)
    <-p.closeEventChan

    p.cleanup()
}

cleanup() 可精简为:

func (p *Player) cleanup() {
    if p.conn != nil {
        p.conn.Close()
        p.conn = nil
    }
    // writeChan 和 closeEventChan 在此处已无需显式 close:
    // - writeChan 应由业务方控制(如 manager 关闭连接时发送空包或关闭它)
    // - closeEventChan 用于内部通知,通常在 EventLoop 结束前已关闭(见上文 close(quit) 后的两次接收)
}

⚠️ 关键注意事项

  • quit 通道只需关闭一次:close(quit) 向所有监听者广播信号,无需多次关闭。
  • defer 保证通知送达:每个 worker 用 defer 发送 p.closeEventChan
  • 避免 range + 关闭通道的陷阱:range ch 仅在通道关闭且缓冲区为空时退出;若写端未关闭或存在残留值,会永远阻塞。务必改用 select + ok 检查。
  • readFromSocket 不应依赖 p.writeChan 状态:读协程的退出应由连接状态或 quit 控制,而非写通道是否关闭。
  • 超时与上下文可选增强:生产环境建议结合 context.Context(如 ctx.Done() 替代 quit),支持更丰富的取消语义(如超时、父子传递)。

通过该模式,readFromSocket 和 writeToSocket 实现了真正的双向生命周期绑定:任一退出,另一方在下一个循环周期内必然响应并退出,最终由 EventLoop 完成原子性清理——这是构建健壮、可维护并发 Go 服务的核心实践之一。