跳转至

05 单机吞吐优化(三):科学复用对象和协程资源

你好,我是徐逸。

上节课我们学习了几个高性能数据处理的技巧,它们能有效降低单个请求对 CPU 和内存资源的消耗,提升单机吞吐。

今天呢,咱们来聊聊另一种优化技巧 —— 资源复用。当我们在处理请求的时候,对于用完的临时对象或者协程,可以不立马销毁,而是把它存起来。等到处理其他请求的时候,我们可以直接复用之前创建好的对象或者协程。这样可以避免频繁地创建和销毁消耗CPU,让更多的CPU资源用于运行业务逻辑代码,提升单机吞吐。

在我开始讲具体的资源复用技巧之前,你可以先琢磨一下,要是我们不停地创建临时对象,Golang 运行时的哪些操作会消耗 CPU 资源呢?

对象池:如何避免频繁分配相同类型临时对象的开销?

实际上,如果频繁创建临时对象的话,Golang 运行时会在下面这两个操作上产生较大的开销。

首先是内存分配。我们不停地创建对象时,就得不断地在堆里面找空闲的内存块,然后进行分配。这就像是在一个大仓库里,每次创建新对象都要重新找地方放,这个过程是很消耗资源的。

再就是垃圾收集(GC)。要是临时对象很多,那在进行垃圾收集的时候,就需要耗费更多的 CPU 资源去扫描这些对象,看看哪些没用了,然后清理掉。这就像大扫除的时候,如果杂物太多,打扫起来就会更费劲。

那到底是不是这样呢?咱们可以用下面的代码验证一下。这段代码会不断地创建bytes.Buffer临时对象。

var data = make([]byte, 1000)

func WriteBufferNoPool() {
    var buf bytes.Buffer
    buf.Write(data)
}

func BenchmarkNoPool(b *testing.B) {
    for i := 0; i < b.N; i++ {
        // 请求处理逻辑
        WriteBufferNoPool()
    }
}

现在让我们基于前面的代码来生成并查看一下CPU火焰图。

go test -bench=BenchmarkNoPool -benchmem -cpuprofile='cpu.pprof'
go tool pprof -http :8889 cpu.pprof

从火焰图里我们能发现,在 WriteBufferNoPool 函数的调用栈里,消耗 CPU 资源最多的是 mallocgc 函数,这个函数就是专门负责堆内存分配的。不仅如此,火焰图里有不少以 gc 开头的函数,这些函数就是 Golang 在运行垃圾收集的时候调用的。所以呢,通过火焰图我们就可以知道,频繁创建临时对象,真的会在内存分配和 GC 方面产生很大的开销。

那问题就来了,有没有啥办法能减少因为频繁创建临时对象而产生的内存分配和 GC 开销呢?

由于我们每次申请的都是同一种类型的对象,也就是 bytes.Buffer。既然这样,像下面的图一样,我们可以在每次用完这个临时对象之后,把它放到一个池子里。等下次要用的时候,就直接从池子里取出来用。这样一来,就不用每次都从堆里重新分配,而且 GC 需要扫描的临时对象也会减少,这就是对象池的思想。

对于对象池,Golang 里有个 sync.Pool 类型来支持它。那这个 sync.Pool 是怎么用的呢?

首先,就像下面这段代码显示的那样,我们得定义一个 New 函数。为啥要定义这个呢?因为当我们从对象池里取对象的时候,如果池子里没有对象,就需要调用这个 New 函数来创建。

var objectPool = sync.Pool{
    New: func() interface{} {
        return &bytes.Buffer{}
    },
}

然后,当我们使用sync.Pool的时候,像下面的代码一样,是通过调用 Get 和 Put 这两个方法,来实现从池里取出临时对象和把临时对象放回池里这两个操作的。

func WriteBufferWithPool() {
    // 获取临时对象
    buf := objectPool.Get().(*bytes.Buffer)
    // 使用
    buf.Write(data)
    // 将buf对象里面的字段恢复初始值
    buf.Reset()
    // 放回
    objectPool.Put(buf)
}

对象池的使用,能给咱们带来多大的性能提升呢?咱们用Benchmark来测一测。

下面是Benchmark脚本:

func BenchmarkNoPool(b *testing.B) {
    for i := 0; i < b.N; i++ {
        // 请求处理逻辑
        WriteBufferNoPool()
    }
}

