登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go Ticker 为什么停止后仍然被旧引用持有

来源:17golang原创

时间:2026-10-26 00:19:39 349浏览 收藏

我第一次遇到这个问题时,表面现象很像 time.Ticker.Stop() 没有生效:停止方法已经调用了,服务对象里却还能看到旧的 ticker 指针,接收 goroutine 也没有自己退出。真正的原因通常不是 ticker “拒绝释放”,而是把四件事混在了一起:停止发送、关闭通道、解除调用方引用,以及让垃圾回收器看到对象已经不可达。

要点速览
  • Stop 只停止后续 tick,不关闭 ticker.C,也不会把调用方保存的指针自动改成 nil。
  • 只要结构体字段、闭包或接收 goroutine 仍然持有旧 ticker 或旧 channel,旧引用就仍然存在;Go 1.23 的 GC 也不会替你消除业务引用。
  • 需要停止的是业务循环时,要让接收方同时监听 ticker.C 和 context.Done(),再由拥有者负责 Stop、退出和清空字段。

Stop 停止了 tick,为什么旧引用还在

官方定义很明确:Ticker.Stop 会关闭 ticker 的计时动作,之后不再发送 tick,但它不会关闭 C 通道。这个设计是为了避免并发接收方因为通道被关闭而误判出一个“结束事件”,也保留了后续调用 Reset 的可能。

因此下面三句话的含义完全不同:

动作它改变什么它不会替你做什么
ticker.Stop()停止后续 tick不关闭 C,不清空调用方变量
close(ticker.C)尝试关闭通道不应该由调用方执行,C 是只读通道且 ticker 仍可能被其他代码使用
ticker = nil移除当前变量的一条引用不影响其他字段、闭包或 goroutine 保存的引用
GC 回收回收已经不可达的对象不负责终止仍在运行的业务循环

所以,调用 Stop 后看到旧指针并不矛盾。停止是 ticker 的运行语义,引用是否还在则是调用方的所有权问题。

Go Ticker 的调用方引用、ticker.C、Stop、不关闭通道与 GC 可回收边界说明图
图1:边界说明图,展示 Stop 停止发送与旧引用仍然可达之间的关系,不是运行截图。

最容易漏掉的是被阻塞的接收 goroutine

旧引用经常不是单独放在一个变量里,而是藏在接收循环的闭包中。下面的写法可以收到 tick,但停止时没有第二个退出信号:

func startPolling() *time.Ticker {
    ticker := time.NewTicker(time.Second)
    go func() {
        for {
            // 这里只监听 ticker.C;Stop 不会关闭这个通道。
            

即使其他地方调用了 ticker.Stop(),这个 goroutine 仍可能停在下一次接收上。它保存着 ticker 的闭包引用,而循环本身又没有返回条件,于是你会同时观察到两个现象:旧引用还在,goroutine 也没有结束。

这不是给 Stop 再加一层包装就能解决的。停止计时和退出业务循环必须有各自的信号。

让 ticker.C 和退出信号进入同一个 select

我更愿意把 ticker 的所有权放在一个短生命周期的服务对象里,再用 context.Context 表达“这段工作应该结束”。收到退出信号后,接收 goroutine 自己返回,拥有者再清理字段:

type Poller struct {
    ticker *time.Ticker
}

func (p *Poller) Run(ctx context.Context) {
    p.ticker = time.NewTicker(time.Second)
    defer func() {
        // Run 返回前停止计时;Stop 不负责关闭 ticker.C。
        p.ticker.Stop()
        // 只有确认没有其他并发读写者后,才能清掉字段引用。
        p.ticker = nil
    }()

    for {
        select {
        case 

这里最关键的不是 defer,而是 ctx.Done() 和 ticker.C 同时出现在 select 中。Stop 负责停止后续 tick,context 负责让业务循环返回,两者职责分开,旧引用才有机会在返回后被统一清理。

Go Ticker 接收 goroutine 同时监听 ticker.C 与 context.Done 并在 Stop 后返回清理的结构说明图
图2:结构说明图,展示可退出接收循环的信号边界和清理责任,不是并发运行证据。

Go 1.23 之后,GC 变化了但引用语义没有变化

Go 官方文档说明,Go 1.23 之后,垃圾回收器可以回收“已经没有引用”的 ticker,即使它还没有显式调用 Stop。这解决的是 ticker 在无人使用时的回收问题,不是把仍被业务对象、闭包或 goroutine 持有的引用强行断开。

因此不要把“Go 1.23 可以回收未停止 ticker”理解成“Stop 后旧指针应该自动消失”。更准确的判断方式是:

  • 变量仍然指向 *time.Ticker:这是调用方仍持有引用,GC 不会因为 Stop 而修改它。
  • 接收循环仍然持有 ticker.C:通道不会因 Stop 自动关闭,接收方需要额外退出信号。
  • 所有业务引用都已退出且变量不可达:此时才属于 GC 可以自行处理的范围。

如果代码只需要一个不能主动停止的简单 tick 通道,time.Tick 在 Go 1.23 以后不再因为“无法 Stop”而天然阻止 GC;但只要业务有明确的关闭、取消、重启或资源回收阶段,我仍建议使用 NewTicker,因为它让生命周期拥有者清楚地表达停止动作。

清理旧引用时要先处理并发访问

把字段直接改成 nil 不是万能修复。如果另一个 goroutine 可能同时读取这个字段,清空动作本身又会制造数据竞争,甚至出现空指针。清理顺序应该围绕所有权来定:

  1. 先发送取消信号或调用上层 cancel(),让接收循环有机会返回。
  2. 等待接收 goroutine 完成,确认它不再读取 ticker。
  3. 由唯一拥有者调用 Stop,并在同步边界内清空字段。
  4. 下次启动时创建新的 ticker,不要复用已经交给旧循环的 channel。

如果服务会重复启动和停止,可以把取消函数、完成通知和 ticker 放进同一个生命周期对象中;不要让外部调用方同时持有 ticker 指针和停止权限。这样排查“旧引用还在”时,问题会从“GC 为什么没清理”变成更容易回答的“谁还拥有这个对象”。

用这张清单判断问题到底在哪一层

观察到的现象优先检查正确处理
Stop 后指针值仍可打印调用方是否还保存字段或局部变量区分停止动作与引用清理,必要时在安全边界置 nil
goroutine 数量不下降接收循环是否只监听 ticker.C加入 context.Done() 或独立 done channel,并等待退出
尝试关闭 ticker.C是否把只读接收通道当成业务关闭通道不要关闭 ticker.C,用退出信号结束消费者
Go 1.23 后仍感觉对象没回收是否还有结构体、闭包或 goroutine 引用先消除业务引用,再把 GC 回收当作运行时实现细节

常见问题

Stop 会不会让 ticker.C 立即返回零值或关闭状态?

不会。Stop 不关闭 C,接收方不能靠通道关闭来判断 ticker 停止,应使用 context 或独立 done channel。

把 ticker 指针设为 nil 就能让 goroutine 退出吗?

不能。置 nil 只影响当前引用,不能唤醒已经阻塞在 上的 goroutine;必须提供退出信号并等待循环返回。

Go 1.23 之后还需要调用 Stop 吗?

如果只是为了帮助 GC,未被引用的 ticker 已可被回收;但如果业务需要立刻停止发送、结束循环或表达资源关闭语义,仍然应该调用 Stop。

可以在多个 goroutine 中同时调用 Stop 吗?

不要把 Stop 权限随意分散。更稳妥的做法是让一个生命周期拥有者负责取消、等待接收循环结束和 Stop,避免清理逻辑互相竞争。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>