• Go 1.23 Timer Channel 的改变
  • 发布于 2个月前
  • 144 热度
    0 评论
Go 1.23 引入了一个新的实现方案,用于由 time.NewTimer、time.After、time.NewTicker 和 time.Tick 创建的基于通道的定时器。

这个新实现带来了两项重要改变:

未停止但不再被引用的定时器和周期计时器现在可以被垃圾回收。在 Go 1.23 之前,未停止的定时器在触发之前无法被垃圾回收,而未停止的周期计时器则永远无法被垃圾回收。Go 1.23 的实现避免了那些没有使用 t.Stop 的程序中的资源泄漏。


定时器通道现在是同步的(无缓冲),这为 t.Reset 和 t.Stop 方法提供了更强的保证:在这些方法返回后,定时器通道的接收操作不会观察到与旧定时器配置相对应的过期时间值。在 Go 1.23 之前,使用 t.Reset 时无法避免过期值,而使用 t.Stop 避免过期值则需要谨慎处理其返回值。Go 1.23 的实现完全消除了这个问题。


这些实现变更带来了两个可观察的副作用,可能会影响生产环境行为或测试,将在后续章节中详细说明。新的实现仅在以下情况下使用:当程序的 package main 位于某个模块中,且该模块的 go.mod 文件声明了 go 1.23 或更高版本。其他程序将继续使用旧的语义。通过设置 GODEBUG 的 asynctimerchan=1 可以强制使用旧语义;相反,asynctimerchan=0 则强制使用新语义。

Cap 和 Len
在 Go 1.23 之前,定时器通道的 cap 为 1,其 len 值表示是否有值在等待接收(有则为 1,无则为 0)。Go 1.23 实现中创建的定时器通道的 cap 和 len 始终为 0。一般来说,使用 len 来轮询任何通道通常都不是好办法,因为其他 goroutine 可能会同时从通道接收数据,这会使 len 的结果随时失效。应该使用非阻塞的 select 来替代使用 len 轮询定时器通道的代码。也就是说,原来的代码:
if len(t.C) == 1 {
    <-t.C
    // 更多代码
}
应该改为:
select {
default:
case <-t.C:
    // 更多代码
}
Select 竞态
在 Go 1.23 之前,使用很短间隔(如 0ns 或 1ns)创建的定时器,由于调度延迟,会比指定的间隔用更长的时间才能使其通道准备好接收。这种延迟可以在以下代码中观察到,该代码在 select 之前就已经准备好的通道和新创建的具有很短超时时间的定时器之间进行选择:
c := make(chan bool)
close(c)

select {
case <-c:
    println("done")
case <-time.After(1*time.Nanosecond):
    println("timeout")
}
当 select 参数被求值并且 select 检查相关通道时,定时器应该已经过期了,这意味着两个 case 都已准备就绪。Select 通过随机选择来处理多个就绪的 case,所以这个程序理论上应该大约各执行一半的情况。由于 Go 1.23 之前的定时器实现中的调度延迟,像这样的程序错误地 100% 执行了 "done" case。

Go 1.23 的定时器实现不受相同调度延迟的影响,因此在 Go 1.23 中,该程序会大约各执行一半的情况。在 Google 代码库中测试 Go 1.23 时,我们发现一些测试用例在使用 select 来对准备就绪的通道(通常是 context Done 通道)与具有很低超时值的定时器进行竞争。通常,生产代码会使用真实的超时时间,这种情况下竞争并不重要,但在测试时超时会被设置得很小。然后测试会要求执行非超时的情况,如果达到超时就会失败。一个简化的示例可能是这样的:
select {
case <-ctx.Done():
    return nil
case <-time.After(timeout):
    return errors.New("timeout")
}
然后测试会将 timeout 设置为 1ns,如果代码返回错误就会失败。要修复这样的测试,要么修改调用者使其理解可能发生超时,要么修改代码使其在超时情况下也优先考虑 done 通道,像这样:
select {
case <-ctx.Done():
    return nil
case <-time.After(timeout):
    // 在测试过程中出现短超时时
    // 再次检查 Done 是否已就绪
    select {
    default:
    case <-ctx.Done():
        return nil
    }
    return errors.New("timeout")
}
调试
如果程序或测试在使用 Go 1.23 时失败,但在使用 Go 1.22 时正常工作,可以使用 asynctimerchan GODEBUG 设置来检查是否是新的定时器实现触发了失败:
GODEBUG=asynctimerchan=0 mytest  # 强制使用 Go 1.23 定时器
GODEBUG=asynctimerchan=1 mytest  # 强制使用 Go 1.22 定时器
如果程序或测试在使用 Go 1.22 时始终通过,但在使用 Go 1.23 时始终失败,这很可能表明问题与定时器有关。在我们观察到的所有测试失败中,问题都出在测试本身,而不是定时器实现。因此,下一步是要准确识别 mytest 中哪些代码依赖于旧实现。为此,你可以使用 bisect 工具:
# 堆代码 duidaima.com
go install golang.org/x/tools/cmd/bisect@latest
bisect -godebug asynctimerchan=1 mytest
以这种方式调用时,bisect 会反复运行 mytest,根据导致定时器调用的堆栈跟踪来切换新旧定时器实现。通过二分查找,它可以将失败缩小到在特定堆栈跟踪期间启用新定时器,并报告这些跟踪。在 bisect 运行时,它会打印试验的状态信息,主要是为了在测试很慢时让你知道它仍在运行。
用户评论