Golang优化通识

简单整理一下 Golang 中比较常见的性能优化思路。

性能优化其实很容易走偏,比如程序一慢就开始研究:

sync.Pool
GOGC
goroutine
各种零拷贝

最后代码复杂了不少,性能可能没什么变化。

我觉得性能优化最重要的一点还是:

先找到慢在哪里,再考虑怎么优化。

基本流程就是:

发现问题 -> 测量 -> 找到瓶颈 -> 修改 -> 再测量

不要凭感觉优化。

1. 先确定是什么问题

首先要知道自己到底想优化什么。

常见的基本就是:

CPU 太高
内存太高
GC 太频繁
接口延迟高
吞吐量低
goroutine 太多

比如接口需要 1 秒:

Go 代码 5ms -> MySQL 600ms -> HTTP 请求 300ms

这时候把 Go 代码从:

5ms -> 2ms

其实没有太大意义。

应该先看 MySQL 和 HTTP 请求为什么慢。

所以优化的时候,先找最大的瓶颈。


2. Benchmark

如果是优化一个具体函数,可以直接写 Benchmark。

func BenchmarkFoo(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Foo()
    }
}

执行:

go test -bench=. -benchmem

主要看:

ns/op
B/op
allocs/op

分别对应:

执行时间
内存分配
分配次数

优化代码之前和之后都跑一下。

before -> 修改代码 -> after

如果数据没变好,那基本不能叫优化。


3. pprof

实际服务性能有问题,我觉得 pprof 是最应该先看的东西。

常见的几个 Profile:

CPU        -> CPU 消耗
heap       -> 内存分配
goroutine  -> goroutine
mutex      -> 锁竞争
block      -> 阻塞

HTTP 服务可以直接引入:

import _ "net/http/pprof"

然后启动:

go func() {
    http.ListenAndServe("localhost:6060", nil)
}()

抓 CPU Profile:

go tool pprof \
    "http://localhost:6060/debug/pprof/profile?seconds=30"

看内存:

go tool pprof \
    http://localhost:6060/debug/pprof/heap

核心就是先找到:

CPU -> 到底消耗在哪个函数

Memory -> 到底是谁在大量申请内存

一个函数只占 0.1% CPU,就算优化十倍,对整体也没什么影响。


4. 少做没必要的内存分配

Go 有 GC,不代表内存分配没有成本。

大致可以理解为:

大量 Allocation -> Heap 变大 -> GC 工作增加 -> CPU 消耗增加

比较常见的是 Slice。

如果已经知道大概有多少数据:

var result []string

for _, v := range values {
    result = append(result, v.Name)
}

可以提前分配:

result := make([]string, 0, len(values))

for _, v := range values {
    result = append(result, v.Name)
}

Map 也是一样:

m := make(map[string]string, len(values))

这种优化比较简单,而且在大量数据处理的时候比较常见。


5. 字符串不要在大循环里面一直 +

比如:

var result string

for _, v := range values {
    result += v
}

数据多的时候会产生不少中间字符串。

可以用:

var builder strings.Builder

for _, v := range values {
    builder.WriteString(v)
}

result := builder.String()

如果大概知道大小,还可以:

builder.Grow(size)

不过数据就几条的时候没必要折腾。

优化热点代码就行。


6. goroutine 不是越多越好

Go 创建 goroutine 很简单:

go work()

所以有时候很容易变成:

程序慢 -> 多开 goroutine

但 goroutine 最后还是需要 CPU 执行。

比如:

4 Core CPU -> 10000 个 CPU 密集型 goroutine

不会因为 goroutine 多就变成 10000 核。

反而还可能增加调度、内存和锁竞争。

并发比较大的地方最好有一个限制,比如:

请求 -> Worker Pool -> 执行任务

或者 Semaphore。

goroutine 主要解决的是并发问题,不是无限增加性能。


7. 锁的范围尽量小

例如:

mu.Lock()
defer mu.Unlock()

value := cache[key]
process(value)

如果 process 很慢,相当于锁一直没有释放。

如果业务允许,可以改成:

mu.Lock()
value := cache[key]
mu.Unlock()

process(value)

简单来说:

拿锁 -> 操作共享数据 -> 释放锁 -> 做其他事情

而不是:

拿锁 -> 做一堆事情 -> 释放锁

如果怀疑锁竞争,可以直接看 mutex profile,而不是先猜哪把锁有问题。


8. IO 往往比 Go 代码本身更值得优化

实际业务里面很常见:

Request -> Go -> MySQL -> Redis -> HTTP API -> Response

真正慢的地方经常不是 Go。

比如:

Go      3ms
MySQL   50ms
HTTP    100ms

这种情况首先应该考虑:

能不能减少数据库查询次数
能不能批量查询
能不能减少 RPC
能不能并行 IO
能不能缓存

一次少掉一个 50ms 的数据库请求,可能比优化半天 Go 代码收益都大。


9. GC 不要一上来就调参数

常见的:

GOGC
GOMEMLIMIT

确实可以影响 Go GC。

但如果程序本身一直在:

大量创建对象 -> 大量 Heap Allocation -> GC

只调整 GOGC 很可能只是改变 GC 什么时候发生。

第一件事还是应该看:

为什么分配这么多内存?

也就是:

pprof heap -> 找 Allocation 热点 -> 优化代码 -> 再考虑 GC 参数

GC 参数更多是调节 CPU 和内存之间的平衡,不是什么性能开关。


10. 算法比语法优化重要

例如:

for _, a := range users {
    for _, b := range orders {
        if a.ID == b.UserID {
            // ...
        }
    }
}

如果数据量很大,与其研究 range 能不能快一点,不如先想:

orders -> map[userID][]Order

把数据结构改掉。

很多时候:

算法优化 -> IO 优化 -> 并发优化 -> 内存优化 -> 语法微优化

越靠前收益通常越明显。

当然也不是绝对的,最终还是要看 Profile。


总结

Golang 性能优化我觉得先掌握这些就够用了。

首先:

不要猜 -> 先测

常用工具:

Benchmark
pprof
trace

然后从真正的瓶颈开始看:

CPU
Memory
GC
Lock
IO

代码层面比较高频的也就是:

Slice / Map 提前分配容量

减少没必要的 Allocation

大量字符串拼接使用 strings.Builder

控制 goroutine 数量

缩小锁的范围

减少数据库和网络 IO

优先优化算法和数据结构

最后还是要回到:

修改前数据 -> 修改代码 -> 修改后数据

数据确实变好了,才说明这个优化是有效的。

不然很多所谓的“优化”,最后只是让代码变得更复杂。

参考

Go Diagnostics:

https://go.dev/doc/diagnostics

Profiling Go Programs:

https://go.dev/blog/pprof

Go GC Guide:

https://go.dev/doc/gc-guide

runtime/pprof:

https://pkg.go.dev/runtime/pprof

发表评论: