8.6 临时对象池

本节要讲解的是sync.Pool类型。我们可以把sync.Pool类型值看作是存放可被重复使用的值的容器。此类容器是自动伸缩的,高效的,同时也是并发安全的。为了描述方便,我们也会把sync.Pool类型的值称为临时对象池,而把存于其中的值称为对象值。至于为什么要加“临时“这两个字,我们稍后再解释。

我们在用复合字面量初始化一个临时对象池的时候,可以为它唯一的公开字段New赋值。该字段的类型是func() interface{},即一个函数类型。可以猜到,被赋给字段New的函数会被临时对象池用来创建对象值。不过,实际上,该函数几乎仅在池中无可用对象值的时候才会被调用。

类型sync.Pool有两个公开的方法。一个是Get,另一个是Put。前者的功能是从池中获取一个interface{}类型的值,而后者的作用则是把一个interface{}类型的值放置于池中。

通过Get方法获取到的值是任意的。如果一个临时对象池的Put方法未被调用过,且它的New字段也未曾被赋予一个非nil的函数值,那么它的Get方法返回的结果值就一定会是nil。我们稍后会讲到,Get方法返回的不一定就是存在于池中的值。不过,如果这个结果值是池中的,那么在该方法返回它之前就一定会把它从池中删除掉。

这样一个临时对象池在功能上与一个通用的缓存池有几分相似。但是实际上,临时对象池本身的特性决定了它是一个个性非常鲜明的同步工具。我们在这里说明它的两个非常突出的特性。

第一个特性是,临时对象池可以把由其中的对象值产生的存储压力进行分摊。更进一步说,它会专门为每一个与操作它的Goroutine相关联的P都生成一个本地池。在临时对象池的Get方法被调用的时候,它一般会先尝试从与本地P对应的那个本地池中获取一个对象值。如果获取失败,它就会试图从其他P的本地池中偷一个对象值并直接返回给调用方。如果依然未果,那它只能把希望寄托于当前的临时对象池的New字段代表的那个对象值生成函数了。注意,这个对象值生成函数产生的对象值永远不会被放置到池中。它会被直接返回给调用方。另一方面,临时对象池的Put方法会把它的参数值存放到与当前P对应的那个本地池中。每个P的本地池中的绝大多数对象值都是被同一个临时对象池中的所有本地池所共享的。也就是说,它们随时可能会被偷走。

临时对象池的第二个突出特性是对垃圾回收友好。垃圾回收的执行一般会使临时对象池中的对象值被全部移除。也就是说,即使我们永远不会显式地从临时对象池取走某一个对象值,该对象值也不会永远待在临时对象池中。它的生命周期取决于垃圾回收任务下一次的执行时间。

请读者阅读一下这段代码:

  1. package main
  2. import (
  3. "fmt"
  4. "runtime"
  5. "runtime/debug"
  6. "sync"
  7. "sync/atomic"
  8. )
  9. func main() {
  10. // 禁用GC,并保证在main函数执行结束前恢复GC
  11. defer debug.SetGCPercent(debug.SetGCPercent(-1))
  12. var count int32
  13. newFunc := func() interface{} {
  14. return atomic.AddInt32(&count, 1)
  15. }
  16. pool := sync.Pool{New: newFunc}
  17. // New 字段值的作用
  18. v1 := pool.Get()
  19. fmt.Printf("v1: %v\n", v1)
  20. // 临时对象池的存取
  21. pool.Put(newFunc())
  22. pool.Put(newFunc())
  23. pool.Put(newFunc())
  24. v2 := pool.Get()
  25. fmt.Printf("v2: %v\n", v2)
  26. // 垃圾回收对临时对象池的影响
  27. debug.SetGCPercent(100)
  28. runtime.GC()
  29. v3 := pool.Get()
  30. fmt.Printf("v3: %v\n", v3)
  31. pool.New = nil
  32. v4 := pool.Get()
  33. fmt.Printf("v4: %v\n", v4)
  34. }

在这里,我们使用runtime/debug代码包的SetGCPercent函数来禁用、恢复GC以及指定垃圾收集比率(详见7.1节中的相关说明),以保证我们的演示能够如愿进行。

我们把这段代码存放在gocp项目的sync1/pool代码包的文件pool_demo.go中,并使用go run命令运行它:

  1. hc@ubt:~/golang/goc2p/src/sync1/pool$ go run pool_demo.go

而后,我们会在标准输出上看到如下内容:

  1. v1: 1
  2. v2: 2
  3. v3: 5
  4. v4: <nil>

请读者注意第3行和第4行的内容,也就是我们在手动地进行垃圾回收之后的输出内容。在把nil赋给poolNew字段之前,即使手动地执行了垃圾回收,我们也是可以从临时对象池获取到一个对象值的。而在这之后,我们却只能取出nil。读者应该可以依据我们刚刚描述的那两个特性想明白如此输出的原因。

看到这里,读者可能会隐约地感觉到,我们在使用临时对象池的时候应该依照一些方式方法,否则就会很容易迈入陷坑。实际情况确实如此。

首先,我们不能对通过Get方法获取到的对象值有任何假设。到底哪一个值会被取出是完全不确定的。这是因为我们总是不能得知操作临时对象池的Goroutine在哪一时刻会与哪一个P相关联,尤其是在比上述示例更加复杂的程序的运行过程中。在这种情况下,我们也就无从知晓我们放入的对象值会被存放到哪一个P的本地池中,以及哪一个Goroutine执行的Get方法会返回该对象值。所以,我们给予临时对象池的对象值生成函数所产生的值,以及通过调用它的Put方法放入到池中的值,都应该是无状态的或者状态一致的。从另一方面说,我们在取出并使用这些值的时候,也不应该以其中的任何状态作为先决条件。这一点非常重要。

第二个需要注意的地方实际上与我们前面讲到的第二个特性紧密相关。临时对象池中的任何对象值都有可能在任何时候被移除掉,并且根本不会通知该池的使用方。这种情况常常会发生在垃圾回收器即将开始回收内存垃圾的时候。如果这时临时对象池中的某个对象值仅被该池引用,那么它还可能会在垃圾回收的时候被回收掉。因此,我们也就不能假设之前放入到临时对象池的某个对象值会一直待在池中,即使我们没有显式地把它从池中取出。甚至一个对象值可以在临时对象池中待多久,我们也无法假设。除非我们像前面的示例那样手动地控制GC的启停。不过,我们并不推荐这种方式。这会带来一些其他问题。

依据我们刚刚讲述的临时对象池特性和使用注意事项,读者应该可以想象得出临时对象池的一些适用场景(比如作为临时且状态无关的数据的暂存处),以及一些不适用的场景(比如用来存放数据库连接的实例)。如果我们在做实现技术的选型的时候把临时对象池作为了候选之一,那么就应该好好想想它的“个性”是不是符合你的需要。如果真的适合,那么它的特性一定会为你的程序增光添彩,无论在功能上还是在性能上。而如果它被用在了不恰当的地方,那么就只能适得其反了。