singleflight是什么
singleflight是Go官方扩展同步包(golang.org/x/sync/singleflight)的一个库,主要用于并发控制。针对同一个key的多个请求,只需要处理一个,其余请求等待结果,以此抑制对下游的重复请求。
为什么需要singleflight
对于读请求量较大的后台服务,为降低存储层的压力,一般会实现缓存层。服务器在收到请求后,首先从缓存获取数据,若缓存未命中才会查询存储层。
若服务器在短时间内收到大量未命中缓存层的重复请求,这些请求会全部查询存储层,给存储层带来较大的压力,甚至有高负载的可能。
singleflight会对请求进行合并,相同key的请求只访问一次存储层,大大减少了对存储层的压力。
如何使用singleflight
三个方法
singleflight对外提供了3个方法:
- Do:在对同一个key多次调用时,若第一次的调用没有完成,只会执行一次fn(),其他调用会阻塞并等待首次调用返回。调用Do函数需要传入2个参数,key用于标识请求,重复请求的key是相同的;fn()为调用者需要实现的业务逻辑。返回值有3个,v和err为fn()的返回值,shared表示返回结果是否是共享的。
- DoChan:作用和Do类似, 只不过返回channel,其中Result结构体由Val、Err和Shared组成。和Do相比,就是同步和异步的区别。
- Forget:通知Group删除传入的key,这样后续调用此key时,请求不会阻塞。
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool)
func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result
func (g *Group) Forget(key string)
使用示例
下述demo模拟1000个请求同时获取相同的数据,即key的值相同。getData()抽象为访问存储层的函数,函数内部的Sleep用于模拟访问耗时;count记录调用getData()函数的次数。
当没有使用singleflight时,输出结果为total num is 1000,表示每个请求都调用了getData()函数;当使用singleflight时,输出结果为total num is 1,表示只有1个请求。
var count int32
func main() {
total := 1000
sg := &singleflight.Group{}
var wg sync.WaitGroup
wg.Add(total)
key := "key"
for i := 0; i < total; i++ {
go func() {
defer wg.Done()
sg.Do(key, func() (interface{}, error) {
res, err := getData(key)
return res, err
})
// getData(key)
}()
}
wg.Wait()
fmt.Printf("total num is %v\n", count)
}
func getData(key string) (interface{}, error) {
atomic.AddInt32(&count, 1)
time.Sleep(10 * time.Millisecond)
return "result", nil
}
源码分析
本文基于https://pkg.go.dev/golang.org/x/[email protected]/singleflight进行分析。
Group结构体
type Group struct {
mu sync.Mutex // protects m
m map[string]*call // lazily initialized
}
Group结构体由互斥锁和map组成,互斥锁用于保证map的并发安全;map的key为调用Do方法传入的key,call保存了当前调用对应的信息。
call结构体
type call struct {
wg sync.WaitGroup
val interface{}
err error
forgotten bool
dups int
chans []chan<- Result
}
val和err是调用fn()函数的返回值;forgetten用于表示Forget()函数是否被调用;dups用于统计调用次数;chans是调用DoChan()函数时返回的channel。
Do函数
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) {
g.mu.Lock()
// map懒加载
if g.m == nil {
g.m = make(map[string]*call)
}
// 若key已经存在,则阻塞并等待wg执行完毕。当wg执行完毕时,所有的wait都会被唤醒。
if c, ok := g.m[key]; ok {
c.dups++
g.mu.Unlock()
c.wg.Wait()
if e, ok := c.err.(*panicError); ok {
panic(e)
} else if c.err == errGoexit {
runtime.Goexit()
}
return c.val, c.err, true
}
c := new(call)
// 首次调用Do会Add 1,后续调用都会阻塞于wg.Wait()
c.wg.Add(1)
g.m[key] = c
g.mu.Unlock()
// 执行业务逻辑
g.doCall(c, key, fn)
return c.val, c.err, c.dups > 0
}
Do函数实现的主要逻辑为,若传入的key已经在map中,则阻塞于wg.Wait();若不在map中,则调用wg.Add(1)并执行业务逻辑。也就是说,同一个key的多个请求,只有首个请求会调用wg.Add(1),其余请求都会调用wg.Wait()并阻塞于此处。
对于阻塞在wg.Wait()的请求,在返回结果前,还区分了panic错误和runtime.Goexit()错误,这部分逻辑是在这个版本补充的,v0.0.0-20190423版本还没有这个逻辑。后续会介绍为什么需要这个逻辑。
doCall函数
doCall()函数的实现看上去较为复杂,而且大部分逻辑是在处理异常。为了更好地理解为什么需要处理这些异常,先介绍一下v0.0.0-20190423版本的doCall函数的实现。
v0.0.0-20190423版本的实现比较简单,执行fn()函数并调用wg.Done(),最后从map中删除key。
// v0.0.0-20190423版本的doCall
func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
c.val, c.err = fn()
c.wg.Done()
g.mu.Lock()
if !c.forgotten {
delete(g.m, key)
}
for _, ch := range c.chans {
ch <- Result{c.val, c.err, c.dups > 0}
}
g.mu.Unlock()
}
此版本存在如下问题:若fn()函数内部出现panic,则当前goroutine会立即停止执行,c.wg.Done()无法被调用,且key一直在map中无法被删除,导致相同key的其他请求全部阻塞于c.wg.Wait()。若业务逻辑在fn()外部有调用recover(),虽然程序不会直接panic,但最终可能会因为死锁而发生错误。
例如,启动2个协程调用group.Do函数,请求的key均为"same key"。fn()内部会panic,外部有recover(),因此该panic可以被recover()捕获。
func main() {
var wg sync.WaitGroup
// singleflight的版本为v0.0.0-20190423024810-112230192c58
group := &singleflight.Group{}
wg.Add(2)
go func() {
DoIt(&wg, group, 1)
}()
go func() {
DoIt(&wg, group, 2)
}()
wg.Wait()
}
func DoIt(wg *sync.WaitGroup, group *singleflight.Group, count int32) {
fmt.Printf("enter DoIt, count is %d\n", count)
defer wg.Done()
defer func() {
if rec := recover(); rec != nil {
//Recoverd panic
fmt.Printf("rec is %d,%v\n", count, rec)
}
}()
key := "same key"
value, err, shared := group.Do(key, func() (_ interface{}, err error) {
fmt.Printf("enter group.Do, count is %d\n", count)
time.Sleep(1000 * time.Millisecond)
panic("panic in singleflight")
})
fmt.Printf("count: %v, value: %v, err: %v, shared: %v\n", count, value, err, shared)
}
执行后,得到如下结果:
enter DoIt, count is 2
enter group.Do, count is 2
enter DoIt, count is 1
rec is 2,panic in singleflight
fatal error: all goroutines are asleep - deadlock!
从输出结果可以看出,DoIt函数被调用了2次,count为2的请求进入了group.Do函数,随后发生了panic,并被recover住。count为1的请求阻塞于c.wg.Wait()函数,主协程也阻塞于自身的wg.Wait()函数,随后进程因为发生死锁而退出。
新版本修复了此问题,将c.wg.Done()放入defer中执行,这样即使fn()中出现panic,c.wg.Done()也会被调用。此外,新版本还区分了panic错误和runtime.Goexit(),主要逻辑如下所示:
// 新版本的doCall
func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
normalReturn := false
recovered := false
defer func() {
// the given function invoked runtime.Goexit
if !normalReturn && !recovered {
c.err = errGoexit
}
c.wg.Done()
// 根据err类型执行对应的逻辑
// ……
}()
func() {
defer func() {
if !normalReturn {
// 若出现panic,则返回值不为nil
if r := recover(); r != nil {
c.err = newPanicError(r)
}
}
}()
c.val, c.err = fn()
normalReturn = true
}()
if !normalReturn {
// 若被赋值,说明fn()内部出现了panic,且panic被捕获
recovered = true
}
}
新引入的2个变量normalReturn和recovered用于判断fn()内部是出现了panic还是调用了runtime.Goexit()。
若fn()内部出现panic,当前goroutine会停止运行,并执行defer语句,且recover()的返回值不为nil。由于recover()捕获了panic错误,逻辑会继续向下执行,那么recovered会被赋值为true。因此,当fn()内部出现panic时,normalReturn为false,recovered为true。
若fn()内部调用runtime.Goexit(),当前goroutine会停止运行,并执行defer()语句,且recover()的返回值为nil,并且不会继续执行后续逻辑,因此recovered为false。综上,fn()内部调用runtime.Goexit()时,normalReturn为false,recovered为false。
区分这两类场景是为了让调用者感知调用结果。对于panic错误,因为group内部捕获了panic,所以需要重新抛出panic,这样业务侧才能知道fn()内部出现了异常;对于runtime.Goexit(),这是业务侧主动执行的结果,因此不需要额外处理。
Do函数为什么需要处理异常
之前提到新版本的Do()函数在调用c.wg.Wait()后和return之前,补充了对错误类型的判断。这是因为相同key的请求需要有相同的处理结果。若第一个请求出现了panic,则后续请求也应当panic;若第一个请求内部调用了runtime.Goexit(),则后续请求也需要调用runtime.Goexit()。
参考
一篇带给你Go并发编程Singleflight