func BenchmarkWithPool(b *testing.B) {
    for i := 0; i < b.N; i++ {
        // 请求处理逻辑
        WriteBufferWithPool()
    }
}

测试结果出来了,使用对象池的方式,性能提升明显。

killianxu@KILLIANXU-MB0 5 % go test -benchmem  -bench=. 
BenchmarkNoPool-4        5402967               232.2 ns/op          1024 B/op          1 allocs/op
BenchmarkWithPool-4     37644055                32.81 ns/op            0 B/op          0 allocs/op
  • 从 CPU 资源消耗来看,不使用对象池,单次函数调用要232.2ns,而使用对象池的方式,只要32.81ns,节约了 86% 左右的 CPU 资源。
  • 从内存消耗来看,使用对象池的方式,平均单次请求从堆中分配的大小和次数直接变成了0。

其实,对象池在Golang标准库中也有很多使用。比如咱们很常用的fmt.Printf()函数,就像下面的代码一样,在它的底层实现中就会调用newPrinter函数和p.free()方法。

func Printf(format string, a ...any) (n int, err error) {
    return Fprintf(os.Stdout, format, a...)
}

func Fprintf(w io.Writer, format string, a ...any) (n int, err error) {
    p := newPrinter()
    p.doPrintf(format, a)
    n, err = w.Write(p.buf)
    p.free()
    return
}

它的newPrinter函数就会从一个对象池里获取pp对象。

// pp is used to store a printer's state and is reused with sync.Pool to avoid allocations.
type pp struct {
    buf buffer
    ...
}

var ppFree = sync.Pool{
    New: func() any { return new(pp) },
}

// newPrinter allocates a new pp struct or grabs a cached one.
func newPrinter() *pp {
    p := ppFree.Get().(*pp)
    p.panicking = false
    p.erroring = false
    p.wrapErrs = false
    p.fmt.init(&p.buf)
    return p
}

而p.free()方法就是把临时对象放回对象池中。

// free saves used pp structs in ppFree; avoids an allocation per invocation.
func (p *pp) free() {
    // Proper usage of a sync.Pool requires each entry to have approximately
    // the same memory cost. To obtain this property when the stored type
    // contains a variably-sized buffer, we add a hard limit on the maximum
    // buffer to place back in the pool. If the buffer is larger than the
    // limit, we drop the buffer and recycle just the printer.
    //
    // See https://golang.org/issue/23199.
    if cap(p.buf) > 64*1024 {
        p.buf = nil
    } else {
        p.buf = p.buf[:0]
    }
    ppFree.Put(p)
}

协程池:如何避免频繁创建协程的开销?

实际上,不只是前面提到的频繁创建临时对象会带来问题,在Golang中,要是频繁地创建协程,也会产生运行时开销。那频繁创建协程到底会带来哪些开销呢?

频繁创建协程,主要会带来这两个方面的开销。

  1. 第一就是内存分配。在Golang中,每次创建一个协程,默认都得分配 2KB 的内存空间,而且还要进行初始化。要是不停地创建协程,那就得不停地进行内存分配和初始化,这个过程就需要消耗不少CPU资源。
  2. 第二就是协程调度。特别是那种 CPU 密集型的应用,如果频繁创建协程,将会导致处于就绪状态、能被 CPU 调度的协程过多。协程调度就会变得很频繁,要知道,协程调度这个过程本身也是会消耗 CPU 资源的。

那到底是不是这样呢?咱们可以用下面的代码验证一下。这段代码会不断地做创建协程操作。

const benchmarkTimes = 10000

func handler(a, b int) int {
    if b < 100 {
        return handler(0, b+1)
    }
    return 0
}

func BenchmarkGo(b *testing.B) {
    var wg sync.WaitGroup
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        wg.Add(benchmarkTimes)
        for j := 0; j < benchmarkTimes; j++ {
            go func() {
                handler(0, 0)
                wg.Done()
            }()
        }
        wg.Wait()
    }
}

现在让我们基于前面的代码来生成并查看一下CPU火焰图。看一下这段代码的运行,除了咱们的handler业务逻辑函数,还会有哪些函数会消耗CPU资源。

