“循环里用 time.After 会泄漏”是老 Go 的经典坑。Go 1.23 起文档说垃圾回收器能回收没有引用的定时器。这篇在 1.27.1 上实测它是否还泄漏,发现旧行为本机已经复现不出来,并量出仍然存在的分配成本。
100000 个 NewTicker(1h) 丢掉不 Stop:GC 之后堆 0.2 MB → 0.2 MB(增加 0.0 MB) 100000 个 time.Tick(1h) 丢掉:GC 之后堆 0.2 MB → 0.2 MB(增加 0.0 MB) 100000 个 NewTimer(1h) 丢掉不 Stop:GC 之后堆 0.2 MB → 0.2 MB(增加 0.0 MB) 循环 100000 次,每次 select 里放一个 time.After(1h):GC 之后堆 0.2 MB → 0.2 MB(增加 0.0 MB) 100000 个 NewTicker(1h) 放进切片保留(对照):GC 之后堆 0.2 MB → 26.0 MB(增加 25.8 MB)
在 go.mod 写 go 1.27 的程序里,丢掉不 Stop 的 10 万个 NewTicker、time.Tick、NewTimer,以及循环里 10 万次 select 放 time.After,GC 之后堆都没有增长;把 Ticker 保留在切片里就增长 25.8 MB。可见回收是靠“没有引用”,不是靠 Stop。
[go 1.27 + GODEBUG=asynctimerchan=1] fatal error: removed GODEBUG "asynctimerchan" set to old value "1" in environment (https://go.dev/doc/godebug#go-127) [go.mod 写 go 1.22] 100000 个 NewTicker(1h) 丢掉不 Stop:GC 之后堆 0.2 MB → 0.2 MB(增加 0.0 MB) [go.mod 写 go 1.22,select+time.After 循环] 循环 100000 次,每次 select 里放一个 time.After(1h):GC 之后堆 0.2 MB → 0.2 MB(增加 0.0 MB) godebug.md:171: Go 1.27 removed the `asynctimerchan` setting, as noted in the [Go 1.23](#go-123) section. godebug.md:358: This setting will be removed in Go 1.27.
老版本的泄漏需要 asynctimerchan=1 之类的旧行为。Go 1.27 把这个设置删了(设置它会直接 fatal error),go.mod 写 go 1.22 的同一程序也不泄漏。我机器上只有 1.27.1 工具链,所以“Go 1.23 之前确实泄漏”这一点只来自官方文档的描述,我没有实测。
每轮 select 里放 time.After(1h):3 次分配,145 ns/轮;复用一个 Timer 并 Reset:0 次分配,83 ns/轮(各 1000000 轮)
不泄漏不代表便宜:热循环里每轮 time.After 要 3 次分配、145 ns,复用一个 Timer 并 Reset 是 0 次分配、83 ns。每轮处理量很小的循环里,这个差别才值得处理;一般代码用 time.After 就够了,文档也这么说。
周期 10 ms 的 Ticker,接收方每次处理 55 ms:600 ms 里收到 11 个 tick(按周期应有 60 个)
接收方比周期慢时,Ticker 不会攒着补发:周期 10 ms、每次处理 55 ms,600 ms 只收到 11 个 tick。需要“一个都不能少”的场景不能靠 Ticker。
go doc time.NewTicker(节选):
NewTicker returns a new Ticker containing a channel that will send the
current time on the channel after each tick. The period of the ticks is
specified by the duration argument. The ticker will adjust the time interval
or drop ticks to make up for slow receivers. The duration d must be greater
than zero; if not, NewTicker will panic.
Before Go 1.23, the garbage collector did not recover tickers that had not
yet expired or been stopped, so code often immediately deferred t.Stop
after calling NewTicker, to make the ticker recoverable when it was no
longer needed. As of Go 1.23, the garbage collector can recover unreferenced
tickers, even if they haven't been stopped. The Stop method is no longer
necessary to help the garbage collector. (Code may of course still want to
call Stop to stop the ticker for other reasons.)
go doc time.After(节选):
After waits for the duration to elapse and then sends the current time on
the returned channel. It is equivalent to NewTimer(d).C.
Before Go 1.23, this documentation warned that the underlying Timer
would not be recovered by the garbage collector until the timer fired,
and that if efficiency was a concern, code should use NewTimer instead and
call Timer.Stop if the timer is no longer needed. As of Go 1.23, the garbage
collector can recover unreferenced, unstopped timers. There is no reason to
prefer NewTimer when After will do.文档的说法是 Stop 不再是为了让 GC 回收,但你仍可能因为别的原因想停掉它,比如不再需要它产生事件。这一点我没有再单独设计实验。