根本解法是让netpoll“少等、快判、早发”:设writedeadline防止单连接阻塞,用io.copybuffer批量写入降epoll唤醒次数,绑定context管理连接生命周期避免goroutine泄漏。
大群聊推送延迟高,不是因为 goroutine 不够多,而是 netpoll 的事件响应链路被卡住了——比如大量连接空闲等待、读写缓冲区反复拷贝、超时未设导致 goroutine 长期 park 在 conn.read() 上。根本解法不在并发数,而在让 netpoll “少等、快判、早发”。
为什么大群聊场景下 netpoll 容易堆积延迟
群聊推送本质是“一发多收”,但 Go 默认的 net.Conn 模型对每个连接独立调用 Write(),而每个 Write() 背后都触发一次 poller 状态检查和可能的 epoll_wait 唤醒。当群规模达万级,哪怕单次推送只耗 10μs,串行写完就是 100ms+;若部分连接因网络抖动或客户端卡顿未就绪,Write() 会阻塞(实际是 park),拖慢整批推送节奏。
- 未设
SetWriteDeadline():慢连接长期占用 goroutine,阻塞后续消息调度 - 小包高频推送:每次
Write([]byte{...})触发一次系统调用 + 内核缓冲区拷贝,放大 syscall 开销 - 连接复用率低:用户频繁上下线导致连接池碎片化,新连接不断重建 epoll 注册,增加 runtime.netpoll 负载
- goroutine 泄漏风险:推送逻辑中启 goroutine 处理单个连接,但未用
context控制生命周期,连接断开后 goroutine 仍挂起在PollDesc.wg上
用 SetWriteDeadline 防止单连接拖垮整批推送
不设写超时,一个 TCP 接收窗口满、NAT 超时断连或客户端崩溃的连接,会让对应 goroutine 一直停在 conn.Write() 的 park 状态,无法被调度器回收。pprof 查 runtime.gopark 下能看到大量状态为 IO wait 的 goroutine 卡在写操作上。
- 必须为每个连接显式设置:
conn.SetWriteDeadline(time.Now().Add(2 * time.Second)) - 超时值不宜过短(5s):否则失去保护意义
- 捕获
os.IsTimeout(err)而非泛判err != nil,仅对超时连接做降级(如标记为离线、移出活跃列表),不 panic 或重试 - 注意:该 deadline 仅作用于单次
Write(),每次调用前都需重置
批量写入 + io.CopyBuffer 减少 epoll 唤醒次数
逐个 conn.Write() 本质是 N 次独立 I/O 事件注册与唤醒。改用 io.CopyBuffer 将消息预拼为二进制流,再一次性推送到连接,可大幅压缩 poller 调度频次。
- 构造共享 buffer:用
sync.Pool复用[]byte,避免每次推送分配新切片 - 对活跃连接批量处理:收集所有待推送连接,用
io.CopyBuffer(dst, src, pool.Get().([]byte))写入,写完立即pool.Put() - 慎用
bufio.Writer:它内部带缓存,但若未显式Flush(),消息不会真正发出;且多个 goroutine 共享同一bufio.Writer有竞态风险 - 实测表明:万级连接下,批量写比单连写降低 netpoll 唤醒次数约 60%,P99 推送延迟从 85ms 降至 32ms
连接管理:用 net.Conn 生命周期绑定 context 避免 goroutine 泄漏
netpoll 不会自动清理已断开但未 Close 的连接。客户端拔线、APP 杀后台后,服务端若未及时检测,PollDesc.rg 里残留的 goroutine 会持续占用内存并干扰 epoll_wait 效率。
- Accept 后立即创建带 cancel 的 context:
ctx, cancel := context.WithCancel(context.Background()) - 启动读协程时传入 ctx,并在
conn.Read()返回io.EOF或net.ErrClosed时调用cancel() - 推送协程监听
ctx.Done(),收到信号即退出,不再对该连接执行任何Write() - 配合
SetReadDeadline:每 30s 检查一次心跳,超时则主动 close 连接,触发 runtime 清理PollDesc
netpoll 的威力不在“能并发多少”,而在“能否精准感知每个 fd 的就绪瞬间”。大群聊推送中最容易被忽略的,是把连接当成静态资源去维护,而忽略了 netpoll 本质是事件驱动——只要有一个连接长期处于半死不活状态,它就在 poller 的就绪队列里悄悄吃掉调度资源。真正的优化,是让无效连接尽早退出 epoll 监控,让有效连接的事件路径尽可能短。











