阅读 565

Go语言学习 - Sync.Pool

Introduction

直接说吧, 这个东西为什么存在, 为了解决什么问题:

假设我们需要频繁申请内存用于存放你的结构体, 而这个结构体本身是短命的, 可能这个请求过去你就不用了. 申请了这么多内存, 对于GC来说就是一种压力了. 针对这个问题, 如果我们能产生一个池子, 用于存放这些短命内存, 理想情况中下次请求来了, 直接从池子中拿就好了, 那么GC的时候我们直接清理池子就完事了, 算是一种GC的优化套路.

你天天用(于debug)的fmt就使用了这个东西, fmt总是需要很多[]byte对象, 但是用一次就申请一次内存显然是不现实的, 于是就整了个它, 每次需要[]byte就从ppFree池中拿一个出来

fmt.Println() 调用 ->
fmt.Fprintln() 调用 ->
fmt.newPrinter() 调用 ->
ppFree.Get() 其中 -> ppFree := sync.Pool{...}
复制代码

sync.Pool的组件

sync.Pool是全局的, 这个Pool的工作会跟所有的P打交道(GMP中的P). 尽管你可以针对不同场景申请不同的Pool, 比如我们可以为对象A的存取设置一个Pool, 再为对象B的存取设置一个Pool. 但是同一个Pool是不能被复制的, 我们在这里留下了几个问题:

  1. 为什么Pool是全局的, Pool是怎么跟P打交道的
  2. 为什么Pool不许被复制, 不许复制这个特性是怎么被保证的

我们先开看看它的组件, 先是顶层:

type Pool struct {
	noCopy     noCopy
	local      unsafe.Pointer 
	localSize  uintptr        
	New func() interface{}
}
复制代码
  • New:
    • Pool池子涉及"存"/"取"两个操作, 这个New函数是针对取的, 假设我们现在池子空了, 取的东西是什么呢? 取的就是New的返回值, 算是一个新的, 当然如果你不设置New函数, 空池取出来的就是nil
    • 写了一个小例子, 看看: go-playground
  • local/localSize:
    • 存/取, 存到哪儿? 从哪儿取? 刚刚说了Pool的工作是全局的, 是结合P发挥的. 我们往下走一步, 关于P你还记得吗? P持有一个G队列, 并且针对某个固定的P, 同一时刻下只会有一个G在运行.
    • 这么说吧, 每个P都会有一个"盒子", 存东西就是往这里面存, 现在假设我们是在P1中: G1先来了,从盒子里取走了这个东西, 等G1用完以后将东西放回盒子里. 按照P调度的原则, P会在G1退出以后切换到G2. 因为G1用完以后放回去了, 因此等到G2想用的时候, 东西还在盒子里, G2拿着用, 用完放回去, 执行完成退出, 切换G3, 凡此以往下去, 这个东西在盒子里存/取/存/取的进行下去, 被这个P中的每一个G拿来使用, 而我们只需要分配一个内存给它就够了
    • 如果每个P都有一个盒子, 那这么多个P的盒子就能组成一个盒子数组, 数组长度就是P的数量. ok, 这里的盒子数组对应到Pool里就是local属性, 数组长度numOf(P)就是localSize属性
    • 回顾一下:
      • 我们给每个P都赋了一个盒子用于存东西, P中的G会从盒子里存走对象, 因此我们说: Pool的工作是关乎P的
      • 如果我们存在两个一模一样的盒子, 那到底往哪儿存呢? 因此我们也说: 同一个Pool必须是全局唯一的, 且不能复制的
  • noCopy:
    • 一个用于防止复制的东西, 刚刚说同一个Pool必须是全局唯一不能复制的, 如果我非要复制它呢? go语言本身也没什么禁止拷贝的设定, 简单来说, noCopy结构体实现了sync.Locker接口, go vet(一个用于检查源码中静态错误的工具)中约定: 任何包含了 sync.Locker实例, 在go vet检查中就不能通过
    • 关于sync.Locker实例到底能不能复制, 我写了一个小例子, 你可以点进去复制到你自己电脑上, 然后通过 go vet 来验证一下, 首先复制这一关就过不了, 其次fmt.Printf本身也需要值拷贝, 即使是这个值拷贝也过不了: go-playground
    • 如果我真的复制锁了, 会发生什么: 死锁, 第二把锁想上锁之前需要等第一把锁解开(但是你没有意识到这一点), 这里是我写的另一个例子: go-playground

到了这里, 总结一下(防止你已经晕了):

  • Pool能存能取, 存到此P下的盒子里.
  • 如果盒子是空的, 用New函数定义空盒子取出来的是什么
  • Pool是关乎P的, 因此是全局的, 有一个锁用于保证每个Pool的唯一性

