1. 程式人生 > 其它 >深入理解 sync.Once 與 sync.Pool

深入理解 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

就標識著已經執行過了。Once 的原始碼非常簡短

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.LoadUint32done = 0 表示還沒有執行,所以多個請求在 f 執行前都會進來執行 o.doSlow(f),然後通過互斥鎖使保證多個請求只有一個才能成功執行,保證了 f 成功返回之後才會記憶體同步原語將 done 設定為 1。最後釋放鎖,後面的請求就因無法滿足判斷而退出。

如果仔細檢視原始碼中的註釋就會發現 go 團隊還解釋了為什麼沒有使用 cas 這種同步原語實現。因為 sync.OnceDo(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 核心物件有三個

  1. New:函式,負責物件初始化
  2. Get:獲取 Pool 中的物件,如果 Pool 中物件不存在則會呼叫 New
  3. 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 方法時會做如下方法:

  1. 查詢 private 是否有值,有直接返回;沒有查詢共享 poolChain 連結串列
  2. 如果 poolChain 連結串列 pop 返回的值不為 nil,則直接返回;如果沒有值則轉向其它 P 中的 poolChain 佇列中存在的值
  3. 如果其它的 P 的共享佇列中都沒有值,就會嘗試在主存中地址獲取對應的值返回
  4. 最終都沒有就會執行 New 函式體返回,沒有設定 New 則返回 nil。

從上面的呼叫過程來看,Pool.Get 獲取值的過程在一定程度與 gmp 模型有很多相似的地方的。

Put

Put 操作就比較簡單了,優先將值賦值給 poolLocalInternal.private (同樣是固定將當前的 G 繫結到 P 上),如果同時有多個值 Put,那麼就會將剩餘的值插入到共享連結串列 poolChain

sync.Pool 使用限制

因為 pool 每次的 get 操作都會將值 remove + return,相當於用完即拋。並且要注意 Get 的執行過程。Put 方法的引數型別可以是任意型別,一定要切記不要將不同型別的值存進去。如果存在多協程(或迴圈)呼叫 Get 時,你無法確定哪次呼叫的就是你想要的型別而導致出現未知的錯誤。

文字同步至:https://github.com/MarsonShine/GolangStudy/issues/5