12.24 你應該掌握的 Go 高級併發模式:計時器

正如我在上一篇文章中所述,準確使用計時器很難的,所以這裡進行一些說明。

前言

如果你認為結合 Goroutines 去處理時間和計數器很簡單的話,那你就錯了,這裡有提到的一些與 time.Timer 相關的問題或 bug:

  • time: Timer.Reset is not possible to use correctly #14038[1]
  • time: Timer.C can still trigger even after Timer.Reset is called #11513[2]
  • time: document proper usage of Timer.Stop #14383[3]

看完上面的鏈接內容後,如果你依然認為很簡單,那來看看下面的代碼,如下代碼會產生死鎖和競爭條件

<code>tm := time.NewTimer(1)
tm.Reset(100 * time.Millisecond)
if !tm.Stop() {
\t}
/<code>

死鎖代碼片段

<code>func toChanTimed(t *time.Timer, ch chan int) {
\tt.Reset(1 * time.Second)
\tdefer func() {
\t\tif !t.Stop() {
\t\t\t\t\t}
\t}()
\tselect {
\tcase ch \tcase \t}
}
/<code>

可能代碼比較難懂,下面對相關方法進行闡述。

time.Ticker

<code>type Ticker struct {
\tC }
/<code>

Ticker 簡單易用,但也有一些小問題

  • 如果 C 中已存在一條消息,則發送消息時將刪除所有未讀值。
  • 必須有停止操作:否則 GC 無法回收它
  • 設置 C 無用:消息仍將在原始的 channel 上發送。

time.Tick

time.Tick 是對 time.NewTicker 的封裝。最好不要使用該方法,除非你準備將 chan 作為返回結果並在程序的整個生命週期中繼續使用它。正如官方描述:

垃圾收集器無法恢復底層的 Ticker,出現 " 洩漏 ". 請謹慎使用,如有疑問請改用 Ticker。

time.After

這與 Tick 的概念基本相同,它是對 Timer 進行封裝。一旦計時器被觸發,它將被回收。請注意,計時器使用了緩存容量是 1 的通道,即使沒有接收者,它仍可以進行計數。如上所述,如果您關心性能且希望能夠取消計時,那麼你不應該使用 After。

time.Timer ( 也稱為 time.WhatTheFork?!)

對於 Go 來說這是一個比較奇怪的 API :NewTicker(Duration) 返回了一個 *Timer 類型,該類型僅暴露一個定義為 chan 類型的變量 C ,這點非常奇怪。

通常在 Go 語言中允許導出的字段意味著用戶可以獲取或設置該字段,而此處設置變量 C 並沒有實際意義。相反:設置 C 並重置 Timer 並不會影響之前在 C 通道的消息傳遞。更糟糕的是:AfterFunc 返回的 Timer 根本不會使用到 C。

這樣看來,Timer 很奇怪,以下是 API 的概述:

<code>type Timer struct {
\tC }

func AfterFunc(d Duration, f func()) *Timer
func NewTimer(d Duration) *Timer
func (*Timer) Stop(bool)
func (*Timer) Reset(d Duration) bool
/<code>

四個非常簡單的函數,其中兩個是構造函數,有可能出錯嗎?

time.AfterFunc

官方文檔:AfterFunc 持續時間超時後通過開 Goroutine 去調用 f 函數,返回一個 Timer 類型,以便通過 Stop 方法取消調用。

這麼描述雖然沒有問題,但需要注意:當調用 Stop 方法時,如果返回 false ,則表示該函數已經執行且停止失敗。但並不意味著函數已經返回,你需要添加一些處理邏輯:

<code>done := make(chan struct{})
f := func() {
\tdoStuff()
\tclose(done)
}
t := time.AfterFunc(1*time.Second, f)
if !t.Stop() {
\t}
/<code>

這個在 Stop 文檔中有相關說明。

除此之外,返回的計時器不會被觸發,只能用於調用 Stop 方法。

<code>t := time.AfterFunc(1*time.Second, func() {
\tfmt.Println("Time has passed!")
})
// This will deadlock.
/<code>

此外,寫這篇文章的時候,重置計時器會在傳入重置函數的時間段過去後再次調用 f,但這種特性目前暫沒有文檔規範,未來可能會被改變。

time.NewTimer

官方文檔 : NewTimer 實例化 Timer 結構體,在持續時間 d 之後發送當前時間至通道內 .

這意味著沒有聲明它就無法構建有效的 Timer 類型結構體。如果你需要構建一個以便後續重複使用,可以用該方法進行實例化,或者使用如下代碼實現自主創建和停止計數器

<code>t := time.NewTimer(0) 

if !t.Stop() {
\t}
/<code>

