sync.Pool between 1.12 and 1.13

​ sync提供强大的‘对象池’,可以重用对象为了减少GC的压力。在使用此包之前,对于你的程序有个性能测试是一个前提。并使用完pool之后,如果你不知道它内部的工作原理,可能会降低你程序的性能。

​ 让我们从一个小例子开始。

package pool_test

import (
	"sync"
	"testing"
)

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}
			inc(s)
		}
	}
}

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
			inc(s)
			pool.Put(s)
		}
	}
}

benchmark结果 TODO ``

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%

结果显示:没有使用sync.Pool的内存分配和使用了的对比是 10k vs 3,可见,效果还是挺明显的。

但是当你使用sync.Pool时,会有大量的对象分配到堆上,以便复用。但当内存上涨,将会触发垃圾回收GC。我们也可以使用runtime.GC()来假装触发了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%

我们可以看到,使用sync.Pool的性能更低。内存分配和内存使用数量飙高。让我们往更深的代码瞧瞧。一探究竟。

sync.Pool 内部工作流程

在pool初始化init函数中注册了个cleanup函数钩子,并标明在GC之前进行清理pool中的对象。

// sync/pool.go
func init() {
   runtime_registerPoolCleanup(poolCleanup)
}

// runtime/mgc.go
func gcStart(trigger gcTrigger) {
   [...]
   // clearpools before we start the GC
   clearpools()
   [...]
}

这就能解释的通为什么在触发GC时性能会特别差。并且官方文档已经给我们了警告。https://golang.org/pkg/sync/#Pool

任何存储在池子中的对象可能在没有任何通知的情况下被删除

下面是1.12 pool工作流

对于每个sync.Pool,go生成一个内部poolLocal附着在每个P上。这个内部的pool有两个属性:privateshared

private : 仅为P自己使用,push or pop,所以不需要锁

shared : 任何一个P都可以使用并且需要并发安全(协程安全,加锁)。

可以确定的是,pool不是一个本地缓存,它有可能被任意一个P和M使用。

在1.13版本中,go将优化访问共享变量,将引入解决GC和清理Pool的一个新缓存

新型Lock-free池和victim缓存,无锁池和牺牲者缓存

go 1.13 引入双向链表做为分享池并且删除了锁,并优化了共享访问。这是提升缓存效率的基础。下面是1.13 共享访问:

有了这个新链式池,每个P可以在其队列开头都有push 和 pop的功能,而共享访问将从尾部pop。队列的头部可以分配原来构体的两倍。并连接到队列的头部。初始大小为8,意味着扩容的大小为16,32 等等。

那么,锁就自然而然的移除掉了。剩余的代码可以依赖于原子操作。

考虑到新的缓存方式,新的策略就十分简单了。现在有两个缓存池:一个活跃的和一个归档的。当一个GC运行时,go将保持一每个池子的引用到池内部的一个新属性,在清理当前池中数据时,要拷贝当前持池到归档池中。


func poolCleanup() {
	// This function is called with the world stopped, at the beginning of a garbage collection.
	// It must not allocate and probably should not call any runtime functions.

	// Because the world is stopped, no pool user can be in a
	// pinned section (in effect, this has all Ps pinned).

	// 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
}

有了这个策略,应用将有不仅一次的GC生命周期去创建/收集新的item作为backup。在这个工作流程下,请求共享池之后 还要请求 牺牲者 缓存 。