使用 Sync Pool 提升程序性能

程序性能优化往往是一个程序提升的瓶颈,这不但需要你有意识的积累知识,而且还需工作上有场景的支持,对我来说工作上可能并没有太多的场景来支持我去提升,但不断的积累还是必要的,每块支持都能建立自己的体系的前提是你积累的足够多,这篇文章就是在看并发相关的博文时发现的一个提升性能的好东西:sync.pool

你有没有遇到过,当多个 goroutine 都需要创建同⼀个对象的时候,如果 goroutine 数过多,导致对象的创建数⽬剧增,进⽽导致 GC 压⼒增大。形成 “并发⼤-占⽤内存⼤-GC 缓慢-处理并发能⼒降低-并发更⼤”这样的恶性循环。当多个可以重复使用的数据都需要进行数据反序列化或是形成一个buffer。

在这个时候,就需要有⼀个对象池,每个 goroutine 不再⾃⼰单独创建对象,⽽是从对象池中获取出⼀个对象(如果池中已经有的话)。

go 语言的并发原语其实已经有了该功能:sync.pool:

sync.Pool 数据类型用来保存一组可独立访问的临时对象。请注意这里加粗的“临时”这两个字,它说明了 sync.Pool 这个数据类型的特点,也就是说,它池化的对象会在未来的某个时候被毫无预兆地移除掉。而且,如果没有别的对象引用这个被移除的对象的话,这个被移除的对象就会被垃圾回收掉。

因为 Pool 可以有效地减少新对象的申请,从而提高程序性能,所以 Go 内部库也用到了 sync.Pool,比如 fmt 包,它会使用一个动态大小的 buffer 池做输出缓存,当大量的 goroutine 并发输出的时候,就会创建比较多的 buffer,并且在不需要的时候回收掉。

有两个知识点你需要记住:

  • sync.Pool 本身就是线程安全的,多个 goroutine 可以并发地调用它的方法存取对象;
  • sync.Pool 不可在使用之后再复制使用

使用

知道了 sync.Pool 这个数据类型的特点,接下来,我们来学习下它的使用方法。其实,这个数据类型不难,它只提供了三个对外的方法:New、Get 和 Put。

1.New

Pool struct 包含一个 New 字段,这个字段的类型是函数 func() interface{}。当调用 Pool 的 Get 方法从池中获取元素,没有更多的空闲元素可返回时,就会调用这个 New 方法来创建新的元素。如果你没有设置 New 字段,没有更多的空闲元素可返回时,Get 方法将返回 nil,表明当前没有可用的元素。

2.Get

如果调用这个方法,就会从 Pool取走一个元素,这也就意味着,这个元素会从 Pool 中移除,返回给调用者。不过,除了返回值是正常实例化的元素,Get 方法的返回值还可能会是一个 nil(Pool.New 字段没有设置,又没有空闲元素可以返回),所以你在使用的时候,可能需要判断。

3.Put

这个方法用于将一个元素返还给 Pool,Pool 会把这个元素保存到池中,并且可以复用。但如果 Put 一个 nil 值,Pool 就会忽略这个值。

先看一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

import (
"fmt"
"sync"
)

var pool *sync.Pool

type Person struct {
Name string
}

func initPool() {
pool = &sync.Pool {
New: func()interface{} {
fmt.Println("Creating a new Person")
return new(Person)
},
}
}

func main() {
initPool()

p := pool.Get().(*Person)
fmt.Println("首次从 pool 里获取:", p)

p.Name = "first"
fmt.Printf("设置 p.Name = %s\n", p.Name)

pool.Put(p)

fmt.Println("Pool 里已有一个对象:&{first},调用 Get: ", pool.Get().(*Person))
fmt.Println("Pool 没有对象了,调用 Get: ", pool.Get().(*Person))
}

我们看到运行结果:

1
2
3
4
5
6
Creating a new Person
首次从 pool 里获取: &{}
设置 p.Name = first
Pool 里已有一个对象:&{first},调用 Get: &{first}
Creating a new Person
Pool 没有对象了,调用 Get: &{}

这个简单的例子相信可以让你了解sync.pool的使用方式,其实就是注册一个New的方法,New一个你要存储的结构,再使用Get的方法进行获取,或是使用Put的方法进行导入。

