Go: Singleflight源码剖析

149 阅读4分钟

在很多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都是不合理的)

以下为作者博客,不定时分享优秀文章,包括区块链、大数据等相关内容。