1、为什么需要一个线程池
异步任务执行
线程池有很多应用场景,涉及异步线程去实现的逻辑往往都会用到线程池。比如我们用的web框架大多数在处理请求时,都会把接收到的请求交给线程池中的某个线程去执行,因为web接收请求的线程,不可能等待一个请求处理完再去接收下一个请求,都会把请求处理放到单独的线程池中去异步处理。
限制最大线程数
上面说的场景,对每个请求开一个新的线程就好了,为什么还要一个『池』呢?
想想如果有大量任务要执行,就会创建大量的线程,从而超出系统的负载,整体崩溃。所以稳定的系统是不能依赖任务数这种不可预估的因素的,需要对线程创建进行数量的管控,因此需要一个线程池来限制最大的线程数。
线程复用
创建线程是一个特别重的操作,之前创建过的线程如果执行完操作之后,再复用于后续的任务,这样就能大大减少线程创建的动作。
2、线程池的一种简单实现
package main
func main() {
tasks := make(chan func())
threadNum := 10
for i := 0; i < threadNum; i++ {
go Work(tasks)
}
}
func Work(tasks chan func()) {
for task := range tasks {
// 忽略task执行异常处理
task()
}
}
上面的示例中,创建了10个线程组成的线程池,每个线程不断地从 tasks
中获取任务,然后执行。这简单几行代码似乎就是一个完美的线程池实现了,是的,大多数场景下这样实现完全足够。
缺点
使用线程池的缺点就是浪费内存,在没有任务执行时,上面的代码可以看到线程一直阻塞,并不会销毁线程。
3、Go开源线程池框架-ants
3.1、ants简介
ants
是一个高性能的 goroutine 池,支持限制线程数量、线程复用。
Github地址:https://github.com/panjf2000/ants
核心特性:
自动调度海量的 goroutines,复用 goroutines
定期清理过期的 goroutines,进一步节省资源
支持非阻塞提交任务
预分配内存 (环形队列,可选)等
3.2、使用示例
package main
import (
"sync"
"github.com/panjf2000/ants/v2"
)
func main() {
var wg sync.WaitGroup
p, _ := ants.NewPool(10)
defer p.Release()
for i := 0; i < 100; i++ {
wg.Add(1)
p.Submit(func() {
// do something
wg.Done()
})
}
wg.Wait()
}
4、ants的整体架构
4.1、处理流程图
4.2、流程示例
如下图,4个线程,有6个任务提交的情况:
其中4个任务从线程池中找到了空闲的线程来执行,剩余两个阻塞等待有线程释放会从线程池,如下图:
上一轮4个任务处理完成之后,剩下的两个任务开始被执行,此时有池程池中有两个空闲线程,如下图:
最后所有任务都执行完,线程池中有4个空闲线程,如下图:
5、ants关键源码分析
5.1、关键结构体
Pool结构:
type poolCommon struct {
capacity int32
workers workerQueue
workerCache sync.Pool
lock sync.Locker
cond *sync.Cond
// 其他字段忽略
}
type Pool struct {
poolCommon
}
其中workers是一个 worker数组,保存了线程运行体。
workerCache是创建worker的对应缓存,worker停止时不会真正的销毁,而是放入workerCache中。
lock用于实现操作线程池时加锁。
cond用于提交任务时线程池如果没有空闲worker时阻塞,等待线程池有空闲worker时再被唤醒。
还有其他字段先暂忽略
Worker结构:
type goWorker struct {
pool *Pool
task chan func()
lastUsed time.Time
}
pool字段和线程池相互引用
task是向worker提交任务的通道
lastUsed记录了worker上次处理任务的时间
5.2、线程池的创建
func NewPool(size int, options ...Option) (*Pool, error) {
if size <= 0 {
size = -1
}
opts := loadOptions(options...)
if !opts.DisablePurge {
if expiry := opts.ExpiryDuration; expiry < 0 {
return nil, ErrInvalidPoolExpiry
} else if expiry == 0 {
opts.ExpiryDuration = DefaultCleanIntervalTime
}
}
if opts.Logger == nil {
opts.Logger = defaultLogger
}
p := &Pool{poolCommon: poolCommon{
capacity: int32(size),
allDone: make(chan struct{}),
lock: syncx.NewSpinLock(),
once: &sync.Once{},
options: opts,
}}
p.workerCache.New = func() interface{} {
return &goWorker{
pool: p,
task: make(chan func(), workerChanCap),
}
}
if p.options.PreAlloc {
if size == -1 {
return nil, ErrInvalidPreAllocSize
}
p.workers = newWorkerQueue(queueTypeLoopQueue, size)
} else {
p.workers = newWorkerQueue(queueTypeStack, 0)
}
p.cond = sync.NewCond(p.lock)
p.goPurge()
p.goTicktock()
return p, nil
}
上面代码中可以看到 NewPool() 基本都是在给Pool结构体各个字段的初始化:
workerCache 的初始化方法中新创建一个goWorker结构,并和当前pool绑定。
workers:根据 PreAlloc 配置项是否开启情况,创建两种不同的worker数组实现方式:一种基于栈,另一种基于环形队列
初始化的最后,启动了两个异步线程:
goPurge:定期清理过期线程
goTicktock:定期更新当前时间缓存
5.3、任务提交流程
func (p *Pool) Submit(task func()) error {
if p.IsClosed() {
return ErrPoolClosed
}
w, err := p.retrieveWorker()
if w != nil {
w.inputFunc(task)
}
return err
}
任务提交时,通过retrieveWorker()
找到一个空闲worker,并调用inputFunc()
把任务交给空闲worker处理。
retrieveWorker()
是如何找到空闲worker的?
func (p *Pool) retrieveWorker() (w worker, err error) {
p.lock.Lock()
retry:
// 先从线程池中的workers中摘下来一个空闲worker,成功则直接返回
if w = p.workers.detach(); w != nil {
p.lock.Unlock()
return
}
// 如果没有取到现有的空闲worker,则创建线程(其实是从workerCache中获取,他会自动管理从缓存中取还是真实创建)
// 当然创建时会判断当前正在运行的worker是否达到了配置的最大限制
if capacity := p.Cap(); capacity == -1 || capacity > p.Running() {
p.lock.Unlock()
w = p.workerCache.Get().(*goWorker)
w.run()
return
}
// 走到这里表示没有获取到可用worker,也不能创建新的(数量达到限制)
// 根据是否配置了非阻塞模式,决定是直接返回错误,还是阻塞等待
if p.options.Nonblocking || (p.options.MaxBlockingTasks != 0 && p.Waiting() >= p.options.MaxBlockingTasks) {
p.lock.Unlock()
// 非阻塞模式: 返回错误
return nil, ErrPoolOverload
}
// 阻塞模式:在Pool.cond变量上阻塞,等待其他线程唤醒
p.addWaiting(1)
p.cond.Wait() // block and wait for an available worker
p.addWaiting(-1)
if p.IsClosed() {
p.lock.Unlock()
return nil, ErrPoolClosed
}
goto retry
}
上面代码可以看到,retrieveWorker() 方法中会不断循环尝试,直到获取到空闲worker。非阻塞模式下则会返回ErrPoolOverload
的错误。
5.4、worker处理流程
func (w *goWorker) run() {
w.pool.addRunning(1)
go func() {
defer func() {
if w.pool.addRunning(-1) == 0 && w.pool.IsClosed() {
w.pool.once.Do(func() {
close(w.pool.allDone)
})
}
w.pool.workerCache.Put(w)
if p := recover(); p != nil {
if ph := w.pool.options.PanicHandler; ph != nil {
ph(p)
} else {
w.pool.options.Logger.Printf("worker exits from panic: %v\n%s\n", p, debug.Stack())
}
}
// Call Signal() here in case there are goroutines waiting for available workers.
w.pool.cond.Signal()
}()
for f := range w.task {
if f == nil {
return
}
f()
if ok := w.pool.revertWorker(w); !ok {
return
}
}
}()
}
上面代码可以看到,worker处理流程非常简单,就是不断从绑定的task通道中获取任务并调用,每处理完一个任务都会调用revertWorker()
将当前worker再放加线程池的workers(空闲worker数组)中。
当worker生命周期结束时(线程数超限,或长时间没有新任务而被清理),调用w.pool.workerCache.Put(w)
把当前的worker放到线程池的workerCache
中,以便下次新创建线程时可以直接复用。
5.5、一些值得学习的细节
5.5.1、当前时间缓存
前面看在线程池创建的代码时,有一处goTicktock()
,我们来看一下他的具体实现:
func (p *Pool) ticktock() {
ticker := time.NewTicker(nowTimeUpdateInterval)
defer func() {
ticker.Stop()
atomic.StoreInt32(&p.ticktockDone, 1)
}()
ticktockCtx := p.ticktockCtx // copy to the local variable to avoid race from Reboot()
for {
select {
case <-ticktockCtx.Done():
return
case <-ticker.C:
}
if p.IsClosed() {
break
}
p.now.Store(time.Now())
}
}
默认每500ms获取一次系统时间,存储到线程池的now
变量,worker每处理完一个任务,都会从这个变量中获取当前时间,并记录为worker的最后工作时间,后续清理任务用来判断是否需要清理此线程。
这样实现的意义在于,减少调用系统时间的次数,每500ms调用一次。如果不缓存当前时间的话,处理每个任务后都会调用一次系统时间,任务量较大时性能就会更差。
当然上面这种实现方式弊端就是当前时间不精确,只能精确到0.5秒级别,对于这个线程池这个项目来说,这个时间只用于线程清理,清理周期都是以秒为单位,时间精度的损失完全可以忽略不计。