Go 併發系列是根據我對晁嶽攀老師的《Go 併發編程實戰課》的吸取和理解整理而成,若有誤差,歡迎指正~
編程
Once 是 Go 內置庫 sync 中一個比較簡單的併發原語。顧名思義,它的做用就是執行那些只須要執行一次的動做。安全
Once 最典型的使用場景就是單例對象的初始化。閉包
在 MySQL 或者 Redis 這種頻繁訪問數據的場景中,創建鏈接的代價遠遠高於數據讀寫的代價,所以咱們會用單例模式來實現一次創建鏈接,屢次訪問數據,從而實現提高服務性能。併發
常見的單例寫法以下:tcp
package mainimport ( "net" "sync" "time")// 使用互斥鎖保證線程(goroutine)安全var connMu sync.Mutexvar conn net.Connfunc getConn() net.Conn { connMu.Lock() defer connMu.Unlock() // 返回已建立好的鏈接 if conn != nil { return conn } // 建立鏈接 conn, \_ = net.DialTimeout("tcp", "baidu.com:80", 10\*time.Second) return conn}// 使用鏈接func main() { conn := getConn() if conn == nil { panic("conn is nil") }}
這個例子中,在建立 tcp 鏈接的時候,使用了單例模式。使用單例模式的注意點是創建鏈接的時候,須要對這個過程加鎖。函數
單例模式有個問題,每次都會進行加鎖、解鎖操做,對性能的消耗比較大,而 Once 能夠解決這個問題。性能
Once 除了 用來初始化單例資源,應用場景還有併發訪問只需初始化一次的共享資源,或者在測試的時候初始化一次測試資源。測試
總之,凡是隻須要初始化一次的資源,均可以用 Once。ui
Once 的使用很簡單,只有一個對外暴露 Do(f func()) 方法,參數是函數。Do 函數只要被調用過一次,以後不管怎麼調用,參數怎麼變化,都不會生效。atom
因此,對於上文的例子,若是咱們使用 Once,要怎麼寫呢?
示例代碼以下:
package mainimport ( "net" "sync" "time")var conn net.Connvar once sync.Oncefunc getConn() net.Conn { once.Do(func() { // 建立鏈接 conn, \_ = net.DialTimeout("tcp", "baidu.com:80", 10\*time.Second) }) return conn}// 使用鏈接func main() { conn := getConn() if conn == nil { panic("conn is nil") }}
在這段代碼中,咱們經過 once.Do() 實現了前面的單例模式。
這裏面三點須要注意,一是 once 的做用域和資源變量 conn 的做用域要保持一致;二是對於同一個 once,僅第一次 Do(f) 中的 f 會被執行,第二次哪怕換成 Do(f1),f1 也不會被執行;三是 Do(f) 的參數 f 是一個無參數無返回值的函數。
第一點比較好理解,若是 once 的做用域只在某個函數中生效,顯然是能夠在多個函數中執行 once.Do() 的。
第二點強調的是,once.Do() 只生效一次指的是 Do() 函數只會執行一次,不會區分參數中的函數 f。
第三點 f 是無參數無返回值的函數。若是有參數要怎麼處理呢?你能夠將參數改爲全局變量,或者用閉包實現 once.Do(),像這樣:
func closureF(x int ) func() { return func() { fmt.Println(x) }}func main() { var once sync.Once x := 4 once.Do(closureF(x))}
Once 實現的功能很簡單,所以不少人會想固然的認爲很容易實現。
一開始我也這麼想的。。
好比,經過一個全局變量來作標記,執行過一次 Do 函數,就修改標記的狀態,以後再執行的時候根據標記的狀態來決定是否須要真正執行函數。
這種作法有個很大的問題,若是 Do(f) 中 f 執行速度很慢,標記位的狀態已經被修改了,後來的 goroutine 就會覺得資源初始化已經完成,可是實際上啥也獲取不到。
下面讓咱們看一看 Once 究竟是如何實現的。
type Once struct { done uint32 m Mutex}func (o \*Once) Do(f func()) { if atomic.LoadUint32(&o.done) == 0 { o.doSlow(f) }}func (o \*Once) doSlow(f func()) { o.m.Lock() defer o.m.Unlock() // 雙檢查 if o.done == 0 { defer atomic.StoreUint32(&o.done, 1) f() }}
先看 Once 的結構體定義:一個 done 標誌位 + 一個鎖。done 用來記錄資源初始化是否完成,鎖用來保證同一時刻,只能有一個 goroutine 進行資源的初始化。
因爲這裏使用了 done 標誌位,鎖只有在資源初始化的時候纔會調用,其它時候並不會調用,所以性能相比單例模式的原始寫法,要高很多。
再看 Do() 函數的實現。這裏使用了函數內聯的方式,只有發現 done 是 0 的狀況下,纔會執行 doSlow() 函數。
doSlow() 中又再次檢查了 done 的值,這就是雙檢查機制。併發場景下,done 值的檢查和修改必須先持有鎖才行。
總體看,Once 的實現仍是比較簡單的。在實踐中,不多會出現 Once 使用錯誤的狀況,可是有兩種場景,仍是要特殊注意下。
因爲 Once 的定義中有互斥鎖,若是出現 once.Do(f) 執行 f,f再執行 once.Do(f) 的場景,就出現了相似重入的現象,形成死鎖,好比下面:
func main() { var once sync.Once once.Do(func() { once.Do(func() { fmt.Println("初始化") }) })}
這種狀況下,m.Lock 等待 m.Lock,造成了死鎖。要避免這種狀況,只要 f 中不執行 once.Do() 就行。
若是 f 執行異常,資源初始化失敗,Once 仍是會認爲執行成功,而再次執行 Do(f) 的時候,f 也不會再被執行,致使接下來直接使用初始化的資源時候異常。
這種狀況下該如何處理呢?
能夠本身實現一個相似的 Once 原語!! 只有當資源初始化成功,done 的值才置成 1, 不然不變。
固然,還有一點須要注意的是,自定義 Once 的結構體中,須要再加一個標誌代表是否初始化成功,不然若是初始化失敗,再繼續後面的流程,很容易出現 panic。
碼農的自由之路
996的碼農,也能自由~
47篇原創內容
公衆號
都看到這裏了,不如點個 贊/在看?