十二. go 高性能编程之 sync.Pool的复用对象

目录

  • 一. sync.Pool 基础
    • 使用示例
    • 性能测试
      • 1. struct 反序列化
      • 2. bytes.Buffer
  • 二. sync.Pool 在标准库中的应用
  • 三. 源码分析
      • 1. Put()添加方法
      • 2. Get()获取
      • 3. sync.Pool的GC垃圾回收
      • 4. 小总结

一. sync.Pool 基础

  1. 是Go1.3发布的一个特性,当项目中频繁的创建回收对象时,造成了GC的压力不必要的资源浪费,从而考虑使用sync.pool缓存对象,当需要是通过这个缓冲池获取
  2. sync.Pool 是可伸缩的,并发安全的,高负载时会动态扩容,大小受限于内存大小,存放在池中对象不活跃会被自动清理
  3. sync.Pool 使用时先通过New设置返回数据函数,该函数决定Pool中存储的什么数据,提供了Get, Put
  1. Get() 用于从对象池中获取对象,因为返回值时 interface{} 因此需要值类型转换
  2. Put() 则是在对象使用完之后放回对象池

使用示例

  1. 声明对象池
//1.现有Student结构体
type Student struct {
	Name   string
	Age    int32
	Remark [1024]byte
}

//2.声明对象池,通过对象池返回指定对象, 
//只需要实现 New 函数即可。对象池中没有对象时,将会调用 New 函数创建
var studentPool = sync.Pool{
    New: func() interface{} { 
        return new(Student) 
    },
}
  1. Get() 用于从对象池中获取对象,因为返回值是 interface{},因此需要类型转换。
  2. Put() 则是在对象使用完毕后,返回对象池
stu := studentPool.Get().(*Student)
json.Unmarshal(buf, stu)
studentPool.Put(stu)

性能测试

1. struct 反序列化

  1. 因为 Student 结构体内存占用较小,内存分配几乎不耗时间。而如果使用标准库 json 反序列化,内部利用了反射,效率是比较低的,占据了大部分时间,因此两种方式最终的执行时间几乎没什么变化。但是内存占用差了一个数量级,使用了 sync.Pool 后,内存占用仅为未使用的 234/5096 = 1/22,对 GC 的影响就很大了
func BenchmarkUnmarshal(b *testing.B) {
	for n := 0; n < b.N; n++ {
		stu := &Student{}
		json.Unmarshal(buf, stu)
	}
}

func BenchmarkUnmarshalWithPool(b *testing.B) {
	for n := 0; n < b.N; n++ {
		stu := studentPool.Get().(*Student)
		json.Unmarshal(buf, stu)
		studentPool.Put(stu)
	}
}

2. bytes.Buffer

  1. 创建了一个 bytes.Buffer 对象池,而且每次只执行一个简单的 Write 操作,存粹的内存搬运工,耗时几乎可以忽略。而内存分配和回收的耗时占比较多,因此对程序整体的性能影响更大
var bufferPool = sync.Pool{
	New: func() interface{} {
		return &bytes.Buffer{}
	},
}

var data = make([]byte, 10000)

func BenchmarkBufferWithPool(b *testing.B) {
	for n := 0; n < b.N; n++ {
		buf := bufferPool.Get().(*bytes.Buffer)
		buf.Write(data)
		buf.Reset()
		bufferPool.Put(buf)
	}
}

func BenchmarkBuffer(b *testing.B) {
	for n := 0; n < b.N; n++ {
		var buf bytes.Buffer
		buf.Write(data)
	}
}

二. sync.Pool 在标准库中的应用

  1. 例如fmt.Printf 的调用是非常频繁的,利用 sync.Pool 复用 pp 对象能够极大地提升性能,减少内存占用,同时降低 GC 压力
type pp struct {
    buf buffer
    ...
}

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

// newPrinter allocates a new pp struct or grabs a cached one.
func newPrinter() *pp {
	p := ppFree.Get().(*pp)
	p.panicking = false
	p.erroring = false
	p.wrapErrs = false
	p.fmt.init(&p.buf)
	return p
}

// free saves used pp structs in ppFree; avoids an allocation per invocation.
func (p *pp) free() {
	if cap(p.buf) > 64<<10 {
		return
	}

	p.buf = p.buf[:0]
	p.arg = nil
	p.value = reflect.Value{}
	p.wrappedErr = nil
	ppFree.Put(p)
}

func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) {
	p := newPrinter()
	p.doPrintf(format, a)
	n, err = w.Write(p.buf)
	p.free()
	return
}

// Printf formats according to a format specifier and writes to standard output.
// It returns the number of bytes written and any write error encountered.
func Printf(format string, a ...interface{}) (n int, err error) {
	return Fprintf(os.Stdout, format, a...)
}

三. 源码分析

  1. 了解sync.Pool得先 了解golang的调度模型MPG
  1. M:Machine,一个M直接关联了一个内核线程, 一个groutine最终是要放到M上执行的;
  2. P:processor,存储当前go协程运行的上下文环境(函数指针,堆栈地址及地址边界),会对自己的go协程队列做一些调度(把占用CPU时间较长的go协程暂停,运行后续的goroutine)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务;
  3. G:指的是Goroutine,其实本质上也是一种轻量级的线程,里面除了存放本goroutine信息外 还有与所在P的绑定等信息
  1. 先看一下sync.Pool结构体,在Pool结构体中提供了一个local 字段,该字段实际是[P]poolLocal类型,数据实际就是存储在这个字段上的,其中的P就是MPG模型中的P,sync.Pool为每个P(对应CPU)都分配一个本地池,当执行Get或者Put操作时,会先将goroutine和某个P的子池关联,再对该子池进行操作
type Pool struct {
	//实现了Locker接口的空结构体
	noCopy noCopy		
	//本地固定大小per-P池,实际类型为[P]poolLocal ,有多少个P数组就有多大,也就是每个P维护了一个本地的poolLocal。
	local     unsafe.Pointer 
	//本地数组的大小
	localSize uintptr        
	//上一周期的局部
	victim     unsafe.Pointer 
	//已使用的数组大小
	victimSize uintptr        
	// New 方法在 Get 失败的情况下,选择性的创建一个值,Get就会返回该值, 否则返回nil
	New func() interface{}
}
  1. sync.Pool结构体中提供的local类型实际是poolLocal,是对poolLocalInternal的封装,结合起来看一下这两个结构体
  1. 在poolLocalInternal中存在private私有对象属性与shared 共享对象列表属性,私有对象只能被特定的P访问,共享列表对象可以被任何P访问,在操作私有对象属性时因为同一时刻一个P只能执行一个goroutine,所以无需加锁,但是对共享列表对象进行操作时,因为可能有多个goroutine同时操作,所以需要加锁
  2. poolLocal 是对 poolLocalInternal 的封装,内部又提供了一个pad字段,目的是为了防止false sharing。cache使用中常见的一个问题是false sharing。当不同的线程同时读写同一cache line上不同数据时就可能发生false sharing。false sharing会导致多核处理器上严重的系统性能下降
type poolLocalInternal struct {
	//私有对象,只能被特定的P访问;因为同一时刻一个P只能执行一个goroutine,所以无需加锁
	private interface{} 	
	//共享对象列表,可以被任何P访问;对共享列表对象进行操作时,因为可能有多个goroutine同时操作,所以需要加锁。
	shared  poolChain   	
	// 自己可以从队列的头部存然后从头部取,而别的P可以从尾部取。
}

type poolLocal struct {
	poolLocalInternal
	// 防止在128 mod(缓存线大小)=0的广泛平台上进行false sharing错误共享。
	// cache使用中常见的一个问题是false sharing
	//当不同的线程同时读写同一cache line上不同数据时就可能发生false sharing
	// false sharing会导致多核处理器上严重的系统性能下降
	pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}

1. Put()添加方法

  1. 当创建Pool实例后需要调用Put()方法将数据存入池中,该方法中
  1. 先对数据进行校验判断是否非空,判断race.Enabled开关
  2. 调用p.pin()获取存储数据的poolLocal,并将当前goroutine绑定到P上,防止当前的p被其他的g抢占
  3. 判断poolLocal的private属性是否设置过私有值,如果没有直接将数据添加到poolLocal的private
  4. 如果poolLocal的private不为null说明设置了pool的私有值,执行l.shared.pushHead(x),将数据追加到poolLocal的shared共享列表的顶部
  5. 数据添加完毕执行runtime_procUnpin()取消 P 的不可抢占状态
func (p *Pool) Put(x interface{}) {
	//1.如果放入的值为空,直接返回
	if x == nil {
		return
	}
	// 2.资源开关判断
	if race.Enabled {
		if fastrand()%4 == 0 {
			//直接丢弃返回
			return
		}
		race.ReleaseMerge(poolRaceAddr(x))
		race.Disable()
	}
	//3.当前goroutine绑定到P上,防止当前的p被其他的g抢占
	l, _ := p.pin()
	//4.检查当前goroutine是否有设置对象池的私有值,如果没有设置,则将x赋值给其私有成员,并将x重置为nil
	if l.private == nil {
		l.private = x
		x = nil
	}
	//5.如果x不为nil,则证明私有成员可能有值,导致没有被成功设置到私有成员,
	//于是将该值追加到共享列表的顶部
	if x != nil {
		l.shared.pushHead(x)
	}
	//6.取消 P 的不可抢占,调用程序在处理完池后必须调用runtime_procumpin()
	runtime_procUnpin()
	if race.Enabled {
		race.Enable()
	}
}
  1. 内部的p.pin(): 将当前goroutine固定到 调度器P,禁用抢占并返回调度器P和P的id对应的poolLocal pool
