首頁 > 軟體

一文解析 Golang sync.Once 用法及原理

2022-08-31 14:03:23

前言

在此前一篇文章中我們瞭解了 Golang Mutex 原理解析,今天來看一個官方給出的 Mutex 應用場景:sync.Once。

1. 定位

Once is an object that will perform exactly one action.

sync.Once 是 Go 標準庫提供的使函數只執行一次的實現,常應用於單例模式,例如初始化設定、保持資料庫連線等。它可以在程式碼的任意位置初始化和呼叫,因此可以延遲到使用時再執行,並行場景下是執行緒安全的。

2. 對外介面

Once 對外僅暴露了唯一的方法 Do(f func()),f 為需要執行的函數。

// Do calls the function f if and only if Do is being called for the
// first time for this instance of Once. In other words, given
// 	var once Once
// if once.Do(f) is called multiple times, only the first call will invoke f,
// even if f has a different value in each invocation. A new instance of
// Once is required for each function to execute.
//
// Do is intended for initialization that must be run exactly once. Since f
// is niladic, it may be necessary to use a function literal to capture the
// arguments to a function to be invoked by Do:
// 	config.once.Do(func() { config.init(filename) })
//
// Because no call to Do returns until the one call to f returns, if f causes
// Do to be called, it will deadlock.
//
// If f panics, Do considers it to have returned; future calls of Do return
// without calling f.
//
func (o *Once) Do(f func()) 

結合註釋,我們來看看 Do 方法有哪些需要注意的:

  • 只有在當前的 Once 範例第一次呼叫 Do 方法時,才會真正執行 f。哪怕在多次呼叫 Do 中間 f 的值有所變化,也只會被實際呼叫一次;
  • Do 針對的是隻希望執行一次的初始化操作,由於f 是沒有引數的,如果需要傳參,可以採用包裝一層 func 的形式來實現:config.once.Do(func() { config.init(filename) })
  • 在對f 的呼叫返回之前,不會返回對Do的呼叫,所以如果f方法中又呼叫來Do方法,將會死鎖。所以不要做這樣的操作:
func main() {
 var once sync.Once
 once.Do(func() {
    once.Do(func() {
       fmt.Println("hello kenmawr.")
    })
 })
}
  • 如果 f 丟擲了 panic,此時Do會認為f已經返回,後續多次呼叫Do也不會再觸發對 f 的呼叫。

3. 實戰用法

sync.Once 的場景很多,但萬變不離其宗的落腳點在於:任何只希望執行一次的操作

基於此,我們可以發現很多具體的應用場景落地,比如某個資源的清理,全域性變數的初始化,單例模式等,它們本質都是一樣的。這裡簡單列幾個,大家可以直接參考程式碼熟悉。

3.1 初始化

很多同學可能會有疑問,我直接在 init() 函數裡面做初始化不就可以了嗎?效果上是一樣的,為什麼還要用 sync.Once,這樣還需要多宣告一個 once 物件。

原因在於:init() 函數是在所在包首次被載入時執行,若未實際使用,既浪費了記憶體,又延緩了程式啟動時間。而 sync.Once 可以在任何位置呼叫,而且是並行安全的,我們可以在實際依賴某個變數時才去初始化,這樣「延遲初始化」從功能上講並無差異,但可以有效地減少不必要的效能浪費。

我們來看 Golang 官方的 html 庫中的一個例子,我們經常使用的跳脫字串函數

func UnescapeString(s string) string

在進入函數的時候,首先就會依賴包裡內建的 populateMapsOnce 範例(本質是一個 sync.Once) 來執行初始化 entity 的操作。這裡的entity是一個包含上千鍵值對的 map,如果init()時就去初始化,會浪費記憶體。

var populateMapsOnce sync.Once
var entity           map[string]rune

func populateMaps() {
    entity = map[string]rune{
        "AElig;":                           'U000000C6',
        "AMP;":                             'U00000026',
        "Aacute;":                          'U000000C1',
        "Abreve;":                          'U00000102',
        "Acirc;":                           'U000000C2',
        // 省略後續鍵值對
    }
}

func UnescapeString(s string) string {
    populateMapsOnce.Do(populateMaps)
    i := strings.IndexByte(s, '&')

    if i < 0 {
            return s
    }
    // 省略後續的實現
    ...
}

3.2 單例模式

開發中我們經常會實現 Getter 來暴露某個非匯出的變數,這個時候就可以把 once.Do 放到 Getter 裡面,完成單例的建立。