必須從 channel 中讀取數據。假如在 New 和 Stop 調用期間觸發了定時器,且 channel 存在未消費的數據, 則 C 會存在一個值。將導致後續讀取均是錯誤的。

(*time.Timer).Stop

Stop 方法會阻止計時器觸發。如果調用停止計時器的方法,則返回 true,如果計時器已超時或者已停止,則返回 false。

以上句子中的“或”非常重要。文檔中所以關於 Stop 的示例都顯示了以下代碼片段:

<code>if !t.Stop() {
\t}
/<code>

關鍵點在於 "or" 它意味著有效 0 次或 1 次。對已消費完通道數據和在此期間未調用 Reset 進行過多次執行的情況,均是無效的。綜上所述,當且僅當沒有執行對通道數據的消費,Stop+drain 才是安全的。

在文檔中體現如下:

例如:假設程序尚未從 t.C 接收數據:

此外,上面的模式不是線程安全的,因為當消費完通道數據時,Stop 返回的值可能已經過時了,兩個 Goroutine 嘗試消費通道 C 數據也會導致死鎖。

(*time.Timer).Reset

這個方法更有意思,文檔很長,你可以在這裡[4] 進行查看

文檔中一個有趣的摘錄:

請注意,因為在清空 channel 和計數器到期之間存在競爭條件,我們無法正確使用 Reset 返回值。Reset 方法必須作用於已停止或已過期的 channel 上。

文檔所提供 Reset 正確使用方法如下:

<code>if !t.Stop() {
\t}
t.Reset(d)
/<code>

不能與來自通道的其他接收者同時使用 Stop 和 Reset 方法, 為了使 C 上傳遞的消息有效,C 應該在每次 重置 之前被消費完。

重置計時器而不清空它將使運行過程時丟棄該值,因為 C 緩存為 1,運行時對其他執行是有損發送[5]

time.Timer: 把這些方法放在一起

  • Stop 僅作用在 New 和 Reset 方法之後才安全
  • Reset 僅在 Stop 方法後有效。
  • 只有在每次運行 Stop 後,channel 消費完時,所接收的值才是有效的。
  • 只有 channel 未被消費時,才允許清空 channel。

以下是計時器轉換,使用和調用關係流程圖:

你應該掌握的 Go 高級併發模式:計時器


timer.png

如下是一個正確複用計時器的例子,它解決了文章開頭提到的一些問題:

<code>func toChanTimed(t *time.Timer, ch chan int) {
\tt.Reset(1 * time.Second)
\t// No defer, as we don't know which
\t// case will be selected

\tselect {
\tcase ch \tcase \t\t// C is drained, early return
\t\treturn
\t}

\t// We still need to check the return value
\t// of Stop, because t could have fired
\t// between the send on ch and this line.
\tif !t.Stop() {
\t\t\t}
}

/<code>

上述代碼可以確保 toChanTimed 返回後可以重新使用計時器

想知道更多嗎

本文中所提到的類型和函數均依賴於計數器的運行,只是使用方式不一樣。time/sleep.go[6]包含了使用它們的大部分代碼。

如下表中,包含由 time 包設置的 runtimeTimer 字段

Constructorwhen 字段period 字段f 字段arg 字段NewTicker(d)dset to dsendTimeCNewTimer(d)dnot setsendTimeCAfterFunc(d,f)dnot setgoFuncf

運行計數器不依賴於 Goroutine ,而是以更高效精確的方式組合使用。你可以在 runtime/time.go[7] 包中深入瞭解實現細節。祝學的開心!


via: https://blogtitle.github.io/go-advanced-concurrency-patterns-part-2-timers/

作者:Rob[8]譯者:liulizhi[9]校對:polaris1119[10]

本文由 GCTT[11] 原創編譯,Go 中文網[12] 榮譽推出

[1]

time: Timer.Reset is not possible to use correctly #14038: https://github.com/golang/go/issues/14038

[2]

time: Timer.C can still trigger even after Timer.Reset is called #11513: https://github.com/golang/go/issues/11513

[3]

time: document proper usage of Timer.Stop #14383: https://github.com/golang/go/issues/14383

[4]

這裡: https://golang.org/pkg/time/#Timer.Reset

[5]

有損發送: https://golang.org/src/time/sleep.go?s=#L134

[6]

time/sleep.go: https://golang.org/src/time/sleep.go

[7]

runtime/time.go: https://golang.org/src/runtime/time.go

[8]

Rob: https://blogtitle.github.io/authors/rob/

[9]

liulizhi: https://github.com/liulizhi

[10]

polaris1119: https://github.com/polaris1119

[11]

GCTT: https://github.com/studygolang/GCTT

[12]

Go 中文網: https://studygolang.com/


分享到:


相關文章: