大群聊广播延迟高源于单连接阻塞、小包高频唤醒、生命周期失控三处瓶颈;须设writedeadline、用io.copybuffer批量写、以context管控连接生命周期。
大群聊广播延迟高,根本不是 goroutine 不够多,而是 netpoll 事件链路被卡在了单连接阻塞、小包高频唤醒、生命周期失控这三处——改并发数没用,得动 setwritedeadline、io.copybuffer 和 context。
为什么 conn.Write() 会拖垮整批广播
逐个调用 conn.Write() 时,每个连接都触发一次 poller 状态检查和可能的 epoll_wait 唤醒。万级连接下,哪怕单次写仅耗 10μs,串行也超 100ms;更糟的是,一个慢连接(如 NAT 超时、客户端崩溃)若未设写超时,goroutine 会长期 park 在 conn.Write() 上,阻塞后续所有推送。
- pprof 查
runtime.gopark下大量状态为IO wait的 goroutine,基本就是这个原因 -
conn.SetWriteDeadline(time.Now().Add(2 * time.Second))必须每次Write()前重置,不能只设一次 - 错误处理只捕获
os.IsTimeout(err),其他错误(如net.ErrClosed)应直接清理连接,不重试
用 io.CopyBuffer 替代循环 Write() 降唤醒频次
批量写入的核心是把消息预拼成二进制流,再一次性推送到连接,避免 N 次独立 I/O 事件注册。这能显著压缩 runtime.netpoll 的调度压力,尤其在活跃连接数高时效果明显。
- 用
sync.Pool复用 buffer:buf := pool.Get().([]byte); defer pool.Put(buf) - 对活跃连接列表统一调用:
io.CopyBuffer(conn, bytes.NewReader(msg), buf) - 慎用过大的 buffer(如 >64KB):可能引发内存碎片或 GC 压力,建议 8KB–32KB 区间实测
连接生命周期必须由 context 驱动,不能靠手动 close
群聊场景用户频繁上下线,若广播 goroutine 仅靠 conn.Close() 清理,极易泄漏。一旦连接断开而 goroutine 仍挂在 PollDesc.wg 上,它就永远等不到唤醒,持续占用调度器资源。
- 每个连接应绑定独立
context.WithCancel(),广播前检查ctx.Err() != nil - 在
conn.ReadMessage()的读 goroutine 中监听ctx.Done(),主动触发 cancel - 广播逻辑里用
select { case 跳过已失效连接
真正难的不是写对这几行代码,而是所有连接必须同步应用 deadline、buffer、context 三者——漏掉任意一环,延迟就会在高负载下突然毛刺式反弹,且 pprof 很难一眼定位到是哪个环节松动了。











