临时对象池(sync.Pool)

sync.Pool是Go语言标准库中的一个同步工具。

介绍

sync.Pool类型可以被称为临时对象池,它的值可以被用来存储临时的对象。它属于结构体类型,在它的值被真正使用之后,就应该再被复制了。
临时对象,就是不需要持久使用的某一类值。这类值对于程序来说可有可无,但如果有的话明显更好。它们的创建和销毁可以在任何时候发生,并且完全不会影响到程序功能。同时,它们也应该是无需被区分的,其中的任何一个值都可以代替另一个。如果某类值完全符合上述条件,就可以把它们存储到临时对象池中。
可以把临时对象池当做针对某种数据的缓存来用,实际上,这可能就是最主要的用途。连接池好像也能用这个。

使用方式

sync.Pool类型只有两个方法:

  • Put,用于在当前的池中存放临时对象,它接受一个空接口类型的值
  • Get,用于从当前的池中获取临时对象,它返回一个空接口类型的值

Get方法可能会中当前的池中删除掉任何一个值,然后把这个值作为结果返回。如果此时当前的池中没有任何值,那么就会使用当前池的New字段创建一个新的值,并将其返回。

New字段
sync.Pool类型的New字段是一个创建临时对象的函数。它的类型是没有参数但是会返回一个空接口类型的函数。即:func() interface{}
这个函数是Get方法最后的获取到临时对象的手段。函数的结果不会被存入当前的临时对象池中,而是直接返回给Get方法的调用方。
这里的New字段的实际值需要在初始化临时对象池的时候就给定。否则,在Get方法调用它的时候就会得到nil。

fmt包中的临时对象

举个例子,标准库的fmt包就用到了sync.Pool类型。fmt包会创建一个用于缓存某类临时对象的sync.Pool类型的值,并赋值给ppFree变量。这类临时对象可以识别格式化和暂存需要打印的内容。下面是这部分的源码:

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

临时对象池ppFree的New字段在被调用的时候,就是执行一个new方法。new方法是分配内存空间,填充零值填充参数类型,并返回其指针。所以,这里会返回一个全新的pp类型值的指针,就是临时对象。这就保证了ppFree的Get方法总能返回一个包含需要打印内容的值。pp类型是fmt包中的私有类型,有很多实现了不同功能的方法。不过,这里的重点是,它的每一个值都是独立的、平等的和可重用的。
这些对象既不互相干扰,也不会受到外部状态的影响。由于fmt包真正使用这些零食对象之前,总是会先对其进行重置,所以并不在意取到的哪一个临时对象。这就是临时对象的平等新的具体体现。
另外,这些代码在使用完临时对象之后,都会先抹掉其中以缓冲的内容,然后再将它存放到ppFree中。这样就为重用这类临时对象做好了准备。
打执行打印函数,比如:fmt.Println、fmt.Printf等的时候,都使用了ppFree以及其中的临时对象。因此,在程序同时执行很多的打印函数调用的时候,ppFree可以及时的提供它缓存的临时对象,这样就加快了执行的速度。
当程序在一段时间内不再执行打印函数调用时,ppFree中的临时对象又能被及时的清理掉,以节省内存空间。在这个维度上,临时对象池也可以帮助程序实现可伸缩性。这就是它的最大价值。

自动清理机制

前面将了临时对象会在什么时候被创建。这里来讲讲临时对象会在什么时候被销毁。

池清理函数
sync包在被初始化的时候,会向G系统注册一个函数,这个函数的功能就是清楚所有已创建的临时对象池中的值。可以把这个函数称为池清理函数。注册之后,在每次即将执行垃圾回收时都会执行池清理函数。

池汇总列表
另外,在sync包中还有一个包级私有的全局变量。这个变量记录了程序中使用的所有临时对象池的汇总,它是元素类型为*sync.Pool的切片。可以称之为池汇总列表。通常,在一个临时对象池的Put方法或Get方法第一次被调用的时候,这个池就会被添加到池汇总列表中。这样,池清理函数总是能访问到所有正在被真正使用的临时对象池。

清理的过程
Go语言运行时系统中的垃圾回收器会在每次开始执行前先执行池清理函数。
池清理函数会遍历池汇总列表,对其中的每一个临时对象池,它都会先将池中所有的私有临时对象共享临时对象列表都置为nil,然后再把这个池中的所有本地池列表都销毁掉。
然后,池清理函数会把池汇总列表重置为空的切片。这样,这些池中存储的临时对象就全部被清除干净了。
最后,就是垃圾回收器了。如果临时对象池以外的代码再无对它们的引用,在稍后的垃圾回收过程中,这些临时对象就会被当做垃圾销毁掉,它们占用的内存空间也会被回收。

小结
上面的清理过程的说明中,有几个重点标出的词:私有临时对象、共享临时对象列表和本地池列表。这些会在下面展开。

临时对象池存储值的数据结构

在临时对象池中,有一个多层的数据结构。
这个数据结构的顶层,是本地池列表,它是一个数组。列表的长度总是与Go语言调度器中的P的数量相同。

这里提到了P,引申开来,再次说明一下G-P-M模型:

在Go语言调度器中的P是processor的缩写,它指的是一种可以承载若干个G、并且能够使这些G适时地与M进行对接,并得到真正运行的中介。
这里的G正式goroutine的缩写,而M则是machine的缩写,M是系统级的线程。正式因为P的存在,G和M才能够进行灵活、高效的配对,从而实现强大的并发编程模型。

