go原生netpoll足够支撑长连接,但需在net.conn上添加轻量约束与状态管理:解决goroutine泄漏、空闲连接堆积、批量控制难等问题,关键在于正确使用setreaddeadline、sync.map+context管理连接、读写解耦编解码。
go 原生 netpoll 已足够支撑大多数长连接场景,但直接裸用 net.listener + conn.read 循环极易掉进资源失控、连接泄漏、超时失效难感知的坑里。你需要的不是重写 netpoll,而是在它之上加一层轻量但关键的约束和状态管理。
为什么不能直接用 net.Conn 做长连接服务
原生 net.Conn 是无状态的字节流抽象,不提供连接生命周期钩子、不自动处理粘包/半包、不暴露底层 fd 就绪状态,更不会帮你回收空闲连接。
- goroutine 泄漏:一个
conn对应一个常驻 goroutine,Read阻塞时无法被外部中断,连接断开后该 goroutine 可能永远卡在系统调用里 - 空闲连接堆积:没有心跳或读超时机制时,客户端静默断连,服务端 conn 会持续占用内存和文件描述符,直到 OS 发送 FIN 或 keepalive 触发(通常要 2 小时)
- 无法批量控制:你想优雅关闭所有连接?只能靠全局 map + channel 广播,但并发读写 map 容易 panic,且无法保证每个 conn 真的响应了关闭信号
SetReadDeadline 是长连接的心跳基础,但必须配对使用
很多人只设 SetReadDeadline 却忽略它是一次性生效的——每次 Read 前都得重新设置,否则第一次超时后后续读永远失败。
- 正确模式是:每次成功读到数据后,立刻调用
conn.SetReadDeadline(time.Now().Add(keepAliveTimeout)) - 不要在
for循环外只设一次;也不要依赖SetKeepAlive(那是 TCP 层保活,OS 级别,不可控且延迟高) - 若业务协议有明确心跳帧(如
PING/PONG),应在收到心跳后重置 deadline;若纯靠超时,建议设为 30–60 秒,避免过早断连又不过于宽松
用 sync.Map + context.WithCancel 管理连接集合
不用自己实现连接池,但必须让每个连接可被统一取消、可被快速遍历、可安全并发访问。
- 用
sync.Map[string]*ConnWrapper存连接,key 推荐用 client IP + port 或自定义 token,避免用conn.RemoteAddr().String()(IPv6 地址格式不稳定) - 每个
ConnWrapper内嵌context.Context和cancel(),当调用全局Shutdown()时,遍历sync.Map并触发所有 cancel,再 wait 所有 goroutine 退出 - 禁止在
conn.Read循环里直接调用conn.Close()—— 应先 cancel context,再由 Read 返回io.EOF或net.ErrClosed后自然退出,最后清理 map 条目
编解码层必须与连接生命周期解耦
长连接框架最常犯的错,就是把协议解析逻辑(如 JSON 解包、Protobuf 反序列化)硬塞进读 goroutine,导致单个慢请求拖垮整个连接的读循环。
- 读 goroutine 只做两件事:收原始字节、按协议边界切帧(如按 \n 分割、按前 4 字节长度字段)、把完整帧发给一个无缓冲 channel
- 另起 goroutine 从 channel 拿帧、解码、执行业务逻辑;这样即使业务阻塞,读循环仍能继续接收新帧
- 注意 channel 容量:设为 1024 或更低,避免背压累积过多帧导致 OOM;满时应丢弃或返回错误给客户端,而不是阻塞读
真正难的不是 epoll 或 Reactor 模型,而是让每个连接在超时、断连、重启、协议错误等边界下,都能干净退出、不泄漏、不卡死。原生 netpoll 提供的是能力,不是契约;你加的那几十行连接管理代码,才是长连接服务可用性的分水岭。