go test -bench=BenchmarkGo -benchmem -cpuprofile='cpu.pprof'
go tool pprof -http :8889 cpu.pprof

从图里我们能发现,除了 handler 这个业务逻辑方法外,runtime.newproc 和 runtime.schedule 这两个函数也占用了一部分 CPU 资源。而这两个函数就是Go协程创建和调度逻辑。所以呢,通过火焰图我们就可以知道,要是频繁地创建协程,就会产生协程创建方面的开销。而且如果协程数量太多了,那协程调度产生的开销也会很大。

那我们有没有办法减少协程创建和调度开销呢?

和前面的对象池思路类似,咱们可以创建一个协程池。

就像后面图里展示的这样,不同的请求需要使用协程运行任务时,不用重新创建协程,而是将自己的任务加到任务通道中,协程池中的协程从通道中获取任务运行,这样就实现了协程共用,避免频繁创建协程。同时,对于协程池中协程的数目,咱们也可以限制一下,避免同时处于就绪态的协程数目过多,导致需要频繁地进行协程调度。

这么说可能有点抽象。接下来,我们结合一个简单的协程池代码直观感受一下。

首先,就像下面这段代码展示的那样,我们可以创建一个叫做 WorkerPool 的协程池结构体。在这个结构体的构造函数里,启动一定数量的协程。这些协程会一直从任务通道 tasks 里获取任务来运行。

type handlerFunc func()

// 工作池
type WorkerPool struct {
    WorkNum int
    tasks   chan handlerFunc
}

// 构造函数,启动固定数目协程
func NewWorkerPool(workNum int) *WorkerPool {
    w := &WorkerPool{WorkNum: workNum, tasks: make(chan handlerFunc)}
    w.start()
    return w
}
func (w *WorkerPool) start() {
    for i := 0; i < w.WorkNum; i++ {
        go func() {
            for task := range w.tasks {
                task()
            }
        }()
    }
}

接着,我们可以给 WorkerPool 添加一个方法。这个方法的作用就是往通道里面写入任务。这样一来,我们就成功实现了一个简单的协程池类型,也就是 WorkerPool。

func (w *WorkerPool) addTask(task handlerFunc) {
    w.tasks <- task
}

那这个协程池要怎么使用呢?我们可以像下面的代码那样,通过 NewWorkerPool 来创建协程池,然后使用 pool.addTask 把我们的业务逻辑添加到协程池里,这样协程就可以运行这些业务逻辑了。

func useWorkerPool() {
    pool := NewWorkerPool(5)
    var wg sync.WaitGroup
    for j := 0; j < benchmarkTimes; j++ {
        wg.Add(1)
        pool.addTask(func() {
            // 业务逻辑处理
            handler(0, 0)
            wg.Done()
        })
    }
    wg.Wait()
}

那么使用协程池和不使用协程池的方式,性能差异有多大呢?我们还是用 Benchmark 脚本测一下。

// 不使用协程池
func BenchmarkGo(b *testing.B) {
    var wg sync.WaitGroup
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        wg.Add(benchmarkTimes)
        for j := 0; j < benchmarkTimes; j++ {
            go func() {
                handler(0, 0)
                wg.Done()
            }()
        }
        wg.Wait()
    }
}
// 使用协程池
func BenchmarkWorkerPool(b *testing.B) {
    workNum := runtime.GOMAXPROCS(0)
    var wg sync.WaitGroup
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        pool := NewWorkerPool(workNum)
        for j := 0; j < benchmarkTimes; j++ {
            wg.Add(1)
            pool.addTask(func() {
                handler(0, 0)
                wg.Done()
            })

        }
        wg.Wait()
    }

}

果然,使用协程池的方式,性能提升非常明显。并发运行1w个任务,使用协程池只需要5.6ms,而不使用协程池需要41ms,有7倍多的性能提升。

killianxu@KILLIANXU-MB0 5 % go test -bench=. -benchmem
BenchmarkGo-4                         27          41407678 ns/op          160019 B/op      10001 allocs/op
BenchmarkWorkerPool-4                207           5608174 ns/op          160576 B/op      10010 allocs/op

当然,你在实际应用的时候,其实并不需要去实现协程池,因为有不少好用的第三方库可供我们选择。在众多第三方库里面,下面这几个就比较受欢迎。

