Object Pool Pattern(对象池模式)
对象池模式,对象被预先创建并初始化后放入对象池中,对象提供者,对象提供者就能利用已有的对象来处理请求,减少对象频繁创建所浪费的资源。例如数据库的连接池等等,基本都是创建后就被放入连接池中,后续的查询请求使用的都是连接池中的对象,从而加快了查询速度(不然每次查询都需要重新创建数据库连接对象,比较浪费)。
一句话解释,对象池模式下,程序在一开始就创建好了一批可用对象供使用
这种模式下,一般有两种角色,即对象池管理者和对象池用户。
对象池管理者负责管理整个对象池,包括初始化对象池,扩充对象池的大小,重置归还对象的状态等等。
对象池用户负责从对象池中获取一个对象,用完之后一般需要归还整这个对象。
一般被放入对象池中的对象包括Socket对象,数据库连接对象(连接池),线程对象(线程池)等等。
五大要素
来看看对象池模式的五大要素:
- 模式名称:对象池模式
- 目的(What):在程序刚开始创建一批可用对象供使用。
- 解决的问题(Why):当我们频繁需要获取一个对象,并且该对象的频繁创建和销毁可能会导致系统性能较高的开销时,使用这种模式,避免对象的重复创建和销毁带来的资源开销。
- 解决方案(How):一个对象池管理者,负责管理对象;有若干个对象池用户,从对象池中获取对象,用完之后归还。
- 解决效果:
- 优点:
- 复用池中对象,消除创建对象、销毁对象所产生的内存开销、CPU开销以及(若跨网络)产生的网络开销
- 缺点:
- 如果是一些轻中量级的对象,创建/销毁对象的开销几乎可以忽略不计,没必要使用这种模式,增加程序的复杂度
- 并发环境中,多个线程可能(同时)需要获取对象,这个时候必然要引起互斥锁,获取互斥锁(加锁/解锁)的操作产生的开销往往会比创建/销毁对象产生的开销还要大。
- 由于一般来说,池中的对象是有限的,这个势必会成为系统可伸缩性的一个瓶颈
- 很难正确的设定对象池的大小,如果太小则不起作用,如果太大,则内存占用资源过高。
- 优点:
Go实现对象池模式
Go语言内部有sync.pool
包实现了对象池的概念,但是这个设计出来的目的是优化GC的,因为它存储的对象随时都有可能被GC回收掉,具体感兴趣地可以去了解一下该包的用法,这篇文章就不对该包作详细解答了,因为我们的主要目的还是了解对象池模式。因此下面我们会自己实现一个对象池。
场景
当前的场景是我们需要实现一个协程池,我们开始给这个协程池固定一个协程数量,每个协程被用于去执行一项任务(Task),假设当前有1万个任务,我们必不可能用只用一个协程去执行,我们开启若干个协程去不断地轮询执行这些任务
object_pool.go
:
package objectPool
import (
"fmt"
"sync"
)
// 任务对象,任务具体做什么由f决定
type Task struct {
f func() error
id int
}
// 创建一个任务对象
func NewTask(f func() error,id int) *Task {
return &Task{
f: f,
id: id,
}
}
// 执行该任务对象
func (t *Task) Execute() error {
return t.f()
}
// 协程池对象
type Pool struct {
EntryChannel chan *Task
// 协程池中worker的数量
workerNum int
// 协程池内部的任务就绪队列
JobsChannel chan *Task
// 加waitgroup为了在测试的时候控制程序主动退出
wg *sync.WaitGroup
}
// 创建一个协程池
func NewPool(cap int, wg *sync.WaitGroup) *Pool {
return &Pool{
EntryChannel: make(chan *Task),
workerNum: cap,
JobsChannel: make(chan *Task),
wg: wg,
}
}
func (p *Pool) SetTask(task *Task) {
p.EntryChannel <- task
}
func (p *Pool) worker(workID int) {
for task := range p.JobsChannel {
// worker从jobsChannel中获取task
err := task.Execute()
if err != nil {
fmt.Printf("workerID: %d, failed to execute task id %d ,err: %v\n", workID, task.id,err)
} else {
fmt.Printf("workerID: %d, the task id %d was successfully executed\n", workID,task.id)
}
// 完成一个任务done
p.wg.Done()
}
}
func (p *Pool) Run() {
for i := 0; i < p.workerNum; i++ {
go p.worker(i)
}
for task := range p.EntryChannel {
p.JobsChannel <- task
}
}
func (p *Pool) Stop() {
close(p.JobsChannel)
close(p.EntryChannel)
}
object_pool_test.go
:
package objectPool
import (
"fmt"
"sync"
"testing"
"time"
)
func TestObjectPool(t *testing.T) {
// 创建一个拥有10个worker的协程池
start := time.Now()
defer func() {
end := time.Now()
fmt.Printf("it takes %f seconds\n",end.Sub(start).Seconds())
}()
wg := &sync.WaitGroup{}
p := NewPool(10,wg)
go p.Run()
// 创建100个task
for i := 0; i < 100; i++ {
wg.Add(1)
t := NewTask(func() error {
// 为了显示出协程池的效果,这里每个任务sleep一秒
time.Sleep(time.Second)
return nil
},i)
p.SetTask(t)
}
wg.Wait()
p.Stop()
}
执行测试结果:
=== RUN TestObjectPool
workerID: 8, the task id 8 was successfully executed
workerID: 2, the task id 4 was successfully executed
...............
...............
workerID: 6, the task id 2 was successfully executed
workerID: 6, the task id 97 was successfully executed
workerID: 2, the task id 99 was successfully executed
it takes 10.030659 seconds
--- PASS: TestObjectPool (10.03s)
PASS
上述例子在做测试的时候,我特意在每个task任务中 sleep了1秒,保证它们至少执行1秒再推出。如果每个任务中只是打印一个日志,这样我们在测试的时候会发现,可能协程池开的worker数越少越好,因为这个时候在一开始创建多个worker的开销已经远远超过每个任务执行的开销了。
因此协程数不是开的越多越好,具体开多少个是需要在指定场景进行性能测试才能决定的。