最稳妥的心跳实现是用 time.Ticker 配合 select 和 done 通道,处理失败时应主动退出而非等待;需防 goroutine 泄漏、channel 卡死及盲目重试。

用 time.Ticker 启动 goroutine 心跳最稳妥
直接用 time.Sleep 在循环里做延时心跳,容易因处理耗时导致间隔漂移;time.Ticker 能维持稳定周期,且自带 channel 驱动,天然适配 goroutine。启动后它会按固定间隔往 t.C 发送时间戳,你只管收——哪怕处理慢了,下一次发送也不会堆积(Ticker 会丢弃未被接收的 tick)。
常见错误是把 ticker.Stop() 忘在 defer 里,导致 goroutine 泄漏;或者在 select 中没加 default 或超时,让心跳 goroutine 卡死在 channel 操作上。
示例结构:
func startHeartbeat(done chan struct{}) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
<pre class="brush:php;toolbar:false;">for {
select {
case <p>}</p>心跳失败要主动退出 goroutine,不能靠 done 通道硬等
网络心跳不是纯计时任务,一旦 sendHeartbeat() 连续失败(比如 HTTP 503、连接 refused、context deadline exceeded),继续发已无意义,还占着 goroutine 和连接资源。这时应立刻返回,让上层决定是否重连或告警。
建议做法:
- 给每次心跳操作加
context.WithTimeout,避免卡死 - 失败计数达到阈值(如 3 次)就 break 出循环,而不是等
done - 不要在心跳 goroutine 里重试——重试逻辑应由外层控制,否则可能掩盖真实故障
错误写法:for { select { case —— 完全忽略错误反馈路径。
goroutine 退出时必须清理资源,尤其是 http.Client 连接
很多心跳用 http.Client 发请求,但默认 client 复用底层 TCP 连接;如果 goroutine 退出前没关闭 idle 连接,这些连接会滞留几分钟(受 IdleConnTimeout 控制),造成 fd 泄露或服务端连接数暴涨。
正确姿势:
- 自定义
http.Client,设短IdleConnTimeout(如 5s)和MaxIdleConnsPerHost - 心跳结束时调用
client.CloseIdleConnections()(Go 1.12+) - 如果用了自定义 transport,也要手动
transport.CloseIdleConnections()
不清理的典型现象:lsof -p $PID | grep :443 显示数百个 ESTABLISHED 状态连接,但业务早已停止。
别在心跳 goroutine 里做重连,用单独 goroutine 管理连接生命周期
心跳和连接管理是两个关注点:心跳只负责“我活着”,连接管理负责“怎么活”。把重连逻辑塞进心跳 goroutine,会导致代码耦合、状态混乱,比如重连中又收到心跳触发,可能并发写同一 socket。
推荐分层:
- 一个 goroutine 负责连接建立/重连(监听
reconnectCh或定时试探) - 另一个 goroutine 仅在连接可用时发心跳(从
connReadyCh接信号) - 用
sync.Once或原子变量保护连接句柄读写
复杂点在于连接状态同步——最容易被忽略的是:心跳 goroutine 看到连接断开后,不能立即停,得等重连 goroutine 确认新连接 ready,否则出现心跳空窗期。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