func (p *Pool) pin() (*poolLocal, int) {
	//1.调用运行时,实现获取runtime的P的id,并设置禁止抢占(避免GC)
	pid := runtime_procPin()
	//2.加载 local 和 localSize,观察 是否对应(因为可能存在动态的 P)
	//在pinSlow中,我们先存储到local,然后存储到localSize,在这里我们按相反的顺序加载。
	s := atomic.LoadUintptr(&p.localSize) 
	l := p.local // 负荷消耗
	//3.如果 P.id 没有越界,则直接返回
	if uintptr(pid) < s {
		// 调用 indexLocal() 完成取值
		return indexLocal(l, pid), pid  
	}
	//4.否则如果P被调大,而没有对应的 poolLocal 时,需要进行创建,调用 p.pinSlow()实现
	return p.pinSlow()
}
  1. 内部p.pinSlow() 该方法主要进行全局加锁,重新分配数组内存,添加到全局列表
func (p *Pool) pinSlow() (*poolLocal, int) {
	//1.因为需要对全局进行加锁,pinSlow() 会首先取消 P 的不可抢占,然后使用 allPoolsMu 进行加锁
	runtime_procUnpin()
	allPoolsMu.Lock()
	defer allPoolsMu.Unlock()
	//2.当锁住后,再次固定 P 取其 id
	pid := runtime_procPin()
	//3.并再次检查是否符合条件,因为可能中途已被其他线程调用
	//当再次固定 P 时 poolCleanup 不会被调用
	s := p.localSize
	l := p.local
	if uintptr(pid) < s {
		return indexLocal(l, pid), pid
	}
	//4.如果数组为空,新建。将其添加到 allPools,垃圾回收器从这里获取所有 Pool 实例
	if p.local == nil {
		allPools = append(allPools, p)
	}
	size := runtime.GOMAXPROCS(0)		// P 数量
	//5.根据 P 数量创建 slice,如果 GOMAXPROCS 在 GC 间发生变化,重新分配此数组并丢弃旧的
	local := make([]poolLocal, size)
	atomic.StorePointer(&p.local, unsafe.Pointer(&local[0])) // 存储释放
	atomic.StoreUintptr(&p.localSize, uintptr(size))         // 存储释放
	return &local[pid], pid
}

var (
	// 因为需要对全局进行加锁,pinSlow() 会首先取消 P 的不可抢占,然后使用 allPoolsMu 进行加锁:
	allPoolsMu Mutex		// 全局锁,来保护数据域的可靠性
	allPools []*Pool		//全局变量,持有了所有的Pool

	// 是一组可能具有非空 幸存者 缓存的池
	oldPools []*Pool
)

2. Get()获取

  1. 该方法主要实现在池中选择任意项,将其从池中移除,并将其返回给调用者
func (p *Pool) Get() interface{} {
	//1.检测数据
	if race.Enabled {
		race.Disable()
	}
	//2.尝试从本地P对应的那个本地池private 中获取一个对象值
	l, pid := p.pin()
	x := l.private
	//3.并从本地池private 中 删除该 对象值。
	l.private = nil
	if x == nil {
		//4.如果 private 是空的,那么从共享池shared队列中 从头获取, 并从共享队列中删除该值
		x, _ = l.shared.popHead()
		if x == nil {
			//5.如果本地的 shared 也没有,那么从其他P的共享池中偷一个过来,并删除共享池中的该值
			x = p.getSlow(pid)
		}
	}
	runtime_procUnpin()
	if race.Enabled {
		race.Enable()
		if x != nil {
			race.Acquire(poolRaceAddr(x))
		}
	}
	//6.如果仍然失败,那么直接通过New()创建一个
	//注意这个分配的值不会被放入池中。New()返回用户注册的New函数的值,如果用户未注册New,那么返回nil
	if x == nil && p.New != nil {
		x = p.New()
	}
	return x
}
  1. 内部的p.getSlow(pid), 就是偷steal,当前localPool中没有缓存的对象时就不能从 private 或 shared获取、为了提高性能可以 从别的P那里偷一个这样比创建一个新的要快
