类型安全的Pool

池(sync.Pool)是一组可单独保存(Set)和检索(Get)的临时对象集合。

存储在池中的任何项都可能在任何时候自动移除而无需通知。如果池在移除项时持有该对象的唯一引用,那么这个对象可能会被释放掉。

池能够确保在多个goroutine同时访问时的安全性。

池的目的在于缓存已分配但未使用的对象以便后续复用,减轻垃圾收集器的压力。

也就是说池的功能是为了重用对象,目的是减轻GC的压力。

类型不安全?

你看sync.Pool提供的方法:

1
2
3
4
5
6
type Pool struct {
New func() any
}
func (p *Pool) Get() any
func (p *Pool) Put(x any)

它存储的对象类型是any,这样的话,我们在使用的时候就需要进行类型转换,这样就会导致类型不安全,或者说使用起来很麻烦。

比就以官方的例子为例:

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 (
"bytes"
"io"
"os"
"sync"
"time"
)
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func timeNow() time.Time {
return time.Unix(1136214245, 0)
}
func Log(w io.Writer, key, val string) {
b := bufPool.Get().(*bytes.Buffer) // 类型转换!!!!
b.Reset()
b.WriteString(timeNow().UTC().Format(time.RFC3339))
b.WriteByte(' ')
b.WriteString(key)
b.WriteByte('=')
b.WriteString(val)
w.Write(b.Bytes())
bufPool.Put(b)
}
func main() {
Log(os.Stdout, "path", "/search?q=flowers")
}

每次我们从sync.Pool中获取对象时,我们都需要进行类型转换,有一点点麻烦,而且是非类型安全的,有潜在的风险,比如误从另外一个包含其它类型的sync.Pool中获取对象。

其实我们可以使用泛型进行改造,但是为啥官方实现没有实现泛型呢?

那是因为Go的泛型实现的比较晚,所以当时只能使用interface{}(后来的any类型)来实现泛型,这样就会导致类型不安全。

类型安全的Pool

我们可以通过泛型来解决这个问题,我们可以定义一个泛型的Pool,这样我们就可以直接使用泛型类型了。

事实上mkmik/syncpool就实现了一个泛型的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
package syncpool
import (
"sync"
)
type Pool[T any] struct {
pool sync.Pool
}
func New[T any](fn func() T) Pool[T] {
return Pool[T]{
pool: sync.Pool{New: func() interface{} { return fn() }},
}
}
func (p *Pool[T]) Get() T {
return p.pool.Get().(T)
}
func (p *Pool[T]) Put(x T) {
p.pool.Put(x)
}

这里你可能有个疑问,Get方法在把接口类型转换为泛型类型时,为什么不需要进行错误检查呢:

1
c, ok := p.pool.Get().(T)

嗯,其实是没必要的,因为我们的泛型Pool已经保证了保存的对象都是T类型的。

我写这篇文章主要源自 Phuong Le 最新的推文 "Golang Tip #71: sync.Pool, make it typed-safe with generics."
他的Golang Tip系列文章非常有价值,我已经获得作者授权,后续会翻译一些文章,希望对大家有所帮助。

还是有装箱/拆箱操作

既然使用底层的snyc.Pool, 那自然还有装箱/拆箱操作,也就是说,当我们保存一个T类型的对象,它会转换成接口类型,当我们取出一个对象时,又会把接口类型转换成T类型。
从性能上讲,这个操作是有开销的,那么sync.Pool是否会修改成泛型呢,目前看是不会的,因为Go要保持向下兼容,基于这个承诺,已经没机会改了。

那么我们能否基于sync.Pool自己修改呢?难度很大,主要在于下面一点:

1
2
3
4
5
6
func init() {
runtime_registerPoolCleanup(poolCleanup)
}
// Implemented in runtime.
func runtime_registerPoolCleanup(cleanup func())

sync.Pool在运行时中插入了一个桩子,运行时在垃圾回收的时候,会调用函数做对象的清理,而且这个函数是单例的,只处理sync.Pool类型(你新创建的sync.Pool都会放到一个全局列表中,被这个函数做对象回收)。

不是太容易hack