这篇文章基于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
,go都会生成一个内部池poolLocal
连接着各个processer(GMP中的P)。这些内部的池由两个属性组成private
和shared
。前者只是他的所有者可以访问(push以及pop操作,也因此不需要锁),而`shared可以被任何processer读取并且是需要自己维持并发安全。而实际上,pool不是一个简单的本地缓存,他有可能在我们的程序中被用于任何的协程或者goroutines
Go的1.13版将改善对shared
的访问,还将带来一个新的缓存,该缓存解决与垃圾回收器和清除池有关的问题。
新的无需锁pool和victim cache
Go 1.13版本使用了一个新的双向链表作为shared pool
,去除了锁,提高了shared
的访问效率。这个改造主要是为了提高缓存性能。这里是一个访问shared
的流程
在这个新的链式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
复制代码
通过这种策略,由于受害者缓存,该应用程序现在将有一个更多的垃圾收集器周期来创建/收集带有备份的新项目。 在工作流中,将在共享池之后在过程结束时请求牺牲者缓存。
有疑问加站长微信联系(非本文作者)