讨论讨论存取的过程

到这里原理已经介绍的差不多了, 但是本着搞艺术应有的精神, 我们决定还是继续看看存取是怎么进行的. 在说之前, 我们需要介绍一下这个"盒子"是什么样的.

type poolLocal struct {
	poolLocalInternal
	pad []byte
}

type poolLocalInternal struct {
	private interface{}   
	shared  []interface{} 
	Mutex               
}
复制代码

这里比较关键的是private/shared:


                       + -- []shared -- data_2 -- data_3 ...
                       |
M1 -- P1 --poolLocal-- + -- private -- data_1
     |
     + -- G1 -- G2 -- G3 ...

                       + -- []shared -- data_5 -- data_6 ...
                       |
M2 -- P2 --poolLocal-- + -- private -- data_4
     |
     + -- G4 -- G5 -- G6 ...


M3/M4 ...

复制代码

之前我们说P会往自己的盒子里存/取对象, 原来我们刚刚说了半天的盒子不止包含有一个舱位啊:

  • 私有舱位(对应private字段), 容量 = 1
  • 公共舱位(对应[]shared字段), 容量 = 好多个
  • 还有一个锁(对应Mutex字段)

关于为什么每个盒子里既有私有舱位, 同时又有公共舱位, 同时还要锁, 我们后面会说

存 - put

源码就不放了, 反正也没人看(你看么?反正我不怎么看), 我就用语言描述一下存的过程大概经历了那些步骤吧:

  • 如果要存的东西是个nil, 退出
  • 尝试获取当前G对应P下的盒子(也就是poolLocal)
  • 如果盒子里的私有舱位是空的, 那么优先存到自己的私有舱位里, 存好了以后退出
  • 如果私有舱位并不是空的, 但还是要存, 这种时候我们会存到公共舱位里去, 存的过程还会加锁, 存完了在解锁,
  • 锁的存在必要在"取"的环节里能看到

取 - get

相似的, 也是私有舱位优先于公共舱位的方案:

  • 拿到自己的盒子
  • 优先从私有舱位取东西出来, 取到了就退出
  • 没取到? 试试自己的公共舱位呢? 上锁, 检查, 解锁
  • 自己公共舱位也没有吗? 试试别人的公共舱位呢?
    • 这里来了, 说明自己的公共舱位也不是只有自己能用, 公共舱位之所以公共, 是因为大家都可能会来检查你, 不同的P都可能来你的公共舱位取东西
    • 尽管同一个P下不同的G是串行的, 但是P跟P之间可是实实在在的并行, 也就是真的可能存在争抢的情况
    • 同时我们也看到了公共舱位本质上也只是一个[]interface{}, 并没有什么特别防止争抢的设定, 因此我们给它加个锁
  • 别人也没有, 只能New一个出来了

那么为什么会出现共享区呢 ?

如果按照我们之前分析的, 存/取/存/取的模式, 如果真的是这样, 你只管使用自己盒子里的东西就够用了, 为什么还要共享区呢? 我认为可能的原因是这样:

  • 抢占调度
    • 抢占调度的存在使得G1都没执行完, 东西都还没放回盒子里, G2就上场了, 这个时候G2想从盒子里取东西来用, 发现没有(因为G1还没放回去), 那就New一个出来, 等G2执行完了把东西放回盒子里. 完事儿切换回G1, G1也用完了, 结果发现盒子里已经有G2刚刚放下去的东西了, 那怎么办呢, 只能放到共享区里了
  • 如果需求量不确定呢?
    • 我们只是设想, G1/G2用一个东西, 那如果G需求多于一个呢? 要用好几个, 或者不确定个, 这时候可以从共享区拿

最后再去想几个问题

盒子, 是永久存在的吗?

并不是, 是随着GC一起清理的

  • 打开sync/pool.go, 翻到246行,有一个init函数, 之前我们说过在Go程序启动的时候会注册并运行库中的init函数
  • 这个init函数向runtime/GC中注册了一个动作"poolCleanup", 也就是说每次GC都会执行这个动作, 大体内容包含:
    • 我们有一个全局维护的allPools, 里面包含了注册过的各种Pool, 我们到时候清理这个东西, 就能清理所有的Pool
    • 针对每一个Pool, 遍历localSize次,来清理所有的localPool, 也就是要清理所有的盒子
    • 首先将这个盒子里的私有舱位设成nil
    • 然后将这个盒子里所有的公共舱位,每一个舱位都会设置成nil

盒子, 是可靠的吗?

不是的, 因为我们会定期将盒子里所有东西全部置成nil, 所以需要持久性质的东西最好还是不要放进去, 比如"数据库连接池"