如果您曾经在 Go 中使用过 goroutine,那么您可能会遇到几个并发原语。也许 sync.Mutex , sync.WaitGroup 也许 sync.Map ,但你听说过 sync.Once 吗?
也许你有,但godoc 怎么说呢?
听起来很简单,那么它有什么用呢?
好吧,出于某种原因,这不是特别有据可查的,但是 a sync.Once 会 等到 第一个内部的执行 .Do 完成。这在执行通常会缓存在map中的相对昂贵的操作时非常有用。
原始缓存
例如,假设您有一个热门网站,该网站访问的后端 API 不是特别快,因此您决定使用map将 API 结果缓存在内存中。一个简单的解决方案可能如下所示:
package main
type QueryClient struct {
cache map[string][]byte
mutex *sync.Mutex
}
func (c *QueryClient) DoQuery(name string) []byte {
// Check if the result is already cached.
c.mutex.Lock()
if cached, found := c.cache[name]; found {
c.mutex.Unlock()
return cached, nil
}
c.mutex.Unlock()
// Make the request if it's uncached.
resp, err := http.Get("#34; + url.QueryEscape(name))
// Error handling and resp.Body.Close omitted for brevity.
result, err := ioutil.ReadAll(resp)
// Store the result in the cache.
c.mutex.Lock()
c.cache[name] = result
c.mutex.Unlock()
return result
}
看起来不错,对吧?
那么如果有两个调用 DoQuery 同时发生会发生什么?调用会竞争,两者都不会看到缓存被填充,并且两者都会 upstream.api 不必要地执行 HTTP 请求,而此时只有一个人需要完成它。
丑陋但更好的缓存
我没有这方面的统计数据,但我认为人们解决这个问题的一种方法是使用通道、上下文或互斥锁。例如你可以把它变成:
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 {
// Check if the operation has already been started.
c.mutex.Lock()
if cached, found := c.cache[name]; found {
c.mutex.Unlock()
// Wait for it to complete.
<-cached.wait
return cached.data, nil
}
entry := &CacheEntry{
data: result,
wait: make(chan struct{}),
}
c.cache[name] = entry
c.mutex.Unlock()
// Make the request if it's uncached.
resp, err := http.Get("#34; + url.QueryEscape(name))
// Error handling and resp.Body.Close omitted for brevity
entry.data, err = ioutil.ReadAll(resp)
// Signal that the operation is complete, receiving on closed channels
// returns immediately.
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 {
// Create a new entry if one does not exist already.
entry = &CacheEntry{
once: new(sync.Once),
}
c.cache[name] = entry
}
c.mutex.Unlock()
// Now when we invoke `.Do`, if there is an on-going simultaneous operation,
// it will block until it has completed (and `entry.data` is populated).
// Or if the operation has already completed once before,
// this call is a no-op and doesn't block.
entry.once.Do(func() {
resp, err := http.Get("#34; + url.QueryEscape(name))
// Error handling and resp.Body.Close omitted for brevity
entry.data, err = ioutil.ReadAll(resp)
})
return entry.data
}
就是这样。这与前面的示例实现相同,但现在更容易理解(至少在我看来)。只有一个返回,代码从上到下直观地流动,无需 entry.wait 像以前一样阅读和理解通道发生了什么。
其他注意事项
另一种类似的机制 sync.Once 是golang.org/x/sync/singleflight。但是, singleflight 仅对进行中的请求进行重复数据删除(即不持久缓存)。 singleflight 与 sync.Once (通过使用 a select 和 ctx.Done() )相比,使用上下文实现可能更清晰,在生产环境中,这对于能够使用上下文取消可能很重要。模式与 singleflight 非常相似, sync.Once 但如果map中存在值,会提前返回。
ianlancetaylor建议使用以下方式让 sync.Once 与上下文一起使用:
c := make(chan bool, 1)
go func() {
once.Do(f)
c <- true
}()
select {
case <-c:
case <-ctxt.Done():
return
}