
本文介绍在 go 中通过共享退出通道(quit channel)协调多个 goroutine 的生命周期,确保任一 goroutine 异常或正常退出时,其他 goroutine 能立即响应并安全退出,避免资源泄漏和 goroutine 泄露。
在构建 WebSocket 服务(如基于 gorilla/websocket)时,常见模式是为每个连接启动两个长期运行的 Goroutine:一个负责从客户端读取消息(readFromSocket),另一个负责向客户端发送消息(writeToSocket)。理想情况下,二者应“同生共死”——任一 Goroutine 因连接断开、错误或主动关闭而退出时,另一个也应立即停止,而非继续阻塞在通道读取或等待中。
原代码的问题在于:writeToSocket 依赖 p.writeChan 的关闭来退出循环,但 close(p.writeChan) 发生在 Cleanup() 中,而 Cleanup() 又依赖 p.closeEventChan 的信号——该信号仅由 readFromSocket 单方面触发。这导致典型的竞态:若 readFromSocket 先退出并触发 Cleanup(),writeToSocket 才会收到通道关闭信号;但若 writeToSocket 因写失败先退出,则 readFromSocket 仍无限循环,造成 Goroutine 泄漏。
✅ 正确解法是引入统一的退出信号源——一个只读的 quit
以下是重构后的核心实现:
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
// 广播退出信号给所有协作 Goroutine
close(quit)
// 等待剩余 Goroutine 完成清理(此处共 2 个,已收 1 个,还需收 1 个)
<-p.closeEventChan
p.cleanup()
}
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
}
// 处理消息逻辑(如转发到 writeChan 等)
}
}
}⚠️ 关键注意事项:
- quit 通道使用 struct{} 类型,零内存开销,语义清晰(仅作信号传递);
- select +
- defer 确保无论何种路径退出,都能向 p.closeEventChan 发送完成信号;
- 主协程需按预期数量接收 closeEventChan 信号(本例为 2 次),避免因漏收导致阻塞;
- p.writeChan 和 p.closeEventChan 本身不应在 EventLoop 中提前关闭,而应由 Cleanup() 在所有子 Goroutine 退出后统一关闭(或由 defer 隐式处理)。
这种模式可轻松扩展至 N 个协作 Goroutine(如增加 ping/pong 心跳协程),只需共享同一 quit 通道并计数等待即可,是 Go 并发编程中管理协同任务生命周期的标准实践。