另外,我们发现 Get 方法取出来的对象和上次 Put 进去的对象实际上是同一个,Pool 没有做任何“清空”的处理。但我们不应当对此有任何假设,因为在实际的并发使用场景中,无法保证这种顺序,最好的做法是在 Put 前,将对象清空。

场景

对象池是在什么时候适合引入?

  • 一个对象会被大量创建,比如高并发场景。
  • 该场景是会被稳定触发的,而不是一次性的。也不能是间隔很久才触发一次。

这里我想做一个形象的比喻来帮助你理解对象池:

对象池其实就像一个图书馆,不一样的是如果你来借书但书被借完了还没还回来,他会立刻买一本给你

看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
var pool *sync.Pool

type Person struct {
Say func()
Name []string
}

func (ps *Person)RegSay(f func()){
ps.Say = f
}

var count int

func initPool() {
pool = &sync.Pool {
New: func()interface{} {
count ++
fmt.Println("Creating a new Person")
return new(Person)
},
}
}

func main() {
initPool()
pool.Put(&Person{})
for i:=0;i<10;i++{
go func(index int) {
// 说点什么
person := pool.Get().(*Person)
person.RegSay (func() {
fmt.Println("我是任务:",index)
})

person.Name = append(person.Name,fmt.Sprintf("%d",index))
person.Say()

fmt.Println("my Records is ",person.Name)

pool.Put(person)
}(i)
}

time.Sleep(10 *time.Second)
fmt.Println(count)
}

在这个例子中我去并发了20个协程,将Person这个对象进行一个对象池的声明,每次并发的逻辑就是给新建一个 person 对象并让他说点什么。然后把它说的内容添加进Records记录下来。为了判断它创建了多少个新的对象,每一次新建我都会进行一次计数。

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Creating a new Person
Creating a new Person
我是任务: 9
Creating a new Person
Creating a new Person
我是任务: 0
我是任务: 1
我是任务: 8
my Records is [9]
我是任务: 5
my Records is [1]
Creating a new Person
Creating a new Person
Creating a new Person
我是任务: 2
Creating a new Person
我是任务: 4
my Records is [4]
my Records is [5]
Creating a new Person
我是任务: 7
my Records is [7]
my Records is [8]
我是任务: 3
my Records is [3]
my Records is [0]
我是任务: 6
my Records is [6]
my Records is [2]
9

可以看到这个对象池基本没有起到作用,仍然是创建了10个对象。因为并发太快了,还没还回去下一个并发就开始执行了。我尝试进行10000次并发,最后竟然创建了9954个新对象。

所以对象池的使用场景一定是多次触发产生复用。而且并发池中的对象虽然一次GC不会将他干掉而是移动到victim中下次还是可以使用(具体的实现可以去百度或谷歌),但两次之后就没有了。所以长时间不触发的场景也不适用。

看一下多次触发的场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func main() {
initPool()
pool.Put(&Person{})
tick := time.NewTicker(1*time.Second)

var pass int = 1
for range tick.C{
fmt.Printf("============================================第%d次触发================================== \n",pass)
for i:=0;i<10;i++{
go func(index int) {
// 说点什么
person := pool.Get().(*Person)
person.RegSay (func() {
fmt.Println("我是任务:",index)
})

person.Records = append(person.Records,fmt.Sprintf("%d",index))
person.Say()

fmt.Println("my Records is ",person.Records)

pool.Put(person)
}(i)
}

if pass == 10{
break
}
pass ++
}

time.Sleep(10 *time.Second)
fmt.Println(count)
}

把并发任务定时并进行10次触发,有意思的事情出现了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
============================================第10次触发================================== 
我是任务: 0
my Records is [0 1 7 6 9 2 9 5 6 5 0]
我是任务: 4
my Records is [0 9 7 2 6 1 9 4]
我是任务: 1
我是任务: 6
我是任务: 7
我是任务: 5
我是任务: 9
my Records is [2 1 2 3 9 2 2 3 7 5 8 5 4 7]
my Records is [0 1 7 6 9 2 9 5 6 5 0 6]
my Records is [5 0 4 8 4 2 7 3 5]
我是任务: 8
我是任务: 2
my Records is [5 6 5 9 7 4 1 7 2]
我是任务: 3
my Records is [0 9 7 2 6 1 9 4 1]
my Records is [5 6 4 8 4 0 3 2 8]
my Records is [3 1 3 6 6 3]
my Records is [8 8 3 0 3 7 9 0 0 9]
9