P存在的一个很重要的原因是为了分散并发程序的执行压力,而让临时对象池中的本地池列表的长度与P的数量相同的主要原因也是分散压力。这里的压力包括存储和性能两个方面。
回到数据结构,在本地池列表中的每个本地池都包含了3个字段,或者说组件:

  • private : 存储私有临时对象的字段,空接口类型,存一个值
  • shared : 共享临时对象列表的字段,空接口的切片,存一组值
  • sync.Mutext : 嵌入式锁(Embedded lock),保护shared字段

具体的数据结构在源码中如下:

// Local per-P Pool appendix.
type poolLocalInternal struct {
    private interface{}   // Can be used only by the respective P.
    shared  []interface{} // Can be used by any P.
    Mutex                 // Protects shared.
}

每个本地池都对应着一个P。一个goroutine想要真正运行就必须先与某个P产生关联,所以一个正在运行的goroutine必然会关联着某个P。在程序调用临时对象池的Put方法或Get方法的时候,总会先试图从临时对象池的本地池列表中获取对应的本地池,依据的就是与当前goroutine关联的那个P的ID。就是说,有一个goroutine,就会有一个与之一一对应的本地池,一个临时对象池的Put方法或Get方法会根据它所在的goroutine关联到P,然后获取到那个对应的本地池。

临时对象池存取值的过程

Put方法,会先试图把新的临时对象存储到本地池的private字段中。这样在需要获取临时对象的时候,可以快速的拿到一个可用的值。只有当private字段已经存有某个值的时候,Put方法才会去访问本地池的shared字段进行存储。
Get方法,会先试图从本地池private字段出获取一个临时对象。只有当private字段的值为nil时,才会去访问本地池的shared字段获取对象。
shared字段,原则上可以被任何goroutine中的代码访问到,字段类型是切片,可以存放一组临时对象。
private字段,只可能被与之对应的P所关联的goroutine中的代码访问到,可以说它是P级私有的。字段里只能存放一个临时对象。
在访问本地池的shared字段时,由于shared字段是共享的,必须受到互斥锁的保护,这个锁正是在结构体中嵌入的Mutex。而访问本地池的private字段是,不需要保护,所以这个private字段的存在是为了提供运行效率的。
在回到Put方法个Get方法,Get方法只需要去访问与之对应的本地池,先试着往private里存,如果已经有了就存到shared里。而Get方法,在访问过对应的本地池的private和shared之后仍没有获取到任何对象,那么就会去访问临时对象池中的所有本地池,这些本地池都在本地池列表里。由于不是与之对应了本地池了,只能访问shared字段,尝试获取到一个对象。这一步也是可能无法获取到一个可用的临时对象的,可能是都被取走了,也可能是刚被大清洗过。还没有的话,就是最后一个手段了,调用对象池(sync.Pool)里New字段的函数创建一个新的临时对象。另外New字段是需要在初始化对象池的时候给定的,否则会返回nil,这样的话Get方法也只能返回nil了。

示例

本篇主要是一些概念,临时对象池的使用起来还是比较简单的。
下面的例子中,关于临时对象池使用的代码并不多,本身也并不需要多少。代码中花了很大的精力在构造一个读写缓冲区的数据结构,真正需要使用到临时对象的时候,往往有现成的对象可用,或是已经在别处定义好对象了。不过作为一个demo还是很完整的,值得参考:

package main

import (
    "io"
    "bytes"
    "fmt"
    "sync"
)

// 存放数据块缓冲区的临时对象
var bufPool sync.Pool

// 预定义定界符
const delimiter = '\n'

// 一个简易的数据库缓冲区的接口
type Buffer interface {
    Delimiter() byte                    // 获取数据块之间的定界符
    Write(contents string) (err error)  // 写入一个数据块
    Read() (contents string, err error) // 读取一个数据块
    Free()                              // 释放当前的缓冲区
}

// 实现一个上面定义的接口
type myBuffer struct {
    buf       bytes.Buffer
    delimiter byte
}

func (b *myBuffer) Delimiter() byte {
    return b.delimiter
}

func (b *myBuffer) Write (contents string) (err error) {
    if _, err = b.buf.WriteString(contents); err != nil {
        return
    }
    return b.buf.WriteByte(b.delimiter)
}

func (b *myBuffer) Read() (contents string, err error) {
    return b.buf.ReadString(b.delimiter)
}

func (b *myBuffer) Free() {
    bufPool.Put(b)
}

func init() {
    bufPool = sync.Pool{
        New: func() interface{} {
            return &myBuffer{delimiter: delimiter}
        },
    }
}

// 获取一个数据库缓冲区
func GetBuffer() Buffer {
    return bufPool.Get().(Buffer)
}

func main() {
    buf := GetBuffer()
    defer buf.Free()
    buf.Write("写入第一行,")
    buf.Write("接着写第二行。")
    fmt.Println("数据已经写入,准备把数据读出")
    for {
        block, err := buf.Read()
        if err != nil {
            if err == io.EOF {
                break
            }
            panic(fmt.Errorf("读取缓冲区时ERROR: %s", err))
        }
        fmt.Print(block)
    }
}

总结

sync.Pool类型是一个比较有用的同步工具,它的值被称为临时对象池。
临时对象池有一个New字段,在初始化的时候最好给定它,是一个用来创建临时对象的函数。临时对象池还有两个方法:Put和Get,分别用于向池中存放和获取临时对象。
还分析了临时对象池内部存储临时对象值的数据结构,正是因为有这样的一个数据结构的支撑,临时对象池才能够有效的分散存储压力性能压力。通过分析临时对象池存取值的过程,了解到Get方法对这个数据结构的妙用,使得其中的临时对象可以被高效的利用。
这样的内部结构和存取方式,让临时对象池成为了一个特点鲜明的同步工具。它存储的临时对象都应该是拥有较长生命周期的值,并且这些值不应该被某个goroutine中的代码长期持有和使用。