七. Go併發程式設計--sync.Once

failymao發表於2021-11-02

一.序

單從庫名大概就能猜出其作用。sync.Once使用起來很簡單, 下面是一個簡單的使用案例

package main

import (
	"fmt"
	"sync"
)

func main() {
	var (
		once sync.Once
		wg   sync.WaitGroup
	)

	for i := 0; i < 10; i++ {
		wg.Add(1)
		// 這裡要注意講i顯示的當引數傳入內部的匿名函式
		go func(i int) {
			defer wg.Done()
			// fmt.Println("once", i)
			once.Do(func() {
				fmt.Println("once", i)
			})
		}(i)
	}

	wg.Wait()
	fmt.Printf("over")
}

輸出:

❯ go run ./demo.go
once 9

測試如果不新增once.Do 這段程式碼,則會輸出如下結果,並且每次執行的輸出都不一樣。

once 9
once 0
once 3
once 6
once 4
once 1
once 5
once 2
once 7
once 8

從兩次輸出不同,我們可以得知 sync.Once的作用是:保證傳入的函式只執行一次

二. 原始碼分析

2.1結構體

Once的結構體如下

type Once struct {
    done uint32
    m    Mutex
}

每一個 sync.Once 結構體中都只包含一個用於標識程式碼塊是否執行過的 done 以及一個互斥鎖 sync.Mutex

2.2 介面

sync.Once.Dosync.Once 結構體對外唯一暴露的方法,該方法會接收一個入參為空的函式

  • 如果傳入的函式已經執行過,會直接返回
  • 如果傳入的函式沒有執行過, 會呼叫sync.Once.doSlow執行傳入的引數
func (o *Once) Do(f func()) {
	// Note: Here is an incorrect implementation of Do:
	//
	//	if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
	//		f()
	//	}
	//
	// Do guarantees that when it returns, f has finished.
	// This implementation would not implement that guarantee:
	// given two simultaneous calls, the winner of the cas would
	// call f, and the second would return immediately, without
	// waiting for the first's call to f to complete.
	// This is why the slow path falls back to a mutex, and why
	// the atomic.StoreUint32 must be delayed until after f returns.

	if atomic.LoadUint32(&o.done) == 0 {
		// Outlined slow-path to allow inlining of the fast-path.
		o.doSlow(f)
	}
}

程式碼註釋中特別給了一個說明: 很容易犯錯的一種實現

if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
	f()
}

如果這麼實現最大的問題是,如果併發呼叫,一個 goroutine 執行,另外一個不會等正在執行的這個成功之後返回,而是直接就返回了,這就不能保證傳入的方法一定會先執行一次了

正確的實現方式

if atomic.LoadUint32(&o.done) == 0 {
    // Outlined slow-path to allow inlining of the fast-path.
    o.doSlow(f)
}

會先判斷 done 是否為 0,如果不為 0 說明還沒執行過,就進入 doSlow

func (o *Once) doSlow(f func()) {
	o.m.Lock()
	defer o.m.Unlock()
	if o.done == 0 {
		defer atomic.StoreUint32(&o.done, 1)
		f()
	}
}

doSlow 當中使用了互斥鎖來保證只會執行一次

具體的邏輯

  1. 為當前Goroutine獲取互斥鎖
  2. 執行傳入的無入參函式;
  3. 執行延遲函式, 將成員變數done更新為1

三. 使用場景案例

3.1 單例模式

原子操作配合互斥鎖可以實現非常高效的單件模式。互斥鎖的代價比普通整數的原子讀寫高很多,在效能敏感的地方可以增加一個數字型的標誌位,通過原子檢測標誌位狀態降低互斥鎖的使用次數來提高效能。

type singleton struct {}

var (
    instance    *singleton
    initialized uint32
    mu          sync.Mutex
)

func Instance() *singleton {
    if atomic.LoadUint32(&initialized) == 1 {
        return instance
    }

    mu.Lock()
    defer mu.Unlock()

    if instance == nil {
        defer atomic.StoreUint32(&initialized, 1)
        instance = &singleton{}
    }
    return instance
}

而使用sync.Once能更簡單實現單例模式

type singleton struct {}

var (
    instance *singleton
    once     sync.Once
)

func Instance() *singleton {
    once.Do(func() {
        instance = &singleton{}
    })
    return instance
}

3.2 載入配置檔案示例

延遲一個開銷很大的初始化操作到真正用到它的時候再執行是一個很好的實踐。因為預先初始化一個變數(比如在init函式中完成初始化)會增加程式的啟動耗時,而且有可能實際執行過程中這個變數沒有用上,那麼這個初始化操作就不是必須要做的。我們來看一個例子:

var icons map[string]image.Image

func loadIcons() {
    icons = map[string]image.Image{
        "left":  loadIcon("left.png"),
        "up":    loadIcon("up.png"),
        "right": loadIcon("right.png"),
        "down":  loadIcon("down.png"),
    }
}

// Icon 被多個goroutine呼叫時不是併發安全的
// 因為map型別本就不是型別安全資料結構
func Icon(name string) image.Image {
    if icons == nil {
        loadIcons()
    }
    return icons[name]
}

多個goroutine併發呼叫Icon函式時不是併發安全的,編譯器和CPU可能會在保證每個goroutine都滿足序列一致的基礎上自由地重排訪問記憶體的順序。loadIcons函式可能會被重排為以下結果:

func loadIcons() {
    icons = make(map[string]image.Image)
    icons["left"] = loadIcon("left.png")
    icons["up"] = loadIcon("up.png")
    icons["right"] = loadIcon("right.png")
    icons["down"] = loadIcon("down.png")
}

在這種情況下就會出現即使判斷了icons不是nil也不意味著變數初始化完成了。考慮到這種情況,我們能想到的辦法就是新增互斥鎖,保證初始化icons的時候不會被其他的goroutine操作,但是這樣做又會引發效能問題。

可以使用sync.Once 改造程式碼

var icons map[string]image.Image

var loadIconsOnce sync.Once

func loadIcons() {
    icons = map[string]image.Image{
        "left":  loadIcon("left.png"),
        "up":    loadIcon("up.png"),
        "right": loadIcon("right.png"),
        "down":  loadIcon("down.png"),
    }
}

// Icon 是併發安全的,並且保證了在程式碼執行的時候才會載入配置
func Icon(name string) image.Image {
    loadIconsOnce.Do(loadIcons)
    return icons[name]
}

這樣設計就能保證初始化操作的時候是併發安全的並且初始化操作也不會被執行多次。

四.總結

作為用於保證函式執行次數的 sync.Once 結構體,它使用互斥鎖和 sync/atomic 包提供的方法實現了某個函式在程式執行期間只能執行一次的語義。在使用該結構體時,我們也需要注意以下的問題:

  • sync.Once.Do 方法中傳入的函式只會被執行一次,哪怕函式中發生了 panic;
  • 兩次呼叫 sync.Once.Do 方法傳入不同的函式只會執行第一次調傳入的函式;

五. 參考

  1. https://lailin.xyz/post/go-training-week3-once.html
  2. https://www.topgoer.cn/docs/gozhuanjia/chapter055.2-waitgroup
  3. https://www.topgoer.com/併發程式設計/sync.html
  4. https://chai2010.cn/advanced-go-programming-book/ch1-basic/ch1-05-mem.html

相關文章