morestack与goroutine pool

时间:2022-05-06
本文章向大家介绍morestack与goroutine pool,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

o语言的goroutine初始栈大小只有2K,如果运行过程中调用链比较长,超过的这个大小的时候,栈会自动地扩张。这个时候会调用到一个函数runtime.morestack。开一个goroutine本身开销非常小,但是调用morestack进行扩栈的开销是比较大的。想想,如果函数的栈扩张了,有人引用原栈上的对象怎么办?所以morestack的时候,里面的对象都是需要调整位置的,指针都要重定位到新的栈。栈越大,涉及到需要调整的对象越多,调用morestack的时候开销也越大。

我们可以写一个简单的bench,这个函数递归调用是比较消耗栈空间的:

func f(n int) {

  var useStack [100]byte

  if n == 0 {

    return

  }

  _ = useStack[3]

  f(n - 1)

}

下面是对比测试:

func bench1() {

  var wg sync.WaitGroup

  for i := 0; i < benchCount; i++ {

    wg.Add(1)

    go func() {

      for j := 0; j < 15; j++ {

        f(2)

      }

      wg.Done()

    }()

    wg.Wait()

  }

}

func bench2() {

  var wg sync.WaitGroup

  for i := 0; i < benchCount; i++ {

    wg.Add(1)

    go func() {

      f(30)

      wg.Done()

    }()

    wg.Wait()

  }

}

两者工作量是一样的,但bench1不会触发runtime.morestack,bench2会触发,可以看到结果相差了一个数量级:

  1. bench1 used: 52480486 ns
  2. bench2 used: 559074503 ns

我们的项目里有发现这个问题,morestack的CPU时间几乎占到了10%。隔壁友商cockroach也发现这个问题。怎么解决呢?

有两个方向,一个是初始分配更大的初始栈,比如起goroutine后,先调用下面这个函数,将栈扩到8K:

// reserveStack reserves 8KB memory on the stack to avoid runtime.morestack.

func reserveStack(dummy bool) {

  var buf [8 << 10]byte

  // avoid compiler optimize the buf out.

  if dummy {

    for i := range buf {

      buf[i] = byte(i)

    }

  }

}

提前预留栈空间的方式,相比程序跑到后面栈不够了扩栈,开销低一些。cockroach是这个做法。实测时我发现morestak的开销并没有被消除,而是转移了。

所以我要说的是另一个方向,goroutine pool。

其实goroutine这么轻量的东西,其实本身做池意义并不大,随用随开,用完就扔,挺好的。然而在触发morestack的情况下,这个开销就有点高,在火焰图上是可以抓到的(go pprof不那么敏感)。采用pool之后,如果goroutine被扩栈了,再还到pool里面,下次拿出来时是一个已扩栈过的goroutine,因此可以避免morestack。

接下来说说这个goroutine pool该怎么写。

我希望接口是这个子的:

pool = New()  // 创建pool

pool.Go(func() {

    // do something

})

跟调用

go func() {

}()

效果是一模一样的,只不过pool.Go执行闭包函数以后,goroutine不是退出,而是放回到池子以,供下次再调用。

为此,我们要将goroutine抽象成一种资源,

func (pool *Pool) Go(f func()) {

     res := pool.get()

     res.run(f)

     // pool.put(res) 这里还不能归还,后面讲为什么

}

这个资源比较特殊,它是由一个channel和一个后台goroutine组成:

type res struct {

     ch chan

     pool *Pool

}

go func(r *res) {

   for work := r.ch {

       work()

       r.pool.put(res)

   }

}

run只需要往channel里投喂,后台的goroutine拿到work后就会执行它。

func (r *res) run(f) {

    r.ch <- f 

}

这里有个细节,执行完以后归还到goroutine pool,要留下work执行完以后做,因为res.run(f)是非阻塞的。

池子的实现是比较容易的,用链表就可以了,把res串起来,首尾结点,尾进头出。不过要注意线程安全,想优化可以把首尾结点的访问分开加锁,甚至用无锁队形来实现。

注意到,我没有为pool设计Close接口,为什么?这是有意为之的。那么,池子里的goroutine什么时候释放呢?设计成过一段时间不用自动回收。

从经验上看,只要涉及重用goroutine的代码,都有很大概率发生泄漏问题,尤其是调用close以及close的实现。分配出去的资源,它是属于池子呢,还是不属于池子呢?关闭的池子时候是等资源还回来呢?还是不等呢?如果归还资源的时候,池子已经关闭了呢?关闭的瞬间,跟正在读写池子的请求,如何处理好加锁呢?所以这是一个设计上的问题。

每次使用都打上最后使用的时间,多起一个回收的goroutine定期的扫描池子内的goroutine,如果很久没用过,就回收掉。这个回收的goroutine如果发现池子里一个goroutine都没了,那它自己也退出,非常干净,完全不会有泄漏。退出前要加个标记,如果池子又被使用了,重新创建回收goroutine起来工作。

完整的代码,可以看看这个PR里面。