func (p *Pool) getSlow(pid int) interface{} {
	//1.获取当前 P.id
	size := atomic.LoadUintptr(&p.localSize) // 加载获取
	locals := p.local                        // 负荷消耗
	//2.尝试从其他进程中 的共享块中 窃取一个元素
	//假设 size 为数组的大小,local 为 p.local,那么尝试遍历其他所有 P
	for i := 0; i < int(size); i++ {
		//获取到其他 P 对应的 poolLocal,
		l := indexLocal(locals, (pid+i+1)%int(size)) // 引入 pid 保证不是自身
		//如果能够steal一个过来,就直接return
		if x, _ := l.shared.popTail(); x != nil {
			return x
		}
	}
	//3.如果 steal不到,则从 victim 里找,和 poolLocal 的逻辑类似
	size = atomic.LoadUintptr(&p.victimSize)
	//4.如果取到的 值越界,直接返回空
	if uintptr(pid) >= size {
		return nil
	}
	locals = p.victim
	l := indexLocal(locals, pid)
	if x := l.private; x != nil {
		l.private = nil
		return x
	}
	for i := 0; i < int(size); i++ {
		l := indexLocal(locals, (pid+i)%int(size))
		if x, _ := l.shared.popTail(); x != nil {
			return x
		}
	}
	//最后,实在没找到,就把 victimSize 置 0,防止后来的“人”再到 victim 里找。
	atomic.StoreUintptr(&p.victimSize, 0)
	return nil
}

3. sync.Pool的GC垃圾回收

  1. 看一下sync.pool.go这个文件,该文件中提供了一个init()函数,该函数中调用了registerPoolCleanup()就是垃圾回收相关的
func init() {
	// 注册了一个PoolCleanup函数
	// 清除掉sync.Pool中的所有的缓存的对象,这个注册函数会在每次GC的时候运行,所以sync.Pool中的值只在两次GC中间的时段有效
	runtime_registerPoolCleanup(poolCleanup)
}
func runtime_registerPoolCleanup(cleanup func())
  1. 继续向下追,在registerPoolCleanup()中调用了poolCleanup(),实际就是 将所有的对象置为 nil,等着GC做自动回收
func poolCleanup() {
	// 该函数会注册到运行时 GC 阶段(前),此时为 STW 状态,不需要加锁
	// 它必须不处理分配且不调用任何运行时函数,防御性的将一切归零,有以下两点原因:
	// 1. 防止整个 Pool 的 false retention
	// 2. 如果 GC 发生在当有 goroutine 与 l.shared 进行 Put/Get 时,它会保留整个 Pool.
	//   那么下个 GC 周期的内存消耗将会翻倍。
	// 遍历所有 Pool 实例,接触相关引用,交由 GC 进行回收
	for _, p := range oldPools {
		p.victim = nil
		p.victimSize = 0
	}

	// 把主要的缓存移至 victim,防止GC 把所有的 Pool 都清空了
	for _, p := range allPools {
		p.victim = p.local
		p.victimSize = p.localSize
		p.local = nil
		p.localSize = 0
	}

	// 具有非空 的主要缓存的池 ,现在具有非空的victim受害者缓存,并且没有池具有主要的 缓存
	oldPools, allPools = allPools, nil
}

4. 小总结

  1. sync.Pool 的设计初衷是对象的复用,避免重复创建、销毁。将暂时不用的对象缓存起来,待下次需要的时候直接使用,不用再次经过内存分配,复用对象的内存,减轻 GC 的压力
  2. sync.Pool的应用场景:第一是当我们必须重用 共享和长期存在的对象时,第二是用于优化内存分配。使用顺序是 设置好 New 函数后,调用 Get 获取,调用 Put 归还对象
  3. 考虑清楚复用对象的生命周期,在使用时,不要对 Get 得到的对象有任何假设,默认Get到对象是一个空对象,Get之后手动初始化。
  4. 好的实践是:Put操作执行前将对象“清空”,并且确保对象被Put进去之后不要有任何的指针引用再次使用,不然极大概率导致data race。
  5. Pool 里对象的生命周期受 GC 影响,不适合于做连接池,因为连接池需要自己管理对象的生命周期
  6. 在加入 victim 机制前,sync.Pool 里对象的最⼤缓存时间是一个 GC 周期,当 GC 开始时,没有被引⽤的对象都会被清理掉;加入 victim 机制后,最大缓存时间为两个 GC 周期

你可能感兴趣的:(#,四.,Go,高性能编程相关问题,#,五.,Go,并发安全相关,golang,开发语言,后端)