深入理解 sync.Once 與 sync.Pool
深入理解 sync.Once 與 sync.Pool
sync.Once
代表在這個物件下在這個示例下多次執行能保證只會執行一次操作。
var once sync.Once
for i:=0; i < 10; i++ {
once.Do(func(){
fmt.Println("execed...")
})
}
在上面的例子中,once.Do 的引數 func 函式就會保證只執行一次。
sync.Once 原理
那麼 sync.Once 是如何保證 Do 執行體函式只執行一次呢?
從 sync.Once 的原始碼就可以看出其實就是通過一個 uint32 型別的 done 標識實現的。當 done = 1
package sync import ( "sync/atomic" ) 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() } }
Do
方法內部用到了記憶體載入同步原語 atomic.LoadUint32
,done = 0
表示還沒有執行,所以多個請求在 f
執行前都會進來執行 o.doSlow(f)
,然後通過互斥鎖使保證多個請求只有一個才能成功執行,保證了 f 成功返回之後才會記憶體同步原語將 done
設定為 1。最後釋放鎖,後面的請求就因無法滿足判斷而退出。
如果仔細檢視原始碼中的註釋就會發現 go 團隊還解釋了為什麼沒有使用 cas 這種同步原語實現。因為 sync.Once
的 Do(f)
在執行的時候要保證只有在 f 執行完之後 do 才返回。想象一下有至少兩個請求,Do 是用 cas 實現的:
func (o *Once) Do(f func()) { if atomic.CompareAndSwapUint32(&o.done, 0, 1) { f() } }
雖然 cas 保證了同一時刻只有一個請求進入 if 判斷執行 f()。但是其它的請求卻沒有等待 f() 執行完成就立即返回了。那麼使用者端在執行 once.Do 返回之後其實就可能存在 f() 還未完成,就會出現意料之外的錯誤。如下面例子
var db SqlDb
var once sync.Once
for i:=0; i < 2; i++ {
once.Do(func() {
db = NewSqlDB()
fmt.Println("execed...")
})
}
// #1
db.Query("select * from table")
...
根據上述如果是用 cas 實現的 once,那麼當 once.Do
執行完返回並且迴圈體結束到達 #1 時,由於 db 的初始化函式可能還沒完成,那麼這個時候 db 還是 nil,那麼直接呼叫 db.Query
就會發生錯誤了。
sync.Once 使用限制
由於 Go 語言一切皆 struct 的特性,我們在使用 sync.Once 的時候一定要注意不要通過傳遞引數使用。因為 go 對於 sync.Once 引數傳遞是值傳遞,會將原來的 once 拷貝過來,所以有可能會導致 once 會重複執行或者是已經執行過了就不會執行的問題。
func main() {
for i := 0; i < 10; i++ {
once.Do(func() {
fmt.Println("execed...")
})
}
duplicate(once)
}
func duplicate(once sync.Once) {
for i := 0; i < 10; i++ {
once.Do(func() {
fmt.Println("execed2...")
})
}
}
比如上述例子,由於 once 已經執行過一次,once.done 已經為 1。這個時候再通過傳遞,由於 once.done 已經為1,所以就不會執行了。上面的輸出結果只會列印第一段迴圈的結果 execed...
。
sync.Pool
sync.Pool 其實把初始化的物件放到內部的一個池物件中,等下次訪問就直接返回池中的物件,如果沒有的話就會生成這個物件放入池中。Pool 的目的是”預熱“,即初始化但還未立即使用的物件,由於預先初始化至 Pool,所以到後續取得時候就直接返回已經初始化過得物件即可。這樣提高了程式吞吐,因為有時候在執行時初始化一些物件的開銷是非常昂貴的,如資料庫連線物件等。
現在我們來深入分析 Pool
sync.Pool 原理
sync.Pool 核心物件有三個
- New:函式,負責物件初始化
- Get:獲取 Pool 中的物件,如果 Pool 中物件不存在則會呼叫 New
- Put:將物件放入 Pool 中
New func
Pool 的結構很簡單,就 5 個欄位
type Pool struct {
...
New func() interface{}
}
欄位 New
是一個初始化物件的指標,該方法不是必填的,當沒有設定 New 函式時,呼叫 Get 方法會返回 nil。只有在指定了 New 函式體後,呼叫 Get 如果發現 Pool 中沒有就會呼叫 New 初始化方法並返回該物件。
poolLocalInternal
在將 Get、Put 之前得先了解 poolLocalInternal 這個物件,裡面只有兩個物件,都是用來儲存要用的物件的:
type poolLocalInternal struct {
private interface{} // Can be used only by the respective P.
shared poolChain // Local P can pushHead/popHead; any P can popTail.
}
操作這個物件時必須要把當前的 goroutine 繫結到 P,並且禁止讓出 g。在 Get 和 Put 操作時都是優先操作 private
這個欄位,只有在這個欄位為 nil 的情況下才會轉而讀取 poolChain 共享連結串列,每讀取操作都是一次 pop。
Get
每個當前 goroutine 都擁有一個 poolLocalInternal.private
,在 g 呼叫 Get 方法時會做如下方法:
- 查詢
private
是否有值,有直接返回;沒有查詢共享 poolChain 連結串列 - 如果 poolChain 連結串列 pop 返回的值不為 nil,則直接返回;如果沒有值則轉向其它 P 中的 poolChain 佇列中存在的值
- 如果其它的 P 的共享佇列中都沒有值,就會嘗試在主存中地址獲取對應的值返回
- 最終都沒有就會執行 New 函式體返回,沒有設定 New 則返回 nil。
從上面的呼叫過程來看,Pool.Get 獲取值的過程在一定程度與 gmp 模型有很多相似的地方的。
Put
Put 操作就比較簡單了,優先將值賦值給 poolLocalInternal.private
(同樣是固定將當前的 G 繫結到 P 上),如果同時有多個值 Put,那麼就會將剩餘的值插入到共享連結串列 poolChain
sync.Pool 使用限制
因為 pool 每次的 get 操作都會將值 remove + return
,相當於用完即拋。並且要注意 Get 的執行過程。Put 方法的引數型別可以是任意型別,一定要切記不要將不同型別的值存進去。如果存在多協程(或迴圈)呼叫 Get 時,你無法確定哪次呼叫的就是你想要的型別而導致出現未知的錯誤。