[译]Go: 理解Sync.Pool的设计思想

原文: medium.com/a-journey-w…

这篇文章基于Go1.12和1.13,我们来看看这两个版本间sync/pool.go的革命性变化。

Sync包提供了强大的可被重复利用实例池,为了降低垃圾回收的压力。在使用这个包之前,需要将你的应用跑出使用pool之前与之后的benchmark数据,因为在一些情况下使用如果你不清楚pool内部原理的话,反而会让应用的性能下降。

pool的局限性

我们先来看看一些基础的例子,来看看他在一个相当简单情况下(分配1K内存)是如何工作的:

type Small struct {
   a int
}

var pool = sync.Pool{
   New: func() interface{} { return new(Small) },
}

//go:noinline
func inc(s *Small) { s.a++ }

func BenchmarkWithoutPool(b *testing.B) {
   var s *Small
   for i := 0; i < b.N; i++ {
      for j := 0; j < 10000; j++ {
         s = &Small{ a: 1, }
         b.StopTimer(); inc(s); b.StartTimer()
      }
   }
}

func BenchmarkWithPool(b *testing.B) {
   var s *Small
   for i := 0; i < b.N; i++ {
      for j := 0; j < 10000; j++ {
         s = pool.Get().(*Small)
         s.a = 1
         b.StopTimer(); inc(s); b.StartTimer()
         pool.Put(s)
      }
   }
}
复制代码

下面是两个benchmarks,一个是使用了 sync.pool 一个没有使用

name           time/op        alloc/op        allocs/op
WithoutPool-8  3.02ms ± 1%    160kB ± 0%      1.05kB ± 1%
WithPool-8     1.36ms ± 6%   1.05kB ± 0%        3.00 ± 0%
复制代码

由于这个遍历有10k的迭代,那个没有使用pool的benchmark显示在堆上创建了10k的内存分配,而使用了pool的只使用了3. 3个分配由pool进行的,但只有一个结构体的实例被分配到内存。到目前为止可以看到使用pool对于内存的处理以及内存消耗上面更加友善。

但是,在实际例子里面,当你使用pool,你的应用将会有很多新在堆上的内存分配。这种情况下,当内存升高了,就会触发垃圾回收。

我们可以强制垃圾回收的发生通过使用 runtime.GC() 来模拟这种情形

name           time/op        alloc/op        allocs/op
WithoutPool-8  993ms ± 1%    249kB ± 2%      10.9k ± 0%
WithPool-8     1.03s ± 4%    10.6MB ± 0%     31.0k ± 0%
复制代码

我们现在可以看到使用了pool的情况反而内存分配比不使用pool的时候高了。我们来深入地看一下这个包的源码来理解为什么会这样。

内部工作流

看一下 sync/pool.go 文件会给我们展示一个初始化函数,这个函数里面的内容能解释我们刚刚的情景:

func init() {
   runtime_registerPoolCleanup(poolCleanup)
}
复制代码

这里在运行时注册成了一个方法去清理pools。并且同样的方法在垃圾回收里面也会触发,在文件 runtime/mgc.go 里面

func gcStart(trigger gcTrigger) {
   [...]
   // clearpools before we start the GC
   clearpools()
复制代码

这就解释了为什么当调用垃圾回收时,性能会下降。pools在每次垃圾回收启动时都会被清理。这个 文档 其实已经有警告我们

Any item stored in the Pool may be removed automatically at any time without notification
复制代码

接下来让我们创建一个工作流来理解一下这里面是如何管理的

sync.Pool workflow in Go 1.12

我们创建的每一个 sync.Pool ,go都会生成一个内部池 poolLocal 连接着各个processer(GMP中的P)。这些内部的池由两个属性组成 privateshared 。前者只是他的所有者可以访问(push以及pop操作,也因此不需要锁),而`shared可以被任何processer读取并且是需要自己维持并发安全。而实际上,pool不是一个简单的本地缓存,他有可能在我们的程序中被用于任何的协程或者goroutines

Go的1.13版将改善对 shared 的访问,还将带来一个新的缓存,该缓存解决与垃圾回收器和清除池有关的问题。

新的无需锁pool和victim cache

Go 1.13版本使用了一个新的双向链表作为 shared pool ,去除了锁,提高了 shared 的访问效率。这个改造主要是为了提高缓存性能。这里是一个访问 shared 的流程

new shared pools in Go 1.13

在这个新的链式pool里面,每一个processpr都可以在链表的头进行push与pop,然后访问 shared 可以从链表的尾pop出子块。结构体的大小在扩容的时候会变成原来的两倍,然后结构体之间使用 next/prev 指针进行连接。结构体默认大小是可以放下8个子项。这意味着第二个结构体可以容纳16个子项,第三个是32个子项以此类推。同样地,我们现在不再需要锁,代码执行具有原子性。

关于新缓存,新策略非常简单。 现在有2组池:活动池和已归档池。 当垃圾收集器运行时,它将保留每个池对该池内新属性的引用,然后在清理当前池之前将池的集合复制到归档池中:

// Drop victim caches from all pools.
for _, p := range oldPools {
   p.victim = nil
   p.victimSize = 0
}

// Move primary cache to victim cache.
for _, p := range allPools {
   p.victim = p.local
   p.victimSize = p.localSize
   p.local = nil
   p.localSize = 0
}

// The pools with non-empty primary caches now have non-empty
// victim caches and no pools have primary caches.
oldPools, allPools = allPools, nil
复制代码

通过这种策略,由于受害者缓存,该应用程序现在将有一个更多的垃圾收集器周期来创建/收集带有备份的新项目。 在工作流中,将在共享池之后在过程结束时请求牺牲者缓存。