package main
import (
   "fmt"
   "sync"
)
type Singleton struct{}
var singleton *Singleton
var once sync.Once

func GetSingletonObj() *Singleton {
   once.Do(func() {
      fmt.Println("Create Obj")
      singleton = new(Singleton)
   })
   return singleton
}
func main() {
   var wg sync.WaitGroup
   for i := 0; i < 5; i++ {
      wg.Add(1)
      go func() {
         defer wg.Done()
         obj := GetSingletonObj()
         fmt.Printf("%pn", obj)
      }()
   }
   wg.Wait()
}
/*--------- 輸出 -----------
Create Obj
0x119f428
0x119f428
0x119f428
0x119f428
0x119f428
**/

3.3 關閉channel

一個channel如果已經被關閉,再去關閉的話會 panic,此時就可以應用 sync.Once 來幫忙。

type T int
type MyChannel struct {
   c    chan T
   once sync.Once
}
func (m *MyChannel) SafeClose() {
   // 保證只關閉一次channel
   m.once.Do(func() {
      close(m.c)
   })
}

4. 原理

在 sync 的原始碼包中,Once 的定義是一個 struct,所有定義和實現去掉註釋後不過 30行,我們直接上原始碼來分析:

package sync
import (
   "sync/atomic"
)
// 一個 Once 範例在使用之後不能被拷貝繼續使用
type Once struct {
   done uint32 // done 表明了動作是否已經執行
   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 結構定義
type Once struct {
	// done indicates whether the action has been performed.
	// It is first in the struct because it is used in the hot path.
	// The hot path is inlined at every call site.
	// Placing done first allows more compact instructions on some architectures (amd64/386),
	// and fewer instructions (to calculate offset) on other architectures.
	done uint32
	m    Mutex
}

sync.Once絕大多數場景都會存取o.done,存取 done 的機器指令是處於hot path上,hot path表示程式非常頻繁執行的一系列指令。由於結構體第一個欄位的地址和結構體的指標是相同的,如果是第一個欄位,直接對結構體的指標解除參照即可,如果是其他的欄位,除了結構體指標外,還需要計算與第一個值的偏移,所以將done放在第一個欄位,則CPU減少了一次偏移量的計算,存取速度更快。

  • 為何不使用 CAS 來達到執行一次的效果

其實使用 atomic.CompareAndSwapUint32 是一個非常直觀的方案,這樣的話 Do 的實現就變成了

func (o *OnceA) Do(f func()) {
  if !atomic.CompareAndSwapUint32(&o.done, 0, 1) {
    return
  }
  f()
}

這樣的問題在於,一旦出現 CAS 失敗的情況,成功協程會繼續執行 f,但其他失敗協程不會等待 f 執行結束。而Do 的API定位對此有著強要求,當一次 once.Do 返回時,執行的 f 一定是完成的狀態。

對此,sync.Once 官方給出的解法是:

Slow path falls back to a mutex, and the atomic.StoreUint32 must be delayed until after f returns.

我們再來結合 doSlow() 看一看這裡是怎麼解決這個並行問題的:

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()
   }
}
  • atomic.LoadUint32 用於原子載入地址(也就是 &o.done),返回載入到的值;
  • o.done 為 0 是代表尚未執行。若同時有兩個 goroutine 進來,發現 o.done 為 0(此時 f 尚未執行),就會進入 o.doSlow(f) 的慢路徑中(slow path);
  • doSlow 使用 sync.Mutex 來加鎖,一個協程進去,其他的被阻塞在鎖的地方(注意,此時是阻塞,不是直接返回,這是和 CAS 方案最大的差別);
  • 經過 o.m.Lock() 獲取到鎖以後,如果此時 o.done 還是 0,意味著依然沒有被執行,此時就可以放心的呼叫 f來執行了。否則,說明當前協程在被阻塞的過程中,已經失去了呼叫f 的機會,直接返回。
  • defer atomic.StoreUint32(&o.done, 1) 是這裡的精華,必須等到f() 返回,在 defer 裡才能夠去更新 o.done 的值為 1。

5. 避坑

  • 不要拷貝一個 sync.Once 使用或作為引數傳遞,然後去執行 Do,值傳遞時 done 會歸0,無法起到限制一次的效果。
  • 不要在 Do 的 f 中巢狀呼叫 Do

到此這篇關於一文解析 Golang sync.Once 用法及原理的文章就介紹到這了,更多相關 Golang sync.Once 內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com