如果读者看过 Go 标准库中 RPC 的源码,就知道 RPC 中将 Request 和 Response 结构通过一个带锁链表进行对象复用,构造一个对象池,以避免 GC 造成的性能代价。下面是概括性的源码:
type Request struct {
ServiceMethod string
Seq uint64
next *Request
}
type Server struct {
reqLock sync.Mutex
freeReq *Request
}
func (server *Server) getRequest() *Request {
server.reqLock.Lock()
req := server.freeReq
if req == nil {
req = new(Request)
} else {
server.freeReq = req.next
*req = Request{}
}
server.reqLock.Unlock()
return req
}
func (server *Server) freeRequest(req *Request) {
server.reqLock.Lock()
req.next = server.freeReq
server.freeReq = req
server.reqLock.Unlock()
}
可以看到,这个对象池就是一个用链表实现的对象栈,每次取对象就从中取头结点,如果头结点为空,则 new 一个,释放对象也就是把对象插入到链表的头部。
那么,这个超简易版的对象池性能如何呢,其实不难分析,性能是不错的,因为在压力平稳后,几乎不需要 new 对象,这样锁的临界区都是对链表节点指针的操作。
但是实际上,我在异步日志库项目中,使用这种对象池方法和使用 sync.Pool 来构建对象池,性能差距非常大,Benchmark 显示,sync.Pool 性能能优于带锁链表 10 倍以上。
太阳底下没有新鲜事,想要优化锁的代价,要么减小临界区,要么分散锁竞争,下面看下标准库中 sync.Pool 是如何做的。
- Go 版本:1.9.2
- 源码文件:
sync/pool.go
Usage
文档中提到,sync.Pool 是一个存储临时对象的池,如果 Pool 持有某个对象的唯一引用,那么这个对象随时可能被析构掉。下面来看下标准库中 fmt 是如何使用 sync.Pool 来暂存 buffer。
type buffer []byte
type pp struct {
buf buffer
//...
}
var ppFree = sync.Pool{
New: func() interface{} { return new(pp) },
}
func newPrinter() *pp {
p := ppFree.Get().(*pp)
// Do more work.
return p
}
func (p *pp) free() {
p.buf = p.buf[:0]
// Do more work.
ppFree.Put(p)
}
初始化 Pool 对象时传入的 New 参数就是新建需要暂存对象的初始化函数。关于 sync.Pool 的使用就不过多赘述了。
Struct
先把 Pool 涉及到的结构列出来:
type Pool struct {
noCopy noCopy
// 实际指向的是 [P]poolLocal
local unsafe.Pointer // 指针任意结构的指针,可以理解为 void*
localSize uintptr // typedef uint64 uintptr;
New func() interface{}
}
type poolLocal struct {
poolLocalInternal
// 字节对齐,避免 false sharing
// 关于 false sharing,后续有博客哟
pad [128 - unsafe.Sizeof(poolLocalInternal{}) % 128]byte
}
type poolLocalInternal struct {
// 只能由当前 P (P 是 Go 中的概念,理解为附着在线程上的 Goroutine 调度器) 使用
private interface{}
// 可以被其他 P 使用
shared []interface{}
Mutex
}
先大概说下 Pool 的整体结构,当我们新建一个 Pool 对象,实际真正存储数据的是 local 指针指向的 []poolLocal对象,更多的信息请往下阅读。
Get
源码如下(把 race 相关的去掉了):
func (p *Pool) Get() interface{} {
// l : *poolLocal
l := p.pin()
// 这里为什么可以不用锁呢?
// 因为 p.pin() 中将 goroutine 设为了不可抢占。
x := l.private
l.private = nil
// 允许抢占
runtime_procUnpin()
// 如果 private 为空
if x == nil {
l.Lock()
// 从 shared 中试着取一个
last := len(l.shared) - 1
if last >= 0 {
x = l.shared[last]
l.shared = l.shared[:last]
}
l.Unlock()
// 如果 shared 也没有可用对象了
if x == nil {
// 调用 getSlow(), 从其他 poolLocal.shared 中试着取一个。
x = p.getSlow()
}
}
// 分别在自己的 private,自己的 shared,别的 shared 中都没有取到。
if x == nil && p.New != nil {
// 只能 new 了
x = p.New()
}
return x
}
在 Struct 中可以看到每个 P 对应的 poolLocal 分别有 privated 和 shared,对应的 Get() 过程就是从: self_private -> self.shared -> other_shared 挨个试着获取对象的过程。下面来看下一些细节信息:
// pin 将当前 goroutine 固定到 P 上,禁止抢占,返回该 P 对应的 poolLocal。
func (p *Pool) pin() *poolLocal {
// pid 就是 P 的唯一 ID
pid := runtime_procPin()
// s 代表 []poolLocal 的 size
s := atomic.LoadUintptr(&p.localSize)
l := p.local
if uintptr(pid) < s {
// 说明空间已分配好,直接返回
return indexLocal(l, pid)
}
// 否则,可能需要make
return p.pinSlow()
}
func (p *Pool) pinSlow() *poolLocal {
// Retry
//...
// GC 相关,见下
if p.local == nil {
allPools = append(allPools, p)
}
// []poolLocal 的 size 就是 GOMAXPROCS
size := runtime.GOMAXPROCS(0)
local := make([]poolLocal, size)
atomic.StorePointer(&p.local, unsafe.Pointer(&local[0]))
atomic.StoreUintptr(&p.localSize, uintptr(size))
return &local[pid]
}
// func getSlow() 就是从 []poolLocal 中其他 poolLocal.shared 中尝试获取对象的过程。
Put
直接看源码:
func (p *Pool) Put(x interface{}) {
if x == nil {
return
}
l := p.pin()
if l.private == nil {
// 这里不用锁的原因与 Get 一样
l.private = x
x = nil
}
runtime_procUnpin()
if x != nil {
l.Lock()
l.shared = append(l.shared, x)
l.Unlock()
}
}
相比于 Get,Put 的代码就简单多了,对于 Put 而言,也是先取出该 goroutine 所在 P 对应的 poolLocal,如果 poolLocal.private == nil,则赋值为 x,否则放到 shared 中。
GC
上问提到,当 Pool 持有对象的唯一引用时,这个对象随时可能被析构掉,那么具体何时呢?
// 当 stop the world 来临,在 GC 之前会调用该函数
func poolCleanup() {
for i, p := range allPools {
// 置 allPools 所有成员为 nil
// ..
}
allPools = []*Pool{}
}
func init() {
runtime_registerPoolCleanup(poolCleanup)
}
可以看到,sync.Pool 实际上是每次 GC 来临都会清空掉所有的 Pool 以及在 Pool 中的对象。