在很多Golang开发者眼中,Singleflight是防止同一时间因大量热点数据的获取导致的缓存击穿的必备方法。在singleflight内部有很多设计的细节值得开发者深入研究,比如singleflight的实现原理,以及singleflight对panic和runtime.Goexit()的处理细节的不同。
下面作者将分享一下对Singleflight的源码感悟和理解,如果各位有不同见解和疑问请在下面留言即可。
核心数据结构
type call struct {
//核心的数据结构,用来阻止同一时间的协程访问同一个业务代码
wg sync.WaitGroup
//返回的业务数据
val interface{}
err error
//主要是来判断多少个协程通过cache直接拿到的数据
dups int
// DoChan方法会用到,会异步获取业务代码数据,但DoChan有一个很不好的地方,业务代码出现panic后会直接导致程序崩溃,无法捕获
chans []chan<- Result
}
// singflight的主要结构
type Group struct {
mu sync.Mutex // 保护 m 的原子性
m map[string]*call // 这里设计核心的数据结构体,singleflight将每个key分配一个call来单独对每一个call进行操作来实现在高频访问的时间段防止缓存击穿
}
//与DoChan方法相关
type Result struct {
Val interface{}
Err error
Shared bool
}
Do 方法
// 返回值分别代码,业务代码的value,以及业务代码的error,和一个boolean值,如果为false代表没有协程共享这次执行结果。
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) {
//原子性访问临界区m
g.mu.Lock()
if g.m == nil {
//用于第一次使用Do方法会初始化m
g.m = make(map[string]*call)
}
//如果相同的key存在,说明在此时间段已经有协程正在执行相同的业务代码,其他协程会直接等待正在执行业务代码的协程的执行结果并获取,这是业务的核心所在。
if c, ok := g.m[key]; ok {
c.dups++
//这里g.mu.Unlock()和c.wg.Wait()和c.wg.Add()值得思考
g.mu.Unlock()
c.wg.Wait()
//singleflight会区分panic和使用runtime.Goexit()进行协程的退出
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)
c.wg.Add(1)
g.m[key] = c
g.mu.Unlock()
g.doCall(c, key, fn)
return c.val, c.err, c.dups > 0
}
DoChan 方法
func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result {
ch := make(chan Result, 1)
g.mu.Lock()
if g.m == nil {
g.m = make(map[string]*call)
}
if c, ok := g.m[key]; ok {
c.dups++
c.chans = append(c.chans, ch)
g.mu.Unlock()
return ch
}
c := &call{chans: []chan<- Result{ch}}
c.wg.Add(1)
g.m[key] = c
g.mu.Unlock()
go g.doCall(c, key, fn)
return ch
}
dochan方法会直接返回一个chan来实现异步获取结果,当其他相同协程进入DoChan时,和Do不同的是,DoChan会直接返回一个Chan来实现不阻塞当前协程
doCall方法
func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
normalReturn := false
recovered := false
defer func() {
if !normalReturn && !recovered {
c.err = errGoexit
}
g.mu.Lock()
defer g.mu.Unlock()
c.wg.Done()
if g.m[key] == c {
delete(g.m, key)
}
if e, ok := c.err.(*panicError); ok {
//这里对应Do和DoChan的两种面对panic的不同处理模式,在对于DoChan方法时,如果业务程序出现panic,则在doCall内部捕获并开启一个新的协程去panic,这会强制让程序进行退出,因为此panic无法被recover。一般对于web应用来讲,框架都会内置recover中间件来防止因panic导致的程序退出,但是singleflight的这个设计违背了这一原则。
if len(c.chans) > 0 {
go panic(e)
select {} // Keep this goroutine around so that it will appear in the crash dump.
} else {
panic(e)
}
} else if c.err == errGoexit {
} else {
//在doChan模式,当业务协程执行完后,会把结果通过chan反馈给其他协程达到共享的目的
for _, ch := range c.chans {
ch <- Result{c.val, c.err, c.dups > 0}
}
}
}()
func() {
defer func() {
if !normalReturn {
// 在Golang中调用 Goexit 后,只有当前的 goroutine 会被终止,其他的 goroutine 不会受到影响。Goexit 在终止 goroutine 之前,会执行所有已定义的 defer 语句。因为 Goexit 不是一个 panic,所以在这些 defer 函数中的 recover 调用会返回 nil,也就是它不会阻止当前 goroutine 的终止。
if r := recover(); r != nil {
c.err = newPanicError(r)
}
}
}()
//如果是Do方法,则通过这两个变量来共享最终的数据
c.val, c.err = fn()
normalReturn = true
}()
if !normalReturn {
recovered = true
}
}
Forget方法
func (g *Group) Forget(key string) {
g.mu.Lock()
delete(g.m, key)
g.mu.Unlock()
}
这个方法一般不会启用(在作者看来有一点副作用了),目前没有什么特殊的业务场景会遇到这种情况。Forget方法会强制删除map里面的key,从而在下一次相同key来到时,会又增加一次真正的业务处理(在绝大多数场景下使用Forget都是不合理的)
以下为作者博客,不定时分享优秀文章,包括区块链、大数据等相关内容。