这里我们重点了解一下 gopool 协程池。gopool 是字节开源的高性能协程池,它能够实现协程的复用,并且还可以对协程的最大数目加以限制。咱们来看看它具体是怎么使用的。

gopool 的使用方法特别简单。就像下面的这段代码,我们只需要把原本用 go 关键字去创建协程的地方,替换成调用 gopool.Go 方法,就能轻松使用默认的协程池运行咱们的逻辑代码。

package main

import (
    "github.com/bytedance/gopkg/util/gopool"
)

func main() {
    // 创建协程运行
    go func() {
        // do your job
    }()
    // 用默认协程池运行
    gopool.Go(func() {
        /// do your job
    })
}

当然,默认协程池的最大协程数目被限制为 math.MaxInt32。要是我们觉得这个值太大,会导致创建的协程过多,从而引发协程频繁进行调度。我们可以像下面一样,对协程池的最大协程数目进行限制。

// Set GOMAXPROCS the size of goroutine pool
p := gopool.NewPool("benchmark", int32(runtime.GOMAXPROCS(0)),nil)

看到这里你可能会问,那这个最大协程数目设置成多少合适呢?

最大协程数目的设置,并没有固定的公式,但我们可以根据任务类型来预估一下,之后再通过压力测试来进一步优化。

  • 对于 CPU 密集型任务,我们可以将协程数目设置得比 CPU 核心数略大一些,一般可以设置为 CPU 核心数的倍数。不过要注意不能超出核心数太多。因为一旦超出过多,就会导致协程调度过于频繁,进而增加 CPU 的开销。
  • 而对于 I/O 密集型任务,协程数目则可以比 CPU 核心数大许多。这样的话,当某个协程因 I/O 操作而被阻塞时,其他协程就能够充分利用 CPU 资源。通常情况下,I/O 阻塞的时间越长,协程数目可以相应设置得越大。
  • 对于混合型任务,其协程数目的设置值应介于 CPU 密集型任务和 I/O 密集型任务的协程数目之间。

在根据任务类型对协程数目完成初步设置之后,我们还要通过压测来对其进一步优化。在压测的过程中,倘若发现 CPU 利用率较低,然而任务处理耗时却大幅增长,这可能是由于存在一些任务因协程不足而处于阻塞状态。在这种情况下,我们便需要增大协程数目。

反之,要是在压测过程中发现 CPU 利用率很高,系统的吞吐量无法继续提升,那么我们可以适当调低协程数目,然后再次进行压测,观察吞吐量是否比之前有所提高。若吞吐量确实提高了,就表明此次协程数目的调整是有效的。

小结

这节课的内容就到这里了,今天我们一共学习了两个资源复用的技巧。通过资源复用,我们可以减少Golang运行时开销,让更多的 CPU 资源能够被用于处理我们的业务逻辑。现在,我们再来一起回顾一下这两个资源复用技巧知识。

  • 首先是对象池。当我们编写的代码中需要频繁创建相同类型的临时对象时,可以使用 sync.Pool 对象池,实现临时对象复用,从而减少 Go中的内存分配和GC开销。
  • 再就是协程池。要是我们的代码需要频繁地创建协程,这时候使用协程池就很关键了。通过协程复用,我们可以降低协程创建的开销。同时,协程池能限制同时运行的协程的最大数目,从而避免同时有太多协程,导致频繁进行协程调度。

希望你好好体会对象池和协程池的应用。在遇到CPU瓶颈时,别忘了尝试运用这两个资源复用的使用技巧,帮你节约更多CPU资源,提升单机吞吐。

思考题

除了这节课介绍的对象池和协程池技巧,你还知道哪些资源复用技巧呢?

欢迎你把你的答案分享在评论区,也欢迎你把这节课的内容分享给需要的朋友,我们下节课再见!

精选留言(1)
  • lJ 👍(4) 💬(1)

    数据库连接池,使用 database/sql 包时,可以配置连接池来复用数据库连接,减少连接创建和关闭的开销。 HTTP 连接池,使用 http.Client 时,可以通过设置 Transport 来配置连接池,减少创建新的 TCP 连接的成本。 IO 操作的缓冲,使用 bufio 包中的 Reader 和 Writer,它们可以缓冲 I/O 操作,从而减少系统调用的次数,提高性能。

    2024-12-18