這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
Go 語言的 channel 本身是不支援 timeout 的,所以一般實現 channel 的讀寫逾時都採用 select,如下:
select {case <-c:case <-time.After(time.Second):}
這兩天在寫碼的過程中突然對這樣實現 channel 逾時產生了懷疑,這種方式真的好嗎?於是我寫了這樣一個測試程式:
package mainimport ( "os" "time")func main() { c := make(chan int, 100) go func() { for i := 0; i < 10; i++ { c <- 1 time.Sleep(time.Second) } os.Exit(0) }() for { select { case n := <-c: println(n) case <-timeAfter(time.Second * 2): } }}func timeAfter(d time.Duration) chan int { q := make(chan int, 1) time.AfterFunc(d, func() { q <- 1 println("run") // 重點在這裡 }) return q}
這個程式很簡單,你會發現運行結果將會輸出 10 次 “run”,也就是每一遍執行 select 註冊的 timer 最終都執行了,雖然這裡讀 channel 都沒有逾時。原因其實很簡單,每次執行 select 語句,都會將 case 條件陳述式給執行一遍,於是 timeAfter 的執行結果就是會建立一個定時器,並註冊到 runtime 中,select 語句執行完成後,這個定時器本身並沒有撤銷,還繼續保留在 runtime 的小頂堆中,所以這些 timer 一逾時就會執行掛載的函數。
當然,用 time.After()
函數來做 channel 的讀寫逾時,在應用程式層根本感受不到底層的定時器還保留著、繼續執行;問題是,如果這裡的 select 語句在迴圈中執行得非常快,也就是 channel 中的訊息來得非常頻繁,會出現的問題就是 runtime 中會有大量的定時器存在,timeout 的時間設定得越長,底層維護的定時器就會越多。原因就是每次 select 都會註冊一個新的 timer,並且 timer 只有在它逾時後才會被刪除。
想想,自己的 channel 每秒鐘將傳輸成千上萬的訊息,將會有多少 timer 對象存在底層 runtime 中。大量的臨時對象會不會影響記憶體?大量的 timer 會不會影響其他定時器的準確度?
最後,我覺得正確的 channel timeout 也許應該這麼做:
to := time.NewTimer(time.Second)for { to.Reset(time.Second) select { case <-c: case <-to.C: }}
這樣做就是為了維護一個全域單一的定時器,每次操作前調整一下定時器的逾時時間,從而避免每次迴圈都產生新的定時器對象。
簡單測試了一下兩種 channel 逾時實現方式,在全力收發資料的情況的記憶體對象和 gc 情況。
* 藍線是採用 time.After()
,並設定4s 逾時的堆記憶體對象分配的數量* 綠線是採用 time.After()
,並設定2s 逾時的堆記憶體對象分配的數量* 黃線是採用全域 timer,並設定4s 逾時的堆記憶體對象分配的數量
這個現象其實是預料之中的,重點可以注意設定的逾時時間越長,time.After()
的表現將越糟糕。
這三條線和的三條線描述的對象是一樣的,圖中的 gc 時間是平均每次 gc 的時間。
針對這個 channel timeout,我沒有去測試是否會影響其他定時器的準確性,但我認為這是必然的,隨著定時器的增多。
最後,我始終覺得 channel 本身應該支援逾時機制,而不是利用 select 來實現。
探索任何一個現象背後的真正原因,才是最有趣的事情。