singleflight使用及原理

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

你可能感兴趣的:(singleflight使用及原理)