Go 1-23 Timer Channel 的改變

官方的介紹。https://go.dev/wiki/Go123Timer


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 避免過期值則需要謹慎處理其返回值。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 實現中創建的定時器通道的 caplen 始終爲 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 工具:

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

以這種方式調用時,bisect 會反覆運行 mytest,根據導致定時器調用的堆棧跟蹤來切換新舊定時器實現。通過二分查找,它可以將失敗縮小到在特定堆棧跟蹤期間啓用新定時器,並報告這些跟蹤。在 bisect 運行時,它會打印試驗的狀態信息,主要是爲了在測試很慢時讓你知道它仍在運行。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/5UdKbSoxueQR6dHwY35y6A