Go 并发系列是根据我对晁岳攀老师的《Go 并发编程实战课》的吸收和理解整理而成,如有偏差,欢迎指正~
为什么需要池化 Pool
Go 是一个支持自动垃圾回收的语言,对程序员而言,我们想创建对象就创建,不用关心资源的回收,大大提高了开发的效率。
但是方便的背后,却有也有不小的代价。Go 的垃圾回收机制还是有一个 STW (stop-the-world,程序暂停)的时间,大量创建的对象,都会影响垃圾回收标记的时间。
除此之外,像数据库的连接,tcp 连接,这些连接的创建本身就十分耗时,如果能将这些连接复用,也能减少业务耗时。
所以,高并发场景下,采用池化(Pool)手段,对某些对象集中管理,重复利用,减少创建和垃圾回收的成本,不仅可以大大提高业务的响应速度,也能提高程序的整体性能。
什么是池化 Pool
池化就是对某些对象进行集中管理,重复利用,减少对象创建和垃圾回收的成本。
Go 标准库 sync 提供了一个通用的 Pool,通过这个 Pool 可以创建池化对象,实现一般对象的管理。
下面我们主要看一下 sync.Pool 的实现。
sync.Pool 使用
sync.Pool 的使用很简单,它有1个对外的成员变量 New 和2个对外的成员方法 Get 和 Put。
下面是一个使用示例(见 fUsePool 方法):
type AFreeCoder struct { officialAccount string article string content \[\]string placeHolder string}// 为了真实模拟,这里禁止编译器使用内联优化//go:noinlinefunc NewAFreeCoder() \*AFreeCoder { return &AFreeCoder{ officialAccount: "码农的自由之路", content: make(\[\]string, 10000, 10000), placeHolder: "如果觉得有用,欢迎关注哦~", }}func (a \*AFreeCoder) Write() { a.article = "Go 并发之性能提升杀器 Pool"}func f(concurrentNum int) { var w sync.WaitGroup w.Add(concurrentNum) for i := 0; i < concurrentNum; i++ { go func() { defer w.Done() a := NewAFreeCoder() a.Write() }() } w.Wait()}func fUsePool(concurrentNum int) { var w sync.WaitGroup p := sync.Pool{ New: func() interface{} { return NewAFreeCoder() }, } w.Add(concurrentNum) for i := 0; i < concurrentNum; i++ { go func() { defer w.Done() a := p.Get().(\*AFreeCoder) defer p.Put(a) a.Write() }() } w.Wait()}
AFreeCoder 是自定义的结构体,用来模拟初始化比较耗时类型。
New 是函数类型变量,传入的函数需要实现 AFreeCoder 的初始化。
Get 方法返回的是 interface{} 类型,需要断言成 New 返回的类型。
Put 方法也比较好理解,变量用完了再放回去。
使用 sync.Pool 真的能提升性能吗?
上面的示例中,f 和 fUsePool 分别实现了不使用 Pool 和使用 Pool 情况下,并发执行 Write 函数的功能。
那么这两个函数的性能对比如何呢?我们可以用 go test 的 benchmark 测试一下(并发数 concurrentNum=100),测试结果如下:
goos: darwingoarch: amd64pkg: go\_practice/pool\_exampleBenchmark\_f-8 853 1355041 ns/op 16392237 B/op 203 allocs/opBenchmark\_fUsePool-8 12460 98046 ns/op 565066 B/op 9 allocs/opPASSok go\_practice/pool\_example 4.663s
测试结果显示,使用了 Pool 之后,内存分配的次数相比不使用 Pool 的方式少很多,整体的耗时也会小很多。
如果把上面示例中初始化函数 NewAFreeCoder 中 content 的初始化操作去掉,再测试一次呢?测试结果如下:
goos: darwingoarch: amd64pkg: go\_practice/pool\_exampleBenchmark\_f-8 853 1355041 ns/op 16392237 B/op 203 allocs/opBenchmark\_fUsePool-8 12460 98046 ns/op 565066 B/op 9 allocs/opPASSok go\_practice/pool\_example 4.663s
上面数据粘错了)使用了 Pool 之后,内存分配次数和每次操作消耗的内存仍然很少,但是整体的耗时相对不使用 Pool 的情况并没减少。
这是因为此时创建 AFreeCoder 对象的成本较低,而 Pool 相关操作也会有性能的消耗,所以才导致两者整体耗时差不多。
使用 sync.pool 的注意点
sync.Pool 本身是线程安全的,可以多个 goroutine 并发调用,使用起来很方便,但是有两个注意点:
- 禁止拷贝
- 不能存放需要保持长连接的对象
第1点,禁止拷贝很好理解,毕竟 New 很容易修改。
第2点,不能存放需要保持长连接的对象。这是因为 sync.Pool 注册了自己的 Pool 清理函数,Pool 中的变量可能会被垃圾回收掉。如果需求保存长连接,有很多其它的 Pool 实现了这种功能。
sync.pool 的实现
sync.pool 的定义
看一下 Pool 的定义:
type Pool struct {
noCopy noCopy
local unsafe.Pointer // local fixed-size per-P pool, actual type is \[P\]poolLocal
localSize uintptr // size of the local array
victim unsafe.Pointer // local from previous cycle
victimSize uintptr // size of victims array
// New optionally specifies a function to generate
// a value when Get would otherwise return nil.
// It may not be changed concurrently with calls to Get.
New func() interface{}
}
noCopy 不用多解释,用于静态检查,防拷贝的。New 前面也说过,用来存对象初始化函数的。
重点是 local 和 victim 这两个字段。解释这两个字段前,先上一张《Go 并发实战课》原文的 sync.Pool 数据结构的示意图:
sync.Pool 中,缓存的对象并不是存储在一个队列中,而是根据处理器 P 的核数 n 存了 n 份,这样能最大程度的保证并发的时候 n 个 goroutine 可以同时获取对象。
local 和 victim 结构都一样,都是 poolLocal 类型,有 private 和 shared 成员。private 存储单个对象,shared 是 poolChain 类型,类似队列,存了一堆对象。因为 local 和 victim 都是和处理器绑定的,当某个 goroutine 独占一个处理器时,直接通过 private 取值不需要加锁,速度就会很快。
为什么有了 local,还需要 victim 呢?这是为了降低池子中对象被回收的可能性。
由于 sync.Pool 中存储对象的个数不定,大小不定,所以它需要在系统闲暇的时候将变量回收掉。其实现方式如下:
func poolCleanup() {
for \_, p := range oldPools {
p.victim = nil
p.victimSize = 0
}
for \_, p := range allPools {
p.victim = p.local
p.victimSize = p.localSize
p.local = nil
p.localSize = 0
}
oldPools, allPools = allPools, nil
}
poolClean() 函数被注册到了 runtime 中,会在每一次 GC 调用之前被调用。这样 GC 第一次调用的时候,local 虽然被清空,但是还能通过 victim 拿到池子中的对象。
Put 方法
Put 方法实现的功能是将用完的对象重新放回池子里。因为 Put 比较简单,所以先介绍 Put 方法。
Put 方法实现如下:
func (p \*Pool) Put(x interface{}) { if x == nil { return } // 把当前goroutine固定在当前的P上 // l 就是 local l, \_ := p.pin() if l.private == nil { l.private = x x = nil } if x != nil { l.shared.pushHead(x) } runtime\_procUnpin()}
先说一下 p.pin() 和 runtime\_procUnpin(), 这两个函数分别实现了某 goroutine 抢占当前 P(处理器)和解除抢占的功能。所以这里 private 的复制和之后 Get 方法中的读取都不需要加锁。
整个逻辑比较简单,优先存到本地 private,如果 private 已经有值了,就放到本地队列中。
Get 方法
Get 方法实现如下:
func (p \*Pool) Get() interface{} { // 把当前goroutine固定在当前的P上 l, pid := p.pin() x := l.private // 优先从local的private字段取,快速 l.private = nil if x == nil { // 从当前的local.shared弹出一个,注意是从head读取并移除 x, \_ = l.shared.popHead() if x == nil { // 如果没有,则去偷一个 x = p.getSlow(pid) } } runtime\_procUnpin() // 如果没有获取到,尝试使用New函数生成一个新的 if x == nil && p.New != nil { x = p.New() } return x}
Get 方法整体概括就是从池子中取出一个对象,如果没有对象了,就 New 一个,再返回。
细节上,先从当前 P 对应的 local 的 private 获取,获取不到,就从当前 P 的 local 的队列 shared 中获取,还获取不到就从其它 P 的 shared 中获取 (getSlow 方法)。
如果最终仍然获取不到,才 New 一个对象。
sync.Pool 的坑
虽然 sync.Pool 也做了很多优化,性能有了很大的提升,但是使用的时候还是有两个坑:
内存泄露
如果池子中对象的类型是 slice,它的 cap 可能不断的变大,而 sync.Pool 的回收机制(第二次回收)可能导致这些过大的对象越来越多,且一直无法回收,最终造成内存泄露。
所以有一些特定 Pool 的使用中,会对池子中的变量的大小做一个限制,超过一个阈值直接丢弃。
内存浪费
除了内存泄露外,还有一种浪费的情况,就是池子中的变量变得很大,但是很多时候只需要一个很小的变量,就会造成内存浪费的情况。
长连接对象如何池化?
因为 sync.Pool 保存的对象可能会被无通知的释放掉,并不适合用来保存连接对象。连接对象的保存一般都通过其它方法完成。
比如 Go 中 http 连接的连接池的实现在 Transport 中,它用一个 idleConn 对象(map)来保存连接,由于没有类似 sync.Pool 的垃圾回收方法 PoolClean(),所以能保持长连接。Transport 对连接数量的控制通过 LRU 实现。
像第三方包 faith/pool,它是通过 channel + Mutex 的方式实现的 Pool,空闲的连接放到 channel 中。这也是 channel 的一个应用场景。
最后
Pool 是一个通用的概念,也是解决对象重用和预先分配的一个常用的优化手段。
但是项目一开始其实没必要考虑考虑这种优化,只有到了中后期阶段,出现性能瓶颈,需要优化的时候,可以考虑通过 Pool 的方式来优化。
码农的自由之路
996的码农,也能自由~
47篇原创内容
公众号
都看到这里了,不如点个 赞/在看,加个关注呗~~
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。