9.5. sync.Once惰性初始化

如果初始化成本比較大的話,那麼將初始化延遲到需要的時候再去做就是一個比較好的選擇。如果在程序啟動的時候就去做這類初始化的話,會增加程序的啟動時間,並且因為執行的時候可能也並不需要這些變量,所以實際上有一些浪費。讓我們來看在本章早一些時候的icons變量:

var icons map[string]image.Image

這個版本的Icon用到了懶初始化(lazy initialization)。

func loadIcons() {
    icons = map[string]image.Image{
        "spades.png":   loadIcon("spades.png"),
        "hearts.png":   loadIcon("hearts.png"),
        "diamonds.png": loadIcon("diamonds.png"),
        "clubs.png":    loadIcon("clubs.png"),
    }
}

// NOTE: not concurrency-safe!
func Icon(name string) image.Image {
    if icons == nil {
        loadIcons() // one-time initialization
    }
    return icons[name]
}

如果一個變量只被一個單獨的goroutine所訪問的話,我們可以使用上面的這種模板,但這種模板在Icon被併發調用時並不安全。就像前面銀行的那個Deposit(存款)函數一樣,Icon函數也是由多個步驟組成的:首先測試icons是否為空,然後load這些icons,之後將icons更新為一個非空的值。直覺會告訴我們最差的情況是loadIcons函數被多次訪問會帶來數據競爭。當第一個goroutine在忙著loading這些icons的時候,另一個goroutine進入了Icon函數,發現變量是nil,然後也會調用loadIcons函數。

不過這種直覺是錯誤的。(我們希望你從現在開始能夠構建自己對併發的直覺,也就是說對併發的直覺總是不能被信任的!),回憶一下9.4節。因為缺少顯式的同步,編譯器和CPU是可以隨意地去更改訪問內存的指令順序,以任意方式,只要保證每一個goroutine自己的執行順序一致。其中一種可能loadIcons的語句重排是下面這樣。它會在填寫icons變量的值之前先用一個空map來初始化icons變量。

func loadIcons() {
    icons = make(map[string]image.Image)
    icons["spades.png"] = loadIcon("spades.png")
    icons["hearts.png"] = loadIcon("hearts.png")
    icons["diamonds.png"] = loadIcon("diamonds.png")
    icons["clubs.png"] = loadIcon("clubs.png")
}

因此,一個goroutine在檢查icons是非空時,也並不能就假設這個變量的初始化流程已經走完了(譯註:可能只是塞了個空map,裡面的值還沒填完,也就是說填值的語句都沒執行完呢)。

最簡單且正確的保證所有goroutine能夠觀察到loadIcons效果的方式,是用一個mutex來同步檢查。

var mu sync.Mutex // guards icons
var icons map[string]image.Image

// Concurrency-safe.
func Icon(name string) image.Image {
    mu.Lock()
    defer mu.Unlock()
    if icons == nil {
        loadIcons()
    }
    return icons[name]
}

然而使用互斥訪問icons的代價就是沒有辦法對該變量進行併發訪問,即使變量已經被初始化完畢且再也不會進行變動。這裡我們可以引入一個允許多讀的鎖:

var mu sync.RWMutex // guards icons
var icons map[string]image.Image
// Concurrency-safe.
func Icon(name string) image.Image {
    mu.RLock()
    if icons != nil {
        icon := icons[name]
        mu.RUnlock()
        return icon
    }
    mu.RUnlock()

    // acquire an exclusive lock
    mu.Lock()
    if icons == nil { // NOTE: must recheck for nil
        loadIcons()
    }
    icon := icons[name]
    mu.Unlock()
    return icon
}

上面的代碼有兩個臨界區。goroutine首先會獲取一個讀鎖,查詢map,然後釋放鎖。如果條目被找到了(一般情況下),那麼會直接返回。如果沒有找到,那goroutine會獲取一個寫鎖。不釋放共享鎖的話,也沒有任何辦法來將一個共享鎖升級為一個互斥鎖,所以我們必須重新檢查icons變量是否為nil,以防止在執行這一段代碼的時候,icons變量已經被其它gorouine初始化過了。

上面的模板使我們的程序能夠更好的併發,但是有一點太複雜且容易出錯。幸運的是,sync包為我們提供了一個專門的方案來解決這種一次性初始化的問題:sync.Once。概念上來講,一次性的初始化需要一個互斥量mutex和一個boolean變量來記錄初始化是不是已經完成了;互斥量用來保護boolean變量和客戶端數據結構。Do這個唯一的方法需要接收初始化函數作為其參數。讓我們用sync.Once來簡化前面的Icon函數吧:

var loadIconsOnce sync.Once
var icons map[string]image.Image
// Concurrency-safe.
func Icon(name string) image.Image {
    loadIconsOnce.Do(loadIcons)
    return icons[name]
}

每一次對Do(loadIcons)的調用都會鎖定mutex,並會檢查boolean變量(譯註:Go1.9中會先判斷boolean變量是否為1(true),只有不為1才鎖定mutex,不再需要每次都鎖定mutex)。在第一次調用時,boolean變量的值是false,Do會調用loadIcons並會將boolean變量設置為true。隨後的調用什麼都不會做,但是mutex同步會保證loadIcons對內存(這裡其實就是指icons變量啦)產生的效果能夠對所有goroutine可見。用這種方式來使用sync.Once的話,我們能夠避免在變量被構建完成之前和其它goroutine共享該變量。

練習 9.2: 重寫2.6.2節中的PopCount的例子,使用sync.Once,只在第一次需要用到的時候進行初始化。(雖然實際上,對PopCount這樣很小且高度優化的函數進行同步可能代價沒法接受。)