楼主这篇断在「每个连接配一个专属发送」上,正好是最关键的 Hub 模式,我帮你把后半段补完——这就是 gorilla 官方 chat 示例的核心思路,配一个专属 send channel + 一个专属写协程,从此告别 concurrent write panic。

核心结构:每个连接包成一个 Client,里面挂两个东西——conn 和 send chan []byte(建议带缓冲,比如 256)。所有想给这个连接发消息的地方(不管是回声、广播还是系统通知),都只往 send 里塞数据,绝不能直接碰 conn.WriteMessage。然后每个连接起一个 writePump 协程,它用 select 同时监听 send 通道和心跳 ticker,通道里来消息就写,ticker 到了就发 Ping。整个进程里,这个连接只有 writePump 一个协程在写,写入天然串行,panic 从根上没了。

Hub 负责中转:一个全局 Hub 维护 clients map[*Client]bool,跑一个 for { select { case client := <-register: ...; case client := <-unregister: ...; case message := <-broadcast: 遍历 clients 塞进各自 send } } 的主循环。因为只有一个协程在读写这个 map,连锁都省了。广播时如果某个 client 的 send 满了(对方太慢),别阻塞——直接 close(client.send) 然后从 map 里删掉,慢客户端就该被踢,不然一个卡住的连接会拖垮整个 Hub。readPump 那边也一样:读到 send 被关闭或写失败,就走注销流程。

心跳保活别偷懒:conn.SetReadDeadline(time.Now().Add(pongWait)),再设 conn.SetPongHandler(func(string) error { conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })——收到 Pong 就续期,超时读自动报错退出。writePump 里用 time.NewTicker(pingPeriod),pingPeriod 要明显小于 pongWait(比如 54s 对 60s),给网络留缓冲。另外每写一条数据或 Ping 前都 SetWriteDeadline,堵死的连接才不会永远挂着。

小坑提醒:send 关闭后 writePump 里再写会 panic,所以关闭动作统一在 Hub 侧做,收到的消息用带 ok 的 type 或者干脆约定「关连接 = 踢出 + 关通道」一个来源;另外别忘了在 readPump 退出时 defer 里做注销,否则客户端断线后 map 里会残留僵尸连接。