乐趣区

关于golang:通过-SingleFlight-模式学习-Go-并发编程

最近接触到微服务框架go-zero,翻看了整个框架代码,发现构造清晰、代码简洁,所以决定浏览源码学习下,本次浏览的源码位于core/syncx/singleflight.go

go-zeroSingleFlight的作用是:将并发申请合并成一个申请,以缩小对上层服务的压力。

利用场景

  1. 查问缓存时,合并申请,晋升服务性能。
    假如有一个 IP 查问的服务,每次用户申请先在缓存中查问一个 IP 的归属地,如果缓存中有后果则间接返回,不存在则进行 IP 解析操作。

如上图所示,n 个用户申请查问同一个 IP(8.8.8.8)就会对应 n 个 Redis 的查问,在高并发场景下,如果能将 n 个 Redis 查问合并成一个 Redis 查问,那么性能必定会晋升很多,而 SingleFlight就是用来实现申请合并的,成果如下:

  1. 避免缓存击穿。

缓存击穿问题是指:在高并发的场景中,大量的申请同时查问一个 key,如果这个 key 正好过期生效了,就会导致大量的申请都打到数据库,导致数据库的连贯增多,负载回升。

通过 SingleFlight 能够将对同一个 Key 的并发申请进行合并,只让其中一个申请到数据库进行查问,其余申请共享同一个后果,能够很大水平晋升并发能力。

利用形式

间接上代码:

func main() {
  round := 10
  var wg sync.WaitGroup
  barrier := syncx.NewSingleFlight()
  wg.Add(round)
  for i := 0; i < round; i++ {go func() {defer wg.Done()
      // 启用 10 个协程模仿获取缓存操作
      val, err := barrier.Do("get_rand_int", func() (interface{}, error) {time.Sleep(time.Second)
        return rand.Int(), nil})
      if err != nil {fmt.Println(err)
      } else {fmt.Println(val)
      }
    }()}
  wg.Wait()}

以上代码,模仿 10 个协程申请 Redis 获取一个 key 的内容,代码很简略,就是执行 Do() 办法。其中,接管两个参数,第一个参数是获取资源的标识,能够是 redis 中缓存的 key,第二个参数就是一个匿名函数,封装好要做的业务逻辑。最终取得的后果如下:

5577006791947779410
5577006791947779410
5577006791947779410
5577006791947779410
5577006791947779410
5577006791947779410
5577006791947779410
5577006791947779410
5577006791947779410
5577006791947779410

从上看出,10 个协程都取得了同一个后果,也就是只有一个协程真正执行了 rand.Int() 获取了随机数,其余的协程都共享了这个后果。

源码解析

先看代码构造:

type (
  // 定义接口,有 2 个办法 Do 和 DoEx,其实逻辑是一样的,DoEx 多了一个标识,次要看 Do 的逻辑就够了
  SingleFlight interface {Do(key string, fn func() (interface{}, error)) (interface{}, error)
    DoEx(key string, fn func() (interface{}, error)) (interface{}, bool, error)
  }
  // 定义 call 的构造
  call struct {
    wg  sync.WaitGroup // 用于实现通过 1 个 call,其余 call 阻塞
    val interface{}    // 示意 call 操作的返回后果
    err error          // 示意 call 操作产生的谬误
  }
  // 总控构造,实现 SingleFlight 接口
  flightGroup struct {calls map[string]*call // 不同的 call 对应不同的 key
    lock  sync.Mutex       // 利用锁管制申请
  }
)

而后看最外围的 Do 办法 做了什么事件:

func (g *flightGroup) Do(key string, fn func() (interface{}, error)) (interface{}, error) {c, done := g.createCall(key)
  if done {return c.val, c.err}

  g.makeCall(c, key, fn)
  return c.val, c.err
}

代码很简洁,利用 g.createCall(key) 对 key 发动 call 申请(其实就是做一件事件),如果此时曾经有其余协程曾经在发动 call 申请就阻塞住(done 为 true 的状况),期待拿到后果后间接返回。如果 done 是 false,阐明以后协程是第一个发动 call 的协程,那么就执行 g.makeCall(c, key, fn) 真正地发动 call 申请(尔后的其余协程就阻塞在了g.createCall(key))。

从上图可知,其实要害就两步:

  1. 判断是第一个申请的协程(利用 map)
  2. 阻塞住其余所有协程(利用 sync.WaitGroup)

来看下 g.createCall(key) 如何实现的:

func (g *flightGroup) createCall(key string) (c *call, done bool) {g.lock.Lock()
  if c, ok := g.calls[key]; ok {g.lock.Unlock()
    c.wg.Wait()
    return c, true
  }

  c = new(call)
  c.wg.Add(1)
  g.calls[key] = c
  g.lock.Unlock()

  return c, false
}

先看第一步:判断是第一个申请的协程(利用 map)

g.lock.Lock()
if c, ok := g.calls[key]; ok {g.lock.Unlock()
  c.wg.Wait()
  return c, true
}

此处判断 map 中的 key 是否存在,如果曾经存在,阐明曾经有其余协程在申请了,以后这个协程只须要期待,期待是利用了 sync.WaitGroupWait()办法实现的,此处还是很奇妙的。要留神的是,map 在 Go 中是非并发平安的,所以须要加锁。

再看第二步:阻塞住其余所有协程(利用 sync.WaitGroup)

c = new(call)
c.wg.Add(1)
g.calls[key] = c

因为是第一个发动 call 的协程,所以须要 new 这个 call,而后将 wg.Add(1),这样就对应了下面的wg.Wait(),阻塞剩下的协程。随后将 new 的 call 放入 map 中,留神此时只是实现了初始化,并没有真正去执行 call 申请,真正的解决逻辑在 g.makeCall(c, key, fn) 中。

func (g *flightGroup) makeCall(c *call, key string, fn func() (interface{}, error)) {defer func() {g.lock.Lock()
    delete(g.calls, key)
    g.lock.Unlock()
    c.wg.Done()}()

  c.val, c.err = fn()}

这个办法中做的事件很简略,就是执行了传递的匿名函数fn()(也就是真正 call 申请要做的事件)。最初解决收尾的事件(通过 defer),也是分成两步:

  1. 删除 map 中的 key,使得下次发动申请能够获取新的值。
  2. 调用wg.Done(),让之前阻塞的协程全副取得后果并返回。

至此,SingleFlight 的外围代码就解析结束了,尽管代码不长,然而这个思维还是很棒的,能够在理论工作中借鉴。

总结

  • map 非并发平安,记得加锁。
  • 巧用 sync.WaitGroup 去实现 须要阻塞管制协程 的利用场景。
  • 通过匿名函数 fn 去封装传递具体业务逻辑,在调用 fn 的下层函数中去实现对立的逻辑解决。

我的项目地址

https://github.com/zeromicro/go-zero

欢送应用 go-zerostar 反对咱们!

微信交换群

关注『微服务实际 』公众号并点击 交换群 获取社区群二维码。

如果你有 go-zero 的应用心得文章,或者源码学习笔记,欢送通过公众号分割投稿!

退出移动版