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

Go 测试中 ticker 未停止为何让 goroutine 泄漏

来源:17golang原创

时间:2026-10-14 22:43:22 210浏览 收藏

Go 测试里看到 goroutine 数量持续上涨时,问题经常被归咎于“忘了调用 ticker.Stop()”。更准确的判断是:Stop() 只停止后续 tick,不会关闭 ticker.C;如果测试启动的 worker 一直阻塞在这个通道上,它仍然没有退出路径,泄漏的其实是 goroutine 生命周期。

要点速览
  • time.NewTicker、ticker.C 消费者和测试 cleanup 是三个不同的生命周期。
  • 不要用 for range ticker.C 等待测试结束,要在 select 中增加 done 或 context.Done()。
  • 关闭时同时执行 Stop、发送退出信号并等待 worker 返回,单独 Stop 不能证明没有泄漏。

先把 ticker、通道和 goroutine 的生命周期分开

Ticker 负责按周期向 C 发送时间值,worker goroutine 负责消费这些值,测试则负责在断言完成后通知 worker 收尾。三者混在一个函数里时,最容易把“停止发消息”误认为“结束消费方”。

对象停止方式不能替代什么
*time.TickerStop()不会关闭 C
ticker.C由消费者选择退出分支不会自动发“结束”值
worker goroutinedone、context 或其他取消信号不能靠 ticker 停止自行返回
测试cleanup 后等待 worker不能只看断言已经通过

下面这种写法在测试结束时只调用了 Stop。tick 不再产生,但消费循环没有结束条件:

func startRefresh() *time.Ticker {
    ticker := time.NewTicker(20 * time.Millisecond)
    go func() {
        // 这里的循环只等待 tick,没有测试退出分支。
        for range ticker.C {
            refresh()
        }
    }()
    return ticker
}

func TestRefresh(t *testing.T) {
    ticker := startRefresh()
    // Stop 只停止后续 tick,不能让上面的 for range 结束。
    t.Cleanup(ticker.Stop)
}
Go time.Ticker、ticker.C、worker goroutine 与测试 cleanup 的生命周期边界结构说明图
图1:Ticker 生命周期结构说明图,区分停止 tick 与结束消费 goroutine。

测试退出时要同时完成三件事

可收尾的实现要给 worker 一个明确的退出信号,再用等待点确认它真的返回。WaitGroup 不是为了修复 ticker,而是把“测试已经发出关闭请求”和“worker 已经离开”这两个时刻分开。

type poller struct {
    ticker *time.Ticker
    done   chan struct{}
    wg     sync.WaitGroup
}

func newPoller() *poller {
    p := &poller{
        ticker: time.NewTicker(20 * time.Millisecond),
        done:   make(chan struct{}),
    }
    p.wg.Add(1)
    go func() {
        defer p.wg.Done()
        for {
            select {
            case 

真实代码中要保证 Close 只调用一次;如果对象可能被多个清理路径触发,可以再用 sync.Once 包住关闭逻辑。若 refresh() 本身会阻塞,也要让它接收 context,否则 worker 虽然离开了 select,仍可能卡在业务调用里。

Go 测试 worker 通过 select、done、Ticker.Stop 和 WaitGroup 收尾的结构说明图
图2:测试 worker 收尾契约说明图,展示 done、Stop 与 Wait 的协作边界。

Stop 不能等同于关闭通道

官方 time.Ticker 文档明确说明,Stop 后不会再发送 tick,但不会关闭通道。这一设计避免并发读取者误把“通道关闭”当成错误 tick,也允许后续复用 ticker。于是,for range ticker.C 在这里没有自然终点。

另外,Go 1.23 起垃圾回收器可以回收已经不可达、尚未停止的 ticker。这个变化减少了某些 timer 对象的长期保留,但不能替代 worker 的退出协议:只要 goroutine 仍持有 ticker.C 并等待输入,它就不是一个已经结束的 goroutine。测试清理依然应该显式停止定时工作并等待消费者。

用现象对应证据的清单排查泄漏

现象优先检查修复方向
Stop 后测试进程仍有后台 workerworker 是否只监听 ticker.C增加 done 或 context.Done()
重复运行测试后 goroutine 数上涨cleanup 是否等待 worker用 WaitGroup.Wait() 建立收尾屏障
关闭偶发卡住业务处理是否阻塞、Close 是否重复调用让处理可取消,并用 sync.Once 保证幂等

排查时不要只把 ticker.Stop() 加到测试末尾就结束。先画出谁创建 ticker、谁持有 channel、谁负责通知退出,再确认测试结束前 worker 已经返回;这比单看 goroutine 数量更容易找到根因。

相关问题

调用 ticker.Stop 后为什么不能从 ticker.C 读到结束信号?

因为 Stop 不关闭通道,也不会发送额外的结束值。需要结束消费者时,应使用独立的 done 通道或 context。

测试里写 defer ticker.Stop 还需要 done 吗?

如果没有其他 goroutine 消费 ticker.C,Stop 通常足够停止定时工作;一旦启动了长期读取的 worker,就必须提供它能选择到的退出分支。

WaitGroup 能单独解决 ticker 泄漏吗?

不能。WaitGroup 只能等待,不能发出取消信号。正确组合是先让 worker 能收到 done 或 context,再用 WaitGroup 确认它已经退出。

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