Go Wiki: Go 1.23 定时器通道变更

Go 1.23 包含了一个由 time.NewTimertime.Aftertime.NewTickertime.Tick 创建的基于通道的定时器的全新实现。

新实现进行了两项重要变更:

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

  2. 定时器通道现在是同步的(无缓冲的),这使得 t.Resett.Stop 方法具有更强的保证:在这些方法返回后,对定时器通道的任何后续接收都不会观察到对应旧定时器配置的陈旧时间值。在 Go 1.23 之前,无法避免使用 t.Reset 时的陈旧值,并且避免使用 t.Stop 时的陈旧值需要谨慎使用 t.Stop 的返回值。Go 1.23 的实现完全消除了这一顾虑。

实现变更有两个可观察到的副作用,可能会影响生产行为或测试,详见下文。

新实现仅用于 go.mod 中声明 go 1.23 或更高版本的模块中的 main 包。其他程序将继续使用旧的语义。GODEBUG 设置 asynctimerchan=1 会强制使用旧语义;反之,asynctimerchan=0 会强制使用新语义。

容量(Cap)和长度(Len)

在 Go 1.23 之前,定时器通道的 cap 是 1,而定时器通道的 len 表示是否有值在等待接收(有则为 1,无则为 0)。Go 1.23 的实现创建的定时器通道的 caplen 始终为 0。

通常情况下,使用 len 来轮询任何通道通常没有太大帮助,因为另一个 goroutine 可能会同时从通道接收,从而随时使 len 的结果失效。使用 len 轮询定时器通道的代码应该改为使用非阻塞的 select。

也就是说,执行以下代码的程序

if len(t.C) == 1 {
    <-t.C
    more code
}

应该改为执行以下代码

select {
default:
case <-t.C:
    more code
}

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 查看相关通道时,定时器应该已经过期,这意味着两种情况都可以继续执行。Select 通过随机选择一个来选择多个就绪情况,所以这个程序应该大约一半时间选择一种情况。

由于 Go 1.23 之前的定时器实现中的调度延迟,像这样的程序会错误地 100% 执行“done”情况。

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):
    // Double-check that Done is not ready,
    // in case of short timeout during test.
    select {
    default:
    case <-ctx.Done():
        return nil
    }
    return errors.New("timeout")
}

调试

如果程序或测试在使用 Go 1.23 时失败,但在使用 Go 1.22 时工作正常,则可以使用 asynctimerchan GODEBUG 设置来检查新定时器实现是否触发了失败。

GODEBUG=asynctimerchan=0 mytest  # force Go 1.23 timers
GODEBUG=asynctimerchan=1 mytest  # force Go 1.22 timers

如果程序或测试在使用 Go 1.22 时始终通过,但在使用 Go 1.23 时始终失败,则强烈表明问题与定时器有关。

在我们观察到的所有测试失败中,问题都出在测试本身,而不是定时器实现,因此下一步是确定 mytest 中具体哪个代码依赖于旧的实现。要做到这一点,您可以使用 bisect 工具

go install golang.org/x/tools/cmd/bisect@latest
bisect -godebug asynctimerchan=1 mytest

这样调用 bisect 时,它会反复运行 mytest,根据导致定时器调用的堆栈跟踪来开启或关闭新的定时器实现。使用二分查找,它将诱发的失败缩小到在特定堆栈跟踪中启用新定时器,然后报告这些堆栈跟踪。当 bisect运行时,它会打印有关其试运行的状态消息,主要是因为当测试很慢时,您知道它仍在运行。

一个 bisect 运行的例子看起来像这样:

$ bisect -godebug asynctimerchan=1 ./view.test
bisect: checking target with all changes disabled
bisect: run: GODEBUG=asynctimerchan=1#n ./view.test... FAIL (7 matches)
bisect: run: GODEBUG=asynctimerchan=1#n ./view.test... FAIL (7 matches)
bisect: checking target with all changes enabled
bisect: run: GODEBUG=asynctimerchan=1#y ./view.test... ok (7 matches)
bisect: run: GODEBUG=asynctimerchan=1#y ./view.test... ok (7 matches)
bisect: target fails with no changes, succeeds with all changes
bisect: searching for minimal set of disabled changes causing failure
bisect: run: GODEBUG=asynctimerchan=1#!+0 ./view.test... FAIL (3 matches)
bisect: run: GODEBUG=asynctimerchan=1#!+0 ./view.test... FAIL (3 matches)
bisect: run: GODEBUG=asynctimerchan=1#!+00 ./view.test... ok (1 matches)
bisect: run: GODEBUG=asynctimerchan=1#!+00 ./view.test... ok (1 matches)
bisect: run: GODEBUG=asynctimerchan=1#!+10 ./view.test... FAIL (2 matches)
bisect: run: GODEBUG=asynctimerchan=1#!+10 ./view.test... FAIL (2 matches)
bisect: run: GODEBUG=asynctimerchan=1#!+0010 ./view.test... ok (1 matches)
bisect: run: GODEBUG=asynctimerchan=1#!+0010 ./view.test... ok (1 matches)
bisect: run: GODEBUG=asynctimerchan=1#!+1010 ./view.test... FAIL (1 matches)
bisect: run: GODEBUG=asynctimerchan=1#!+1010 ./view.test... FAIL (1 matches)
bisect: confirming failing change set
bisect: run: GODEBUG=asynctimerchan=1#v!+x65a ./view.test... FAIL (1 matches)
bisect: run: GODEBUG=asynctimerchan=1#v!+x65a ./view.test... FAIL (1 matches)
bisect: FOUND failing change set
--- change set #1 (disabling changes causes failure)
internal/godebug.(*Setting).Value()
    go/src/internal/godebug/godebug.go:165
time.syncTimer()
    go/src/time/sleep.go:25
time.NewTimer()
    go/src/time/sleep.go:144
time.After()
    go/src/time/sleep.go:202
region_dash/regionlist.(*Cache).Top()
    region_dash/regionlist/regionlist.go:89
region_dash/view.(*Page).ServeHTTP()
    region_dash/view/view.go:45
region_dash/view.TestServeHTTPStatus.(*Router).Handler.func2()
    httprouter/httprouter/params_go17.go:27
httprouter/httprouter.(*Router).ServeHTTP()
    httprouter/httprouter/router.go:339
region_dash/view.TestServeHTTPStatus.func1()
    region_dash/view/view.test.go:105
testing.tRunner()
    go/src/testing/testing.go:1689
runtime.goexit()
    go/src/runtime/asm_amd64.s:1695

---
bisect: checking for more failures
bisect: run: GODEBUG=asynctimerchan=1#!-x65a ./view.test... ok (6 matches)
bisect: run: GODEBUG=asynctimerchan=1#!-x65a ./view.test... ok (6 matches)
bisect: target succeeds with all remaining changes disabled

在这种情况下,堆栈跟踪清楚地表明了是哪个 time.After 调用在使用新定时器时导致了失败。


此内容是 Go Wiki 的一部分。