应使用 time.ticker 做心跳检测,因其自动维持稳定周期、无需手动 reset、底层复用定时器更轻量;但需注意其信号可能堆积,应解耦发包与计时逻辑。

什么时候该用 time.Ticker 做心跳检测
长连接心跳必须周期性发包,且间隔稳定(比如每 30 秒一次),time.Ticker 是最直接的选择。它自动维持节奏,不用你手动 Reset(),也不用担心漏调用导致单次触发后就停摆。
- 心跳逻辑天然「重复」:只要连接活着,就得一直发;
time.Ticker的设计就是为这种场景服务的 - 底层复用单个系统定时器,比反复
Reset()time.Timer更轻量,避免 runtime 定时器堆频繁调整带来的抖动 - 注意:
time.Ticker不保证绝对准时——如果某次心跳发送耗时 2 秒,而间隔设的是 3 秒,下一次信号仍会在第 3 秒准时入队;若你没及时读走,会堆积在ticker.C缓冲区(默认容量 1),下次select可能「连发两次」
为什么别用 time.Timer 模拟心跳
有人图省事,用 time.Timer + Reset() 循环实现“伪 ticker”,这在心跳场景里是危险操作。
-
time.Timer.Reset()不是线程安全的:如果在接收前或接收中调用 <code>Reset(),行为未定义,go 1.22+ 已明确文档标注“调用前必须确保 timer 已停止或已触发” - 容易漏掉
Reset():比如心跳失败重试逻辑分支里忘了重置,连接就静默断开了 - goroutine 泄漏风险高:每次
Reset()都会重新注册调度,若 timer 被频繁创建又丢弃(比如在 for 循环里 new 但不 stop),底层 P 中的定时器堆可能持续增长 - 性能上不划算:高频
Reset()触发最小四叉堆的 up/down 调整,而time.Ticker在 Go 1.14+ 后全程共享一个堆节点
心跳任务执行时间 > 间隔时怎么办
这是真实线上高频问题:网络卡顿、序列化慢、日志刷盘阻塞,都可能导致一次心跳耗时远超设定间隔(比如 30 秒心跳花了 45 秒)。此时 time.Ticker 的默认行为会积压信号,造成后续瞬间并发发包,甚至触发服务端限流。
- 不要依赖
time.Ticker的“自动续期”来控制节奏——它只管发信号,不管任务是否结束 - 正确做法是把「发心跳」和「计时」解耦:用
time.AfterFunc或time.Sleep在任务结束后再启动下一轮,确保严格串行 - 示例节选:
for { doHeartbeat() select { case <-time.After(30 * time.Second): case <-ctx.Done(): return } }
ticker.Stop() 忘了调用会发生什么
不是“偶尔多发一次心跳”,而是 goroutine 持续泄漏:每个 time.NewTicker 都会启动一个后台 goroutine 负责向 C 发送时间,它不会随 ticker 变量被 GC 自动回收。
立即学习“go语言免费学习笔记(深入)”;
- 现象:pprof 查看
runtime/pprof/goroutine,能看到大量time.startTimer相关 goroutine 持续存在 - 修复方式很简单:必须显式调用
ticker.Stop(),且建议搭配defer(如果 ticker 生命周期明确)或在连接关闭路径中统一清理 - 特别注意:如果 ticker 被封装进结构体、作为字段长期持有,Stop 时机要跟连接生命周期对齐,不能只在函数退出时 defer
心跳检测看着简单,但一旦混入超时控制、重连逻辑、上下文取消,time.Ticker 的缓冲积压和 Stop() 漏调就会立刻暴露出来。别把它当黑盒,每次 new 之后,都要想清楚谁负责 stop、信号来了要不要丢弃、任务卡住时整个节奏怎么兜住。