Go sync.Once 的妙用

pseudoyu發表於2021-09-02

如果你曾用過 Go 中的 goroutines,你也許會遇到幾個併發原語,如 sync.Mutex, sync.WaitGroup 或是 sync.Map,但是你聽說過 sync.Once 麼?

也許你聽說過,那 go 文件是怎麼描述它的呢?

Once 是隻執行一個操作的物件。

聽起來很簡單,它有什麼用處呢?

由於某些原因,sync.Once 的用法並沒有很好的文件記錄。在第一個.Do中的操作執行完成前,將一直處於等待狀態,這使得在執行較昂貴的操作(通常快取在 map 中)時非常有用。

原生快取方式

假設你有一個熱門的網站,但它的後端 API 訪問不是很快,因此你決定將 API 結果通過 map 快取在記憶體中。以下是一個基本的解決方案:

package main

type QueryClient struct {
    cache map[string][]byte
    mutex *sync.Mutex
}

func (c *QueryClient) DoQuery(name string) []byte {
    // 檢查結果是否已快取
    c.mutex.Lock()
    if cached, found := c.cache[name]; found {
        c.mutex.Unlock()
        return cached, nil
    }
    c.mutex.Unlock()

    // 如果未快取則發出請求
    resp, err := http.Get("https://upstream.api/?query=" + url.QueryEscape(name))
    // 為簡潔起見,省略了錯誤處理和 resp.Body.Close
    result, err := ioutil.ReadAll(resp)

    // 將結果儲存在快取中
    c.mutex.Lock()
    c.cache[name] = result
    c.mutex.Unlock()

    return result
}

看起來不錯,對吧?

然而,如果有兩個 DoQuery 同時進行呼叫會發生什麼呢?競爭。兩方快取都無法命中,並且都會向 upstream.api 執行不必要的 HTTP 請求,而只有一個需要完成這個請求。

不美觀但更好的快取方式

我並沒有進行統計,但我認為大家解決這個問題的另外一種方式是使用 channel、context 或 mutex。在這個例子中,可以將上文程式碼調整為:

package main

type CacheEntry struct {
    data []byte
    wait <-chan struct{}
}

type QueryClient struct {
    cache map[string]*CacheEntry
    mutex *sync.Mutex
}

func (c *QueryClient) DoQuery(name string) []byte {
    // 檢查操作是否已啟動
    c.mutex.Lock()
    if cached, found := c.cache[name]; found {
        c.mutex.Unlock()
        // 等待完成
        <-cached.wait
        return cached.data, nil
    }

    entry := &CacheEntry{
        data: result,
        wait: make(chan struct{}),
    }
    c.cache[name] = entry
    c.mutex.Unlock()

    // 如果未快取,則發出請求
    resp, err := http.Get("https://upstream.api/?query=" + url.QueryEscape(name))
    // 為簡潔起見,省略了錯誤處理和 resp.Body.Close
    entry.data, err = ioutil.ReadAll(resp)

    // 關閉 channel,傳遞操作完成訊號
    // 立即返回
    close(entry.wait)

    return entry.data
}

這種方案不錯,但程式碼的可讀性受到了很大影響。cached.wait 進行了哪些操作不是很清晰,在不同情況下的操作流也並不直觀。

使用 sync.Once

我們來嘗試一下使用 sync.Once 方案:

package main

type CacheEntry struct {
    data []byte
    once *sync.Once
}

type QueryClient struct {
    cache map[string]*CacheEntry
    mutex *sync.Mutex
}

func (c *QueryClient) DoQuery(name string) []byte {
    c.mutex.Lock()
    entry, found := c.cache[name]
    if !found {
        // 如果在快取中未找到,建立新的 entry
        entry = &CacheEntry{
            once: new(sync.Once),
        }
        c.cache[name] = entry
    }
    c.mutex.Unlock()

    // 現在,當我們呼叫 .Do 時,如果有一個正在同步進行的操作
    // 它將一直阻塞,直到完成(並填充 entry.data)
    // 或者如果操作之前已經完成過一次
    // 本次呼叫不會進行操作,也不會阻塞
    entry.once.Do(func() {
        resp, err := http.Get("https://upstream.api/?query=" + url.QueryEscape(name))
        // 為簡潔起見,省略了錯誤處理和 resp.Body.Close
        entry.data, err = ioutil.ReadAll(resp)
    })

    return entry.data
}

以上就是 sync.Once 的方案,和之前的示例很相似,但現在更容易理解(至少在我看來)。只有一個返回值,且程式碼自上而下,非常直觀,而不必像之前一樣對 entry.wait channel 進行閱讀和理解。

進一步閱讀/其他注意事項

另一個類似於 sync.Once 的機制是 golang.org/x/sync/singleflightsingleflight 只會刪除正在進行中的請求中的重複請求(即不會持久化快取),但與 sync.Once 相比,singleflight 通過 context 實現起來可能更簡潔(通過使用 selectctx.Done()),並且在生產環境中,可以通過 context 取消這一點很重要。singleflight 實現的模式和 sync.Once 十分接近,但如果 map 中存有值,則會提前返回。

ianlancetaylor 建議結合 context 使用 sync.Once,方式如下:

c := make(chan bool, 1)
go func() {
    once.Do(f)
    c <- true
}()
select {
case <-c:
case <-ctxt.Done():
    return
}
更多原創文章乾貨分享,請關注公眾號
  • Go sync.Once 的妙用
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章