触发了10次最后仍然是新建了9个对象,这时我们的目的就达到了,这样直接减小了10倍GC的压力。

所以使用对象池的场景最好是该对象是不断使用的,频次很高的,大量创建的。这样才能最好的发挥sync.pool的优势。不然其实不如不使用。

性能测试

Struct

声明对象池

实现 New 函数。对象池中没有对象时,将会调用 New 函数创建。

1
2
3
4
5
6
7
8
9
10
11
12
type Student struct {
Name string
Age int32
Remark [1024]byte
}


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

Get & Put

1
2
3
stu := studentPool.Get().(*Student)
json.Unmarshal(buf, stu)
studentPool.Put(stu)
  • Get() 用于从对象池中获取对象,因为返回值是 interface{},因此需要类型转换。
  • Put() 则是在对象使用完毕后,返回对象池。

测性能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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)
}
}

执行:go test -bench . -benchmem

测试结果如下:

1
2
3
4
5
6
7
8
goos: darwin
goarch: amd64
pkg: grpc_apply
cpu: Intel(R) Core(TM) i7-8559U CPU @ 2.70GHz
BenchmarkUnmarshal-8 13652 87396 ns/op 1400 B/op 8 allocs/op
BenchmarkUnmarshalWithPool-8 13543 86024 ns/op 248 B/op 7 allocs/op
PASS
ok grpc_apply 4.152s

在这个例子中,因为 Student 结构体内存占用较小,内存分配几乎不耗时间。而标准库 json 反序列化时利用了反射,效率是比较低的,占据了大部分时间,因此两种方式最终的执行时间几乎没什么变化。但是内存占用差了一个数量级,使用了 sync.Pool 后,内存占用仅为未使用的 248/1400 = 1/6,对 GC 的影响就很大了。

bytes.Buffer

再看一个例子这个例子使用bytes.Buffer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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)
}
}

但在实际使用的时候,最好做到物尽其用,尽可能不浪费,我们可以将 buffer 池分成几层。首先,小于 512 byte 的元素的 buffer 占一个池子;其次,小于 1K byte 大小的元素占一个池子;再次,小于 4K byte 大小的元素占一个池子。这样分成几个池子以后,就可以根据需要,到所需大小的池子中获取 buffer 了。

这个例子中要注意一个坑:

取出来的 bytes.Buffer 在使用的时候,我们可以往这个元素中增加大量的 byte 数据,这会导致底层的 byte slice 的容量可能会变得很大。这个时候,即使 Reset 再放回到池子中,这些 byte slice 的容量不会改变,所占的空间依然很大。而且,因为 Pool 回收的机制,这些大的 Buffer 可能不被回收,而是会一直占用很大的空间,这属于内存泄漏的问题。

比如 encoding、json 就出现了类似的问题:将容量已经变得很大的 Buffer 再放回 Pool 中,导致内存泄漏。

解决方法是:在元素放回时,增加了检查逻辑,改成放回的超过一定大小的 buffer,就直接丢弃掉,不再PUT到池子中。

测试结果如下:

1
2
3
4
5
6
7
8
goos: darwin
goarch: amd64
pkg: grpc_apply
cpu: Intel(R) Core(TM) i7-8559U CPU @ 2.70GHz
BenchmarkBufferWithPool-8 12054878 101.3 ns/op 0 B/op 0 allocs/op
BenchmarkBuffer-8 1142041 1025 ns/op 10240 B/op 1 allocs/op
PASS
ok grpc_apply 2.838s

这个例子创建了一个 bytes.Buffer 对象池,而且每次只执行一个简单的 Write 操作,存粹的内存搬运工,耗时几乎可以忽略。而内存分配和回收的耗时占比较多,因此对程序整体的性能影响更大。

你可以根据不同场景建立不同的pool,从而满足你的业务需求达到性能提升预期。