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 的实现完全消除了这种担忧。

实现更改有两个可观察到的副作用,可能会影响生产行为或测试,以下部分对此进行了描述。

新实现仅在程序的包 main 位于模块中且该模块的 go.mod 声明了 go 1.23 或更高版本时使用。其他程序继续使用旧的语义。 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 准备好之前就准备好的通道与使用非常短的超时时间创建的新定时器之间进行 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 的一部分。