go实践01-如何实现一个轻量级线程池?

967 阅读4分钟

为什么要用到 Goroutine 池?

所以和其他语言不同的是,Go 应用通常可以为每个新建立的连接创建一个对应的新 Goroutine,甚至是为每个传入的请求生成一个 Goroutine 去处理。这种设计还有一个好处,实现起来十分简单,Gopher 们在编写代码时也没有很高的心智负担。

不过,Goroutine 的开销虽然“廉价”,但也不是免费的。

最明显的,一旦规模化后,这种非零成本也会成为瓶颈。我们以一个 Goroutine 分配 2KB 执行栈为例,100w Goroutine 就是 2GB 的内存消耗。

其次,Goroutine 从Go 1.4 版本开始采用了连续栈的方案,也就是每个 Goroutine 的执行栈都是一块连续内存,如果空间不足,运行时会分配一个更大的连续内存空间作为这个 Goroutine 的执行栈,将原栈内容拷贝到新分配的空间中来。

另外,随着 Goroutine 数量的增加,Go 运行时进行 Goroutine 调度的处理器消耗,也会随之增加,成为阻碍 Go 应用性能提升的重要因素。

另外,随着 Goroutine 数量的增加,Go 运行时进行 Goroutine 调度的处理器消耗,也会随之增加,成为阻碍 Go 应用性能提升的重要因素。

接下来,我们就来真正实现一个简单的 Goroutine 池,我们叫它 workerpool。

workerpool 的实现原理

workerpool 的实现主要分为三个部分:

  • pool 的创建与销毁;
  • pool 中 worker(Goroutine)的管理;
  • task 的提交与调度。

image.png capacity 是 pool 的一个属性,代表整个 pool 中 worker 的最大容量。我们使用一个带缓冲的 channel:active,作为 worker 的“计数器”,这种 channel 使用模式就是我们在第 33 讲中讲过的计数信号量

当 active channel 可写时,我们就创建一个 worker,用于处理用户通过 Schedule 函数提交的待处理的请求。当 active channel 满了的时候,pool 就会停止 worker 的创建,直到某个 worker 因故退出,active channel 又空出一个位置时,pool 才会创建新的 worker 填补那个空位。

这张图里,我们把用户要提交给 workerpool 执行的请求抽象为一个 Task。Task 的提交与调度也很简单:Task 通过 Schedule 函数提交到一个 task channel 中,已经创建的 worker 将从这个 task channel 中读取 task 并执行。

workerpool 的一个最小可行实现

我们先建立 workerpool 目录作为实战项目的源码根目录,然后为这个项目创建 go module:

$mkdir workerpool1
$cd workerpool1
$go mod init github.com/bigwhite/workerpool

接下来,我们创建 pool.go 作为 workpool 包的主要源码文件。在这个源码文件中,我们定义了 Pool 结构体类型,这个类型的实例代表一个 workerpool:

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 实例的:

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 方法:

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 类型实例的两个 channel:quit 和 active。这种在 for 中使用 select 监视多个 channel 的实现,在 Go 代码中十分常见,是一种惯用法。

当接收到来自 quit channel 的退出“信号”时,这个 Goroutine 就会结束运行。而当 active channel 可写时,run 方法就会创建一个新的 worker Goroutine。 此外,为了方便在程序中区分各个 worker 输出的日志,我这里将一个从 1 开始的变量 idx 作为 worker 的编号,并把它以参数的形式传给创建 worker 的方法。

我们再将创建新的 worker goroutine 的职责,封装到一个名为 newWorker 的方法中:

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 是一个对用户提交的请求的抽象,它的本质就是一个函数类型:

type Task func()

这样,用户通过 Schedule 方法实际上提交的是一个函数类型的实例。

在新 worker 中,为了防止用户提交的 task 抛出 panic,进而导致整个 workerpool 受到影响,我们在 worker 代码的开始处,使用了 defer+recover 对 panic 进行捕捉,捕捉后 worker 也是要退出的,于是我们还通过<-p.active更新了 worker 计数器。并且一旦 worker goroutine 退出,p.wg.Done 也需要被调用,这样可以减少 WaitGroup 的 Goroutine 等待数量。

我们再来看 workerpool 提供给用户提交请求的导出方法 Schedule:

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 实例发送到 workerpool 的 tasks channel 中。但考虑到现在 workerpool 已经被销毁的状态,我们这里通过一个 select,检视 quit channel 是否有“信号”可读,如果有,就返回一个哨兵错误 ErrWorkerPoolFreed。如果没有,一旦 p.tasks 可写,提交的 Task 就会被写入 tasks channel,以供 pool 中的 worker 处理。

这里要注意的是,这里的 Pool 结构体中的 tasks 是一个无缓冲的 channel,如果 pool 中 worker 数量已达上限,而且 worker 都在处理 task 的状态,那么 Schedule 方法就会阻塞,直到有 worker 变为 idle 状态来读取 tasks channel,schedule 的调用阻塞才会解除。

至此,workerpool 的最小可行实现的主要逻辑都实现完了。我们来验证一下它是否能按照我们的预期逻辑运行。

现在我们建立一个使用 workerpool 的项目 demo1:

$mkdir demo1
$cd demo1
$go mod init demo1

由于我们要引用本地的 module,所以我们需要手工修改一下 demo1 的 go.mod 文件,并利用 replace 指示符将 demo1 对 workerpool 的引用指向本地 workerpool1 路径:

module demo1

go 1.17

require github.com/bigwhite/workerpool v1.0.0

replace github.com/bigwhite/workerpool v1.0.0 => ../workerpool1

然后创建 demo1 的 main.go 文件,源码如下:

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()
}

这个示例程序创建了一个 capacity 为 5 的 workerpool 实例,并连续向这个 workerpool 提交了 10 个 task,每个 task 的逻辑很简单,只是 Sleep 3 秒后就退出。main 函数在提交完任务后,调用 workerpool 的 Free 方法销毁 pool,pool 会等待所有 worker 执行完 task 后再退出。

demo1 示例的运行结果如下:

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 Pike 在 2014 年在博文《自引用函数与选项设计》中论述的一种,这种方案也被后人称为“功能选项(functional option)”方案。

接下来,我们就来看看如何使用 Rob Pike 的这种“功能选项”方案,让 workerpool 支持行为定制机制。

首先,我们将 workerpool1 目录拷贝一份形成 workerpool2 目录,我们将在这个目录下为 workerpool 包添加功能选项机制。

然后,我们在 workerpool2 目录下创建 option.go 文件,在这个文件中,我们定义用于代表功能选项的类型 Option:

type Option func(*Pool)

我们看到,这个 Option 实质是一个接受 *Pool 类型参数的函数类型。那么如何运用这个 Option 类型呢?别急,马上你就会知道。现在我们先要做的是,明确给 workerpool 添加什么功能选项。这里我们为 workerpool 添加两个功能选项:Schedule 调用是否阻塞,以及是否预创建所有的 worker。

为了支持这两个功能选项,我们需要在 Pool 类型中增加两个 bool 类型的字段,字段的具体含义,我也在代码中注释了:

type Pool struct {
    ... ...
    preAlloc bool // 是否在创建pool的时候就预创建workers,默认值为:false

    // 当pool满的情况下,新的Schedule调用是否阻塞当前goroutine。默认值:true
    // 如果block = false,则Schedule返回ErrNoWorkerAvailInPool
    block  bool
    ... ...
}

针对这两个字段,我们在 option.go 中添加两个功能选项,WithBlock 与 WithPreAllocWorkers:

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 函数代码如下:

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 选项的加入,Pool 的 run 方法的实现有了变化,我们来看一下:

 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 选项,有了一些变化:

 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
     }
 }

Schedule 在 tasks chanel 无法写入的情况下,进入 default 分支。在 default 分支中,Schedule 根据 block 字段的值,决定究竟是继续阻塞在 tasks channel 上,还是返回 ErrNoIdleWorkerInPool 错误。

和第一版 worker 代码一样,我们也来验证一下新增的功能选项是否好用。我们建立一个使用新版 workerpool 的项目 demo2,demo2 的 go.mod 与 demo1 的 go.mod 相似:

module demo2

go 1.17

require github.com/bigwhite/workerpool v1.0.0

replace github.com/bigwhite/workerpool v1.0.0 => ../workerpool2

demo2 的 main.go 文件如下:

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,我们会得到这个结果:

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 调用上。

此文章为3月Day26学习笔记,内容来源于极客时间《Tony Bai · Go 语言第一课》。