需为每个WebSocket连接启动读写分离goroutine,用context控制生命周期,读循环处理CloseMessage和错误,写操作通过单goroutine串行channel完成,设读写deadline防挂起,避免并发写panic。

WebSocket连接建立后如何避免goroutine泄漏
Go 的 http.ServeHTTP 启动 WebSocket 服务时,每个连接对应一个长生命周期的 goroutine。若未显式控制退出,客户端断开后 goroutine 仍可能卡在 conn.ReadMessage 或 conn.WriteMessage 上,尤其在未设超时或未监听 done channel 的情况下。
正确做法是:为每个连接启动独立 goroutine 处理读、写,并用 context.WithCancel 统一控制生命周期;读循环中检测 websocket.CloseMessage 并主动调用 conn.Close();写操作必须加锁或通过 channel 串行化,防止并发写 panic。
- 永远不要在 handler 中直接循环
ReadMessage而不检查返回错误类型 ——websocket.CloseError和io.EOF需特殊处理 - 设置
conn.SetReadDeadline和conn.SetWriteDeadline,否则网络卡顿会导致 goroutine 永久挂起 - 使用
sync.Map存储活跃连接时,键建议用conn.RemoteAddr().String()或自增 ID,避免用原始*websocket.Conn作 map key(不可比较)
多客户端广播时如何避免 Write 争用和阻塞
多个 goroutine 同时调用同一个 *websocket.Conn.WriteMessage 会触发 panic: “write tcp: use of closed network connection” 或 “concurrent write to websocket connection”。根本原因是 WebSocket 连接不是并发安全的。
典型解法是为每个连接维护一个专属的写 channel(如 chan []byte),由单个 goroutine 从该 channel 读取并调用 WriteMessage;广播时向所有客户端的写 channel 发送消息,而非直接调用 WriteMessage。
立即学习“go语言免费学习笔记(深入)”;
type Client struct {
conn *websocket.Conn
send chan []byte
}
func (c *Client) writePump() {
defer c.conn.Close()
for {
select {
case message, ok := <-c.send:
if !ok {
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
}
}
}
- channel 缓冲区大小建议设为 16–64,过小易阻塞发送方,过大则内存堆积
- 广播前应检查 client.send 是否已关闭(
select { case _, ok := ),避免 panic - 不要在广播循环里调用
conn.WriteMessage—— 即使加了 mutex,也无法解决 TCP 写缓冲区满时的阻塞问题
如何安全地从 map 中删除断开的客户端
常见错误是:在读循环中检测到连接关闭后,直接从全局 map[string]*Client 中 delete,但此时另一个 goroutine 可能正遍历该 map 广播消息,导致 panic: “concurrent map read and map write”。
必须保证所有 map 修改(增/删)都在同一 goroutine 中完成,或使用 sync.RWMutex 保护读写。更推荐的做法是:将连接管理封装成结构体,提供 Register/Unregister 方法,内部用 channel 串行化操作。
type Manager struct {
clients map[string]*Client
broadcast chan Message
register chan *Client
unregister chan *Client
}
func (m *Manager) run() {
for {
select {
case client := <-m.register:
m.clients[client.id] = client
case client := <-m.unregister:
delete(m.clients, client.id)
close(client.send)
case msg := <-m.broadcast:
for _, client := range m.clients {
select {
case client.send <- msg.data:
default:
// send queue full, skip or close
}
}
}
}
}
- 客户端断开时,除了
deletemap,还必须close(client.send),否则其writePump会永久阻塞在 channel receive 上 - 避免在 HTTP handler 中直接修改全局 map —— handler 应只发
register或unregister事件到 manager 的 channel - 不要依赖
defer在 handler 结尾清理 map,因为 handler 返回不代表连接已断开(可能是长连接中间阶段)
客户端重连时如何避免重复注册和状态错乱
真实场景中,前端频繁刷新或网络抖动会触发大量重连请求,若服务端仅按 IP + 端口判重,会导致同一用户多个连接共存;若强制踢旧连接,则可能误杀正在传输关键消息的会话。
合理方案是:要求客户端在首次连接时带上唯一标识(如 JWT payload 中的 user_id 或前端生成的 session_id),服务端用该 ID 做去重依据,并支持“优雅替换”——先发通知给旧连接,等待其确认下线后再注册新连接。
- 解析 token 必须在 upgrade 前完成,否则无法拒绝非法连接;可用
websocket.Upgrader.CheckOrigin或中间件提前校验 - 存储用户 ID 到连接映射时,用
sync.Map替代普通 map,避免为每个用户加锁 - 不要把用户状态(如在线/离线)全放在内存 map 中 —— 关键状态应落库,map 仅作快速查找索引
最易被忽略的一点:WebSocket 连接关闭后,底层 TCP 连接可能仍处于 TIME_WAIT 状态,此时相同四元组的新连接会被内核延迟接受,表现为前端重连慢或失败 —— 这不是 Go 代码问题,但排查时容易误判为服务端逻辑缺陷。










