Go—临时对象池 sync.Pool

2,896 阅读3分钟

最近在工作中遇到了 sync.Pool,当时看的一知半解,大致觉得是一个内存池来的,后面想仔细看一下sync.Pool是怎么使用的,但是一看源码就懵逼了,基本看不懂逻辑。因为sync.Pool的源码涉及到 go 的调度的知识,如果不清楚 go 的调度,看不懂是正常的。所以建议先去了解一下 go 的调度

是什么

pool如果从名字上看,就是池的意思,但是用池来形容不够恰当,应该称为cache,因为 Pool 里装的对象可以被无通知地被回收,所以 pool 不适合用来当 socket 连接池

有什么用

首先看源码的注释

// Pool's purpose is to cache allocated but unused items for later reuse,
// relieving pressure on the garbage collector. That is, it makes it easy to
// build efficient, thread-safe free lists. However, it is not suitable for all
// free lists.

对于很多需要重复分配,回收内存的地方,sync.Pool是个很好的解决方案,因为频繁地分配、回收内存会给 GC 带来一定的压力,而 sync.Pool 可以将暂时不用的对象缓存起来,待下次需要的时候直接使用,不用再次经过内存分配,复用对象的内存,减轻 GC 的压力,提升系统的性能。

怎么用

先来看个例子

package main

import(
    "fmt"
    "sync"
)

func main() {
    // 创建一个缓存int对象的一个pool
    p := &sync.Pool{
        New: func() interface{} {
            return 0
        },
    }

    a := p.Get().(int)
    p.Put(1)
    b := p.Get().(int)
    fmt.Println(a, b)
}

输出的结果是 0 1,创建的时候可以指定一个New函数,获取对象的时候如何在池里面找不到缓存的对象将会使用指定的 new 函数创建一个返回,如果没有new函数则返回nil,使用的方法很简单。

阅读源码的注释之后,有几个点需要关注的:

  1. get() 的数据和 put() 进去的数据是没有关系的

  2. pool 是线程安全的

  3. 缓存池的大小是不受限制的(受限于内存)

  4. pool 包在初始化的时候注册了 poolCleanup 函数,它会清除所有的 pool 里面的所有缓存的对象,该函数注册进去之后会在每次 gc 之前都会调用,因此sync.Pool 缓存的期限只是两次 gc 之间这段时间

    func init() {
        runtime_registerPoolCleanup(poolCleanup)
    }
    

缓存对象的开销

如何在多个 goroutine 之间使用同一个 pool 做到高效呢?官方的做法就是尽量减少竞争,因为 sync.pool 为每个P都分配了一个子池。如下所示

当执行 putget 操作的时候,都会把 goroutine 固定在某个 P 的子池上,然后对其进行操作。私有对象只有当前 P 使用,不用加锁,共享列表对象的是与其他 P 分享的,所以需要加锁

get的过程:

  1. 固定在某个 P 的子池上,尝试从私有对象获取,如果私有对象非空则返回获取的值,然后把私有对象置空
  2. 如果私有对象为空,则去共享对象列表获取(需要加锁)
  3. 如果共享对象列表为空,那么去其他 P 的共享对象列表一个(需要加锁)
  4. 如果其他 P 的共享对象列表也为空,那么就 new 一个出来

可以看到一次 get 操作最少 0 次加锁,最大 N(N等于MAXPROCS)次加锁。

put 的过程

  1. 固定到某个 P,如果私有对象为空则放到私有对象;

  2. 否则加入到该 P 子池的共享列表中(需要加锁)。

可以看到一次put操作最少0次加锁,最多1次加锁。

总的来说,pool 虽然减少了 gc 的负担,但是其开销也是不小的