You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

535 lines
22 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 35即学即练如何实现一个轻量级线程池
你好我是Tony Bai。
在这一讲的开始首先恭喜你完成了这门课核心篇语法部分的学习。这一部分的篇幅不多主要讲解了Go的两个核心语法知识点接口与并发原语。它们分别是耦合设计与并发设计的主要参与者Go应用的骨架设计离不开它们。
但理论和实践毕竟是两回事,学完了基本语法,也需要实操来帮助我们落地。所以,在这核心篇的最后一讲,我依然会用一个小实战项目,帮助你学会灵活运用这部分的语法点。
不过,关于接口类型做为“关节”作用的演示,我们前面的两个小实战项目中都有一定的体现了,只是那时还没有讲到接口类型,你现在可以停下来,回顾一下[09讲](https://time.geekbang.org/column/article/434017)和[27讲](https://time.geekbang.org/column/article/471138)的代码,看看是否有更深刻的体会。
而且接口类型对Go应用静态骨架的编织作用在接口类型数量较多的项目中体现得更明显由于篇幅有限我很难找到一个合适的演示项目。
因此这一讲的实战项目我们主要围绕Go并发来做实现一个轻量级线程池也就是Goroutine池。
## 为什么要用到Goroutine池
在[第31讲](https://time.geekbang.org/column/article/475959)学习Goroutine的时候我们就说过相对于操作系统线程Goroutine的开销十分小一个Goroutine的起始栈大小为2KB而且创建、切换与销毁的代价很低我们可以创建成千上万甚至更多Goroutine。
所以和其他语言不同的是Go应用通常可以为每个新建立的连接创建一个对应的新Goroutine甚至是为每个传入的请求生成一个Goroutine去处理。这种设计还有一个好处实现起来十分简单Gopher们在编写代码时也没有很高的心智负担。
**不过Goroutine的开销虽然“廉价”但也不是免费的**。
最明显的一旦规模化后这种非零成本也会成为瓶颈。我们以一个Goroutine分配2KB执行栈为例100w Goroutine就是2GB的内存消耗。
其次Goroutine从[Go 1.4版本](https://go.dev/doc/go1.4)开始采用了连续栈的方案也就是每个Goroutine的执行栈都是一块连续内存如果空间不足运行时会分配一个更大的连续内存空间作为这个Goroutine的执行栈将原栈内容拷贝到新分配的空间中来。
连续栈的方案虽然能避免Go 1.3采用的分段栈会导致的[“hot split”问题](https://docs.google.com/document/d/1wAaf1rYoM4S4gtnPh0zOlGzWtrZFQ5suE8qr2sD8uWQ/pub)但连续栈的原理也决定了一旦Goroutine的执行栈发生了grow那么即便这个Goroutine不再需要那么大的栈空间这个Goroutine的栈空间也不会被Shrink收缩这些空间可能会处于长时间闲置的状态直到Goroutine退出。
另外随着Goroutine数量的增加Go运行时进行Goroutine调度的处理器消耗也会随之增加成为阻碍Go应用性能提升的重要因素。
那么面对这样的问题,常见的应对方式是什么呢?
Goroutine池就是一种常见的解决方案。这个方案的核心思想是对Goroutine的重用也就是把M个计算任务调度到N个Goroutine上而不是为每个计算任务分配一个独享的Goroutine从而提高计算资源的利用率。
接下来我们就来真正实现一个简单的Goroutine池我们叫它**workerpool**。
## workerpool的实现原理
workerpool的工作逻辑通常都很简单所以即便是用于生产环境的workerpool实现代码规模也都在千行左右。
当然workerpool有很多种实现方式这里为了更好地演示Go并发模型的应用模式以及并发原语间的协作我们采用完全基于channel+select的实现方案不使用其他数据结构也不使用sync包提供的各种同步结构比如Mutex、RWMutex以及Cond等。
workerpool的实现主要分为三个部分
* pool的创建与销毁
* pool中workerGoroutine的管理
* task的提交与调度。
其中后两部分是pool的“精髓”所在这两部分的原理我也用一张图表示了出来
![图片](https://static001.geekbang.org/resource/image/d4/fd/d48ba3a204ca6e8961a4425573afa0fd.jpg?wh=1920x1047)
我们先看一下图中pool对worker的管理。
capacity是pool的一个属性代表整个pool中worker的最大容量。我们使用一个带缓冲的channelactive作为worker的“计数器”这种channel使用模式就是我们在第33讲中讲过的**计数信号量**,如果记不太清了可以复习一下[第33讲](https://time.geekbang.org/column/article/477365)中的相关内容。
当active channel可写时我们就创建一个worker用于处理用户通过Schedule函数提交的待处理的请求。当active channel满了的时候pool就会停止worker的创建直到某个worker因故退出active channel又空出一个位置时pool才会创建新的worker填补那个空位。
这张图里我们把用户要提交给workerpool执行的请求抽象为一个Task。Task的提交与调度也很简单Task通过Schedule函数提交到一个task channel中已经创建的worker将从这个task channel中读取task并执行。
好了“Talk is cheapshow me the code”接下来我们就来写一版workerpool的代码来验证一下这里分析的原理是否可行。
## workerpool的一个最小可行实现
我们先建立workerpool目录作为实战项目的源码根目录然后为这个项目创建go module
```plain
$mkdir workerpool1
$cd workerpool1
$go mod init github.com/bigwhite/workerpool
```
接下来我们创建pool.go作为workpool包的主要源码文件。在这个源码文件中我们定义了Pool结构体类型这个类型的实例代表一个workerpool
```plain
type Pool struct {
capacity int // workerpool大小
active chan struct{} // 对应上图中的active channel
tasks chan Task // 对应上图中的task channel
wg sync.WaitGroup // 用于在pool销毁时等待所有worker退出
quit chan struct{} // 用于通知各个worker退出的信号channel
}
```
workerpool包对外主要提供三个API它们分别是
* workerpool.New用于创建一个pool类型实例并将pool池的worker管理机制运行起来
* workerpool.Free用于销毁一个pool池停掉所有pool池中的worker
* Pool.Schedule这是Pool类型的一个导出方法workerpool包的用户通过该方法向pool池提交待执行的任务Task
接下来我们就重点看看这三个API的实现。
我们先来看看workerpool.New是如何创建一个pool实例的
```plain
func New(capacity int) *Pool {
if capacity <= 0 {
capacity = defaultCapacity
}
if capacity > maxCapacity {
capacity = maxCapacity
}
p := &Pool{
capacity: capacity,
tasks: make(chan Task),
quit: make(chan struct{}),
active: make(chan struct{}, capacity),
}
fmt.Printf("workerpool start\n")
go p.run()
return p
}
```
我们看到New函数接受一个参数capacity用于指定workerpool池的容量这个参数用于控制workerpool最多只能有capacity个worker共同处理用户提交的任务请求。函数开始处有一个对capacity参数的“防御性”校验当用户传入不合理的值时函数New会将它纠正为合理的值。
Pool类型实例变量p完成初始化后我们创建了一个新的Goroutine用于对workerpool进行管理这个Goroutine执行的是Pool类型的run方法
```plain
func (p *Pool) run() {
idx := 0
for {
select {
case <-p.quit:
return
case p.active <- struct{}{}:
// create a new worker
idx++
p.newWorker(idx)
}
}
}
```
run方法内是一个无限循环循环体中使用select监视Pool类型实例的两个channelquit和active。这种在for中使用select监视多个channel的实现在Go代码中十分常见是一种惯用法。
当接收到来自quit channel的退出“信号”时这个Goroutine就会结束运行。而当active channel可写时run方法就会创建一个新的worker Goroutine。 此外为了方便在程序中区分各个worker输出的日志我这里将一个从1开始的变量idx作为worker的编号并把它以参数的形式传给创建worker的方法。
我们再将创建新的worker goroutine的职责封装到一个名为newWorker的方法中
```plain
func (p *Pool) newWorker(i int) {
p.wg.Add(1)
go func() {
defer func() {
if err := recover(); err != nil {
fmt.Printf("worker[%03d]: recover panic[%s] and exit\n", i, err)
<-p.active
}
p.wg.Done()
}()
fmt.Printf("worker[%03d]: start\n", i)
for {
select {
case <-p.quit:
fmt.Printf("worker[%03d]: exit\n", i)
<-p.active
return
case t := <-p.tasks:
fmt.Printf("worker[%03d]: receive a task\n", i)
t()
}
}
}()
}
```
我们看到在创建一个新的worker goroutine之前newWorker方法会先调用p.wg.Add方法将WaitGroup的等待计数加一。由于每个worker运行于一个独立的Goroutine中newWorker方法通过go关键字创建了一个新的Goroutine作为worker。
新worker的核心依然是一个基于for-select模式的循环语句在循环体中新worker通过select监视quit和tasks两个channel。和前面的run方法一样当接收到来自quit channel的退出“信号”时这个worker就会结束运行。tasks channel中放置的是用户通过Schedule方法提交的请求新worker会从这个channel中获取最新的Task并运行这个Task。
Task是一个对用户提交的请求的抽象它的本质就是一个函数类型
```plain
type Task func()
```
这样用户通过Schedule方法实际上提交的是一个函数类型的实例。
在新worker中为了防止用户提交的task抛出panic进而导致整个workerpool受到影响我们在worker代码的开始处使用了defer+recover对panic进行捕捉捕捉后worker也是要退出的于是我们还通过`<-p.active`更新了worker计数器。并且一旦worker goroutine退出,p.wg.Done也需要被调用,这样可以减少WaitGroupGoroutine等待数量。
我们再来看workerpool提供给用户提交请求的导出方法Schedule
```plain
var ErrWorkerPoolFreed = errors.New("workerpool freed") // workerpool已终止运行
func (p *Pool) Schedule(t Task) error {
select {
case <-p.quit:
return ErrWorkerPoolFreed
case p.tasks <- t:
return nil
}
}
```
Schedule方法的核心逻辑,是将传入的Task实例发送到workerpooltasks channel中。但考虑到现在workerpool已经被销毁的状态,我们这里通过一个select,检视quit channel是否有“信号”可读,如果有,就返回一个哨兵错误ErrWorkerPoolFreed。如果没有,一旦p.tasks可写,提交的Task就会被写入tasks channel,以供pool中的worker处理。
这里要注意的是,这里的Pool结构体中的tasks是一个无缓冲的channel,如果poolworker数量已达上限,而且worker都在处理task的状态,那么Schedule方法就会阻塞,直到有worker变为idle状态来读取tasks channelschedule的调用阻塞才会解除。
至此,workerpool的最小可行实现的主要逻辑都实现完了。我们来验证一下它是否能按照我们的预期逻辑运行。
现在我们建立一个使用workerpool的项目demo1
```plain
$mkdir demo1
$cd demo1
$go mod init demo1
```
由于我们要引用本地的module,所以我们需要手工修改一下demo1go.mod文件,并利用replace指示符将demo1workerpool的引用指向本地workerpool1路径:
```plain
module demo1
go 1.17
require github.com/bigwhite/workerpool v1.0.0
replace github.com/bigwhite/workerpool v1.0.0 => ../workerpool1
```
然后创建demo1main.go文件,源码如下:
```plain
package main
import (
"time"
"github.com/bigwhite/workerpool"
)
func main() {
p := workerpool.New(5)
for i := 0; i < 10; i++ {
err := p.Schedule(func() {
time.Sleep(time.Second * 3)
})
if err != nil {
println("task: ", i, "err:", err)
}
}
p.Free()
}
```
这个示例程序创建了一个capacity5workerpool实例,并连续向这个workerpool提交了10task,每个task的逻辑很简单,只是Sleep 3秒后就退出。main函数在提交完任务后,调用workerpoolFree方法销毁poolpool会等待所有worker执行完task后再退出。
demo1示例的运行结果如下:
```plain
workerpool start
worker[005]: start
worker[005]: receive a task
worker[003]: start
worker[003]: receive a task
worker[004]: start
worker[004]: receive a task
worker[001]: start
worker[002]: start
worker[001]: receive a task
worker[002]: receive a task
worker[004]: receive a task
worker[005]: receive a task
worker[003]: receive a task
worker[002]: receive a task
worker[001]: receive a task
worker[001]: exit
worker[005]: exit
worker[002]: exit
worker[003]: exit
worker[004]: exit
workerpool freed
```
从运行的输出结果来看,workerpool的最小可行实现的运行逻辑与我们的原理图是一致的。
不过,目前的workerpool实现好比“铁板一块”,虽然我们可以通过capacity参数可以指定workerpool容量,但我们无法对workerpool的行为进行定制。
比如当workerpool中的worker数量已达上限,而且worker都在处理task时,用户调用Schedule方法将阻塞,如果用户不想阻塞在这里,以我们目前的实现是做不到的。
那我们可以怎么改进呢?我们可以尝试在上面实现的基础上,为workerpool添加功能选项(functional option)机制。
## 添加功能选项机制
功能选项机制,可以让某个包的用户可以根据自己的需求,通过设置不同功能选项来定制包的行为。Go语言中实现功能选项机制有多种方法,但Go社区目前使用最为广泛的一个方案,是Go语言之父Rob Pike2014年在博文[《自引用函数与选项设计》](https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html)中论述的一种,这种方案也被后人称为“功能选项(functional option)”方案。
接下来,我们就来看看如何使用Rob Pike的这种“功能选项”方案,让workerpool支持行为定制机制。
首先,我们将workerpool1目录拷贝一份形成workerpool2目录,我们将在这个目录下为workerpool包添加功能选项机制。
然后,我们在workerpool2目录下创建option.go文件,在这个文件中,我们定义用于代表功能选项的类型Option
```plain
type Option func(*Pool)
```
我们看到,这个Option实质是一个接受\*Pool类型参数的函数类型。那么如何运用这个Option类型呢?别急,马上你就会知道。现在我们先要做的是,明确给workerpool添加什么功能选项。这里我们为workerpool添加两个功能选项:Schedule调用是否阻塞,以及是否预创建所有的worker
为了支持这两个功能选项,我们需要在Pool类型中增加两个bool类型的字段,字段的具体含义,我也在代码中注释了:
```plain
type Pool struct {
... ...
preAlloc bool // 是否在创建pool的时候就预创建workers默认值为false
// 当pool满的情况下新的Schedule调用是否阻塞当前goroutine。默认值true
// 如果block = false则Schedule返回ErrNoWorkerAvailInPool
block bool
... ...
}
```
针对这两个字段,我们在option.go中添加两个功能选项,WithBlockWithPreAllocWorkers
```plain
func WithBlock(block bool) Option {
return func(p *Pool) {
p.block = block
}
}
func WithPreAllocWorkers(preAlloc bool) Option {
return func(p *Pool) {
p.preAlloc = preAlloc
}
}
```
我们看到,这两个功能选项实质上是两个返回闭包函数的函数。
为了支持将这两个Option传给workerpool,我们还需要改造一下workerpool包的New函数,改造后的New函数代码如下:
```plain
func New(capacity int, opts ...Option) *Pool {
... ...
for _, opt := range opts {
opt(p)
}
fmt.Printf("workerpool start(preAlloc=%t)\n", p.preAlloc)
if p.preAlloc {
// create all goroutines and send into works channel
for i := 0; i < p.capacity; i++ {
p.newWorker(i + 1)
p.active <- struct{}{}
}
}
go p.run()
return p
}
```
新版New函数除了接受capacity参数之外,还在它的参数列表中增加了一个类型为Option的可变长参数opts。在New函数体中,我们通过一个for循环,将传入的Option运用到Pool类型的实例上。
新版New函数还会根据preAlloc的值来判断是否预创建所有的worker,如果需要,就调用newWorker方法把所有worker都创建出来。newWorker的实现与上一版代码并没有什么差异,这里就不再详说了。
但由于preAlloc选项的加入,Poolrun方法的实现有了变化,我们来看一下:
```plain
func (p *Pool) run() {
idx := len(p.active)
if !p.preAlloc {
loop:
for t := range p.tasks {
p.returnTask(t)
select {
case <-p.quit:
return
case p.active <- struct{}{}:
idx++
p.newWorker(idx)
default:
break loop
}
}
}
for {
select {
case <-p.quit:
return
case p.active <- struct{}{}:
// create a new worker
idx++
p.newWorker(idx)
}
}
}
```
新版run方法在preAlloc=false时会根据tasks channel的情况在适合的时候创建worker(第4行~第18行),直到active channel写满,才会进入到和第一版代码一样的调度逻辑中(第20行~第29行)。
而且,提供给用户的Schedule函数也因WithBlock选项,有了一些变化:
```plain
func (p *Pool) Schedule(t Task) error {
select {
case <-p.quit:
return ErrWorkerPoolFreed
case p.tasks <- t:
return nil
default:
if p.block {
p.tasks <- t
return nil
}
return ErrNoIdleWorkerInPool
}
}
```
Scheduletasks chanel无法写入的情况下,进入default分支。在default分支中,Schedule根据block字段的值,决定究竟是继续阻塞在tasks channel上,还是返回ErrNoIdleWorkerInPool错误。
和第一版worker代码一样,我们也来验证一下新增的功能选项是否好用。我们建立一个使用新版workerpool的项目demo2demo2go.moddemo1go.mod相似:
```plain
module demo2
go 1.17
require github.com/bigwhite/workerpool v1.0.0
replace github.com/bigwhite/workerpool v1.0.0 => ../workerpool2
```
demo2main.go文件如下:
```plain
package main
import (
"fmt"
"time"
"github.com/bigwhite/workerpool"
)
func main() {
p := workerpool.New(5, workerpool.WithPreAllocWorkers(false), workerpool.WithBlock(false))
time.Sleep(time.Second * 2)
for i := 0; i < 10; i++ {
err := p.Schedule(func() {
time.Sleep(time.Second * 3)
})
if err != nil {
fmt.Printf("task[%d]: error: %s\n", i, err.Error())
}
}
p.Free()
}
```
demo2中,我们使用workerpool包提供的功能选项,设置了我们期望的workerpool的运作行为,包括不要预创建worker,以及不要阻塞Schedule调用。
考虑到Goroutine调度的次序的不确定性,这里我在创建workerpool与真正开始调用Schedule方法之间,做了一个Sleep,尽量减少Schedule都返回失败的频率(但这仍然无法保证这种情况不会发生)。
运行demo2,我们会得到这个结果:
```plain
workerpool start(preAlloc=false)
task[1]: error: no idle worker in pool
worker[001]: start
task[2]: error: no idle worker in pool
task[4]: error: no idle worker in pool
task[5]: error: no idle worker in pool
task[6]: error: no idle worker in pool
task[7]: error: no idle worker in pool
task[8]: error: no idle worker in pool
task[9]: error: no idle worker in pool
worker[001]: receive a task
worker[002]: start
worker[002]: exit
worker[001]: receive a task
worker[001]: exit
workerpool freed(preAlloc=false)
```
不过,由于Goroutine调度的不确定性,这个结果仅仅是很多种结果的一种。我们看到,仅仅001这个worker收到了task,其余的worker都因为worker尚未创建完毕,而返回了错误,而不是像demo1那样阻塞在Schedule调用上。
## 小结
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
在这一讲中,我们基于我们前面所讲的Go并发方面的内容,设计并实现了一个workerpool的最小可行实现,只用了不到200行代码。为了帮助你理解Go并发原语是如何运用的,这个workerpool实现完全基于channel+select,并没有使用到sync包提供的各种锁。
我们还基于workerpool的最小可行实现,为这个pool增加了功能选项的支持,我们采用的功能选项方案也是Go社区最为流行的方案,日常编码中如果你遇到了类似的需求可以重点参考。
最后我要提醒你:上面设计与实现的workerpool只是一个演示项目,不能作为生产项目使用。
## 思考题
关于workerpool这样的项目,如果让你来设计,你的设计思路是什么,不妨在留言区敞开谈谈?
欢迎你把这节课分享给更多感兴趣的朋友。我是Tony Bai,我们下节课见。
### [今天的项目源码在这里!](https://github.com/bigwhite/publication/tree/master/column/timegeek/go-first-course/35)