← 返回笔记列表

别用「关闭 channel」通知退出:close(done) 的时序安全性

发布于 来自 Kite

  • 工程笔记
  • Go-WebSocket
  • Go
  • 并发

关闭 send 通道来通知退出,为什么会 panic

一个连接要有两件事:待发送队列 send,以及「关门」的信号。 最直觉的写法是关闭 send —— 但关闭之后任何一次发送都会 panic, 而发送方可能有好几个:

  • Hub 投递消息;
  • 读循环回复回执;
  • 定时心跳。

它们的时序没有任何保证,你无法证明「关闭之后不会再有人投」。 所以正确的做法是另开一个只用于通知的通道,并且只由退出路径关闭一次:

func (c *Client) push(data []byte) bool {
    select { case <-c.done: return false; default: }
    select { case c.send <- data: return true; default: return false }
}

// close(done) 由 sync.Once 保证只发生一次:
// 投递方永远不会因为「通道已关闭」而 panic,最多是白投一次。
func (c *Client) close() { c.once.Do(func() { close(c.done) }) }

这里还有一个 Go 的语义要点:关闭通道是广播,所有 <-done 同时解除阻塞; 而「往通道里发一个值」只能唤醒一个接收者。通知退出要的就是广播,所以只能靠 close。

退出顺序:先摘出映射,再关闭连接

读循环退出时要做的是:

defer func() {
    h.unregister <- c   // 先让 Hub 把这条连接移出映射
    c.close()           // 再关闭:此后没人会再往 send 投递
}()

顺序反了会出现「连接已经被移出、但还有人在往它的 send 里投」, 那些消息就静静躺在通道里等着被 GC。

校验必须在升级之前

WebSocket 握手一旦完成,就只能靠关闭帧表达错误,前端拿不到任何原因。 因此身份校验必须放在 Upgrade 之前,并且以 JSON 返回:

uid := strings.TrimSpace(ctx.Query("uid"))
if !common.ValidUID(uid) {
    ctx.JSON(http.StatusBadRequest, gin.H{
        "error": "uid 非法:只允许字母、数字、下划线、连字符,长度 1~64",
    })
    return
}

字符集限制不只是洁癖:它顺手挡掉了往日志里注入换行、往页面里注入 HTML 的输入, 也避免了「同名不同形」的困惑(全角字符、零宽空格)。

身份不能由客户端说了算

msg.Sender = c.uid        // 发送者以连接身份为准,客户端传的 sender 一律忽略
msg.Time   = common.Now()

同理,报文解码要严格:早期版本直接 json.Unmarshal 且忽略错误, 结果客户端发一个空字符串就能让服务端往 Hub 里塞一条空消息。 现在三类非法上行(非 JSON / 缺 receiver / 空内容)都会回一条 type=error, 而不是静默丢弃 —— 静默丢弃的表现是「前端一直等回执」,比报错难排查得多。

优雅退出要分两步

http.Server.Shutdown 只等普通请求,已经升级成 WebSocket 的连接不在它的管理范围内。 所以顺序必须是:

  1. 停掉监听(Shutdown);
  2. 再统一断开还挂着的 WebSocket(hub.Close())。
// h.Close() 放在 serve 之后:先把监听停掉,再统一断开还挂着的 WebSocket。
h := handle.New(webSocketConfig())
defer h.Close()

顺带:用 nil channel 表达「关闭」

配置项里 ping_period <= 0 表示关闭心跳。不用 if 包一层,而是:

var ping <-chan time.Time
if cfg.PingPeriod > 0 {
    t := time.NewTicker(cfg.PingPeriod)
    defer t.Stop()
    ping = t.C
}
// select 里 <-ping 在 ping 为 nil 时永久阻塞 —— 心跳随之关闭

nil channel 上的接收永远阻塞,这是 Go 里最干净的「禁用某个 case」写法, 比在 select 外面套判断、或者在循环里加 if enabled 都要可靠。