『每周譯Go』Go sync.Once 的妙用
如果你曾用過 Go 中的 goroutines,你也許會遇到幾個并發(fā)原語,如 sync.Mutex, sync.WaitGroup 或是 sync.Map,但是你聽說過 sync.Once 么?
也許你聽說過,那 go 文檔是怎么描述它的呢?
Once 是只執(zhí)行一個操作的對象。
聽起來很簡單,它有什么用處呢?
由于某些原因,sync.Once 的用法并沒有很好的文檔記錄。在第一個.Do中的操作執(zhí)行完成前,將一直處于等待狀態(tài),這使得在執(zhí)行較昂貴的操作(通常緩存在 map 中)時非常有用。
原生緩存方式
假設(shè)你有一個熱門的網(wǎng)站,但它的后端 API 訪問不是很快,因此你決定將 API 結(jié)果通過 map 緩存在內(nèi)存中。以下是一個基本的解決方案:
package main
type QueryClient struct {
cache map[string][]byte
mutex *sync.Mutex
}
func (c *QueryClient) DoQuery(name string) []byte {
// 檢查結(jié)果是否已緩存
c.mutex.Lock()
if cached, found := c.cache[name]; found {
c.mutex.Unlock()
return cached, nil
}
c.mutex.Unlock()
// 如果未緩存則發(fā)出請求
resp, err := http.Get("https://upstream.api/?query=" + url.QueryEscape(name))
// 為簡潔起見,省略了錯誤處理和 resp.Body.Close
result, err := ioutil.ReadAll(resp)
// 將結(jié)果存儲在緩存中
c.mutex.Lock()
c.cache[name] = result
c.mutex.Unlock()
return result
}
看起來不錯,對吧?
然而,如果有兩個 DoQuery 同時進(jìn)行調(diào)用會發(fā)生什么呢?競爭。兩方緩存都無法命中,并且都會向 upstream.api 執(zhí)行不必要的 HTTP 請求,而只有一個需要完成這個請求。
不美觀但更好的緩存方式 我并沒有進(jìn)行統(tǒng)計(jì),但我認(rèn)為大家解決這個問題的另外一種方式是使用 channel、context 或 mutex。在這個例子中,可以將上文代碼調(diào)整為:
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()
// 如果未緩存,則發(fā)出請求
resp, err := http.Get("https://upstream.api/?query=" + url.QueryEscape(name))
// 為簡潔起見,省略了錯誤處理和 resp.Body.Close
entry.data, err = ioutil.ReadAll(resp)
// 關(guān)閉 channel,傳遞操作完成信號
// 立即返回
close(entry.wait)
return entry.data
}
這種方案不錯,但代碼的可讀性受到了很大影響。cached.wait 進(jìn)行了哪些操作不是很清晰,在不同情況下的操作流也并不直觀。
使用 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 {
// 如果在緩存中未找到,創(chuàng)建新的 entry
entry = &CacheEntry{
once: new(sync.Once),
}
c.cache[name] = entry
}
c.mutex.Unlock()
// 現(xiàn)在,當(dāng)我們調(diào)用 .Do 時,如果有一個正在同步進(jìn)行的操作
// 它將一直阻塞,直到完成(并填充 entry.data)
// 或者如果操作之前已經(jīng)完成過一次
// 本次調(diào)用不會進(jìn)行操作,也不會阻塞
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 的方案,和之前的示例很相似,但現(xiàn)在更容易理解(至少在我看來)。只有一個返回值,且代碼自上而下,非常直觀,而不必像之前一樣對 entry.wait channel 進(jìn)行閱讀和理解。
進(jìn)一步閱讀/其他注意事項(xiàng)
另一個類似于 sync.Once 的機(jī)制是 golang.org/x/sync/singleflight。singleflight 只會刪除正在進(jìn)行中的請求中的重復(fù)請求(即不會持久化緩存),但與 sync.Once 相比,singleflight 通過 context 實(shí)現(xiàn)起來可能更簡潔(通過使用 select 和 ctx.Done()),并且在生產(chǎn)環(huán)境中,可以通過 context 取消這一點(diǎn)很重要。singleflight 實(shí)現(xiàn)的模式和 sync.Once 十分接近,但如果 map 中存有值,則會提前返回。
ianlancetaylor 建議結(jié)合 context 使用 sync.Once,方式如下:
c := make(chan bool, 1)
go func() {
once.Do(f)
c <- true
}()
select {
case <-c:
case <-ctxt.Done():
return
}
● 原文地址:https://blog.chuie.io/posts/synconce/
● 原文作者:Jason Chu
●本文永久鏈接:https://github.com/gocn/translator/blob/master/2021/w34_the_underutilized_usefulness_of_sync_Once.md
●譯者:張宇
●校對:Cluas
想要了解關(guān)于 Go 的更多資訊,還可以通過掃描的方式,進(jìn)群一起探討哦~
