go 优化技巧 Go GC 性能优化 在运行的时候增加 GODEBUG=gctrace=1 的环境变量,让 Golang runtime 在每次 GC 时往 stderr 输出信息;
小结:
【CPU热点】
go tool pprof -seconds 30 https://<测试域名>/debug/pprof/profile
【内存分配】
go tool pprof -seconds 30 https://<测试域名>/debug/pprof/allocs
采样依据
sample_index = inuse_space //: [alloc_objects | alloc_space | inuse_objects | inuse_space]
sample_index Sample value to report (0-based index or name)
正则查找某个方法
list Output annotated source for functions matching regexp
list job
list j
【trace功能】
函数占用的时间
curl 'https://<测试域名>/debug/pprof/trace?seconds=50' > trace.datago tool trace -http 127.0.0.1:8080 trace.data
go tool trace -http 0.0.0.0:8080 trace.data
安装graphviz
https://www.graphviz.org/download/
yum install graphviz
浏览器访问 上述地址
Network blocking profile
Synchronization blocking profile
Syscall blocking profile
Scheduler latency profile
/sched

mq的耗时远低于数据库连接建立、关闭开销(图中是 在业务函数中建立、关闭);
待通过连接池优化。
User-defined tasks and regions
只改一个参数让Golang GC耗时暴降到1/30! https://mp.weixin.qq.com/s/EEDNuhEr0G4SbTzDhVBjbg
只改一个参数让Golang GC耗时暴降到1/30!
👉导读
Golang GC 问题的处理网上有比较多的参考文章与教程,本文则聚焦在一次实际业务场景中遇到的问题,并将问题排查处理的全过程详细地做了整理记录,相信对各位 Gopher 有较大参考价值。👉目录
1 问题现象2 确定原因3 根因分析4 刨根问底
5 解决方案6 问题解决7 总结
01
问题现象
最近有调用方反馈,在使用我们提供的某接口一段时间后,超时率的毛刺比较多,有时候成功率会突然掉到 99.5% 以下,触发业务告警。
经过排查,了解到客户因为测试我们的接口时的平均耗时很低(18ms),所以在线上设置了比较严格的超时时间,150ms。
虽然 150ms 确实比较严格,但根据历史经验,这个接口的内网 TP99 也不会超过 100ms,150ms 的超时时间不应该带来 0.5% 这么高的失败率抖动,于是开始深入排查。
首先是监控,我们的服务有一个专门的错误码来表示客户端断开链接,大多数情况下也就是服务端还在处理,但客户端认为超时时间到了,主动断开。观察此调用方的主动断连监控(请求数为客户端断连次数):

发现抖动确实比较明显,而且分钟级别毛刺最高达到 1000+ ,确实可能导致调用方的失败率告警。
02
确定原因
在排除了常见的可能问题(服务单点故障,依赖组件性能问题)之后,再看业务监控已经没什么帮助了,需要仔细分析服务自己的 Metrics 才可能找到原因。
首先发现问题的是 GCCPUFraction (左侧)这一项:

这一项的含义是 GC 使用的 CPU 占总 CPU 使用的比值,官方文档中是一个 0 - 1 的值,上报时做了 x1000 处理,所以这里是千分比。
也就是说,服务平均有 2% 以上的 CPU 使用在了 GC 上,最严重的机器甚至超过了 4%。
右边这一项 PauseNS 是 GC 的 STW 阶段耗时,这个值在 500us 左右。上报的是最近 256 次 GC 的平均值,如果平均值有 0.5ms,那么极大值达到几毫秒甚至十几毫秒也是有可能的。
看到了这两个指标之后,猜测造成请求超时毛刺的原因可能是 GC 的 STW 阶段会偶尔耗时过长,导致一些本来可以处理的请求,因为 STW 而超过了客户端的超时时间,造成超时毛刺。
Pause 耗时区间的数量也可以佐证这一点:

可以看到,超过 50ms,甚至超过 100ms 的 Pause 都时有出现,有理由怀疑这些 Pause 造成了极限条件下的超时毛刺。
03
根因分析
找到了可疑的目标之后,后续就要找到问题的根本原因,进而解决问题。
3.1 添加调试端口
Golang 官方其实提供了很好的运行时性能分析工具,只需要引入 net/http/pprof 这个包,就可以在默认的 HTTP 端口上增加很多用于性能分析的 Handler。当然,也可以把它们手动挂到自己定义的其他 HTTP Mux 上。
我这里新开了一个 8002 作为调试端口,挂载上了性能分析的 Handler,然后使用了内网的测试域名绑定到这个端口,方便进行访问。
3.2 CPU 热点
既然问题是 GC 占用的 CPU 过高,首先我们分析一下 CPU 热点,进一步确认一下问题。
go tool pprof -seconds 30 https://<测试域名>/debug/pprof/profile
这个命令用于进行 30s 的 CPU 性能分析,在完成之后会进入 profile 的交互工具:

这里直接输入 top 10 可以输出 flat 占比最高的 10 个函数,可以看到 runtime.mallocgc 的 cum 占比已经达到了 15%,占用了快到 3s CPU 时间。
Flat vs Cum:Flat 占比是指这个函数自身的代码使用了多少 CPU,不计算子函数的耗时。而 Cum 则代表这个函数实际执行消耗了多少 CPU,也就是包括了所有的子函数(和子函数的子函数...)的耗时。
交互工具这里输入 web 就会自动生成调用图(callgraph)并在浏览器里打开 ,限于文档空间这里只展示重要的部分 :

先分析一下主流程,这里业务逻辑一共占用了 54% 左右的 CPU(最上面的 endpoint.handler 方法),再往下看,其中 33% 是 RPC 调用(左下角的 client.Invoke),12% 是结果输出(右边的 endpoint.httpOutput,包括写出 HTTP Response,日志上报,监控)。剩下 9% 左右就是实际的各种业务逻辑了,因为非常分散且没有集中的点,所以图上没有显示出来。
纵观这个图可以看出,除了 HTTP/RPC 网络调用占用了大部分 CPU 外,并没有其他很明显的 CPU 热点,这就让最后一个热点凸显了出来:

GC 占用的 CPU 在这次 Profiling 过程中占了 15%,比业务逻辑还高!到这里基本确认,GC 一定存在某些问题。
3.3 内存分配
确定了 GC 确实占用了过多的 CPU,那下一步就是继续探寻为什么了。
虽然前面一直没有提,但是 GC 大家应该都了解,就是垃圾回收,也就是 Go Runtime 将内存中不再被程序使用到的部分回收回去以供下次使用的过程。
那么 GC 占用 CPU 高,首先想到的就是可能程序申请了太多的内存,造成了很大的内存压力,进而导致不停的 GC,最后占用过高。
我们可以用另一个 pprof 工具 allocs 来确认猜测是否成立,这个工具会记录堆上的对象创建和内存分配,只需要把上面的 HTTP Path 最后的 profile 换成 allocs 即可:
go tool pprof -seconds 30 https://<测试域名>/debug/pprof/allocs
和 CPU Profile 不一样,Allocs 的 Profile 会记录四个不同的信息,可以输入 o 查看 sample_index 的可选值:

- alloc_object:新建对象数量记录;
- alloc_space:分配空间大小记录;
- inuse_object:常驻内存对象数量记录;
- inuse_space:常驻内存空间大小记录。
一般来说,inuse 相关的记录可以用于排查内存泄漏,OOM 等问题。这里我们是 GC 的问题,主要和内存申请和释放有关,所以先看下 alloc_space:
输入命令,sample_index=alloc_space,然后再 top 10 看下:

可以发现,这 30s 一共申请了 1300M 的内存,其中绝大多数都是框架和框架组件,比如 RPC 的网络读取(consumeBytes, ReadFrame),pb 的解析和序列化(marshal),名字服务的选址(Select),日志输出(log),监控上报(ReportAttr)。也没有明显的可疑对象。
我们进行一些简单计算:进行 profiling 的线上节点是 2C2G 配置,看监控单节点大约是 800 QPS 的流量,所以 30s 就是 2w4 的请求,申请了 1300M 的空间,平均一个请求就是大约 50KB 左右。
这个量也比较合理,毕竟一次请求我们需要储存 HTTP 包内容,解析成结构体,还需要调用两次 RPC 接口,最后做日志写出和监控上报,最后再给出返回值,50KB 的资源使用并不算大。而且绝大多数都是框架使用,即使用对象池把业务上的一些小对象复用,对这个内存申请也影响不大。
虽然这个步骤没有排查到想查的问题,但是却发现了一些其他的代码中的小问题,这里也顺便记录下。
首先,上面的 top 10 看不出任何问题,但是为了保险起见,看了一下 top 20:

这里有三个我们业务代码里的函数,flat 都很少,占比加起来也不到 4.5%,不过既然发现了他们 ,就深入看一下函数里各行的分配情况,看看有没有什么问题。
3.3.1 预分配空间
首先看第一个函数 AddOtherLog。这个函数的作用是向上下文中添加一些额外的日志信息,最后打日志的时候会输出到最后一个字段中,一般用于输出一些执行过程中的辅助信息,供排查问题使用。
这个函数的定义很简单,我们输入 list AddOtherLog$ 看一下:

可以看到这函数就一行,就是往 OtherInfo 这个 Map 里添加一条记录。
第二个函数中的问题其实也和 OtherInfo 有关,也 list FillResponseByLogicLayer$ 看看(只截取相关部分):

也是往这个 Map 里增加数据,申请了不少内存。
看到这里自然就想到,往 Map 里插入数据,如果 Map 本身空间足够,是不需要重新申请内存的,这里申请了内存必然是因为初始化的时候忘记设置 cap 了,再一看创建 UnifiedContext 对象的代码,果然:

把这里改成提供 20 的 cap 之后,AddOtherLog 等函数操作 Map 时果然按预期不再申请内存。
Note:这个优化其实对内存分配和 GC 影响很小。
因为无论如何储存 20 个 KV 需要的内存总是要申请的,这个改动区别只是在于在初始化的时候申请还是在插入时因为空间不足而申请。而且 Golang 的 Map,KV 是储存在 bucket 里,一个 bucket 存在 8 个 slot,所以在量很小时,实际上最后申请的内存空间差不多。
但这依然是个优化,因为 Map 每次增长都需要重算 hash,但一开始设置好容量就可以避免这部分 CPU 占用。
3.3.2 字符串处理
然后看第三个函数 AccessLog:

还是和 OtherInfo 有关,这里是实际打日志的时候,把这个 Map 中的 KV 转换成 a^b 这种字符串(当然,最后输出的时候是还得 Join 一下,在很后面,不展示了)。
这里主要耗费在了字符串拼接上。众所周知用 fmt.Sprintf 来做字符串拼接性能很差,内存占用也比较高,应该考虑换成 strings.Builder。
这里优化了之后,内存申请减少了一半:

看到效果不错,顺便把其他重字符串拼接的地方也都优化了一下,不过因为都不是主要问题,不再赘述。
3.3.3 多次 Query 解析
最后这个问题是在偶然间发现的:

这里明明只是一些判断,为什么会申请 7MB 的内存呢?
仔细一看,uc.Request.URL.Query() 这里是个函数调用,再一翻代码,果然这里这个 Query 函数并不会缓存解析结果,每次调用都会重新解析 querystring,构造 url.Values。
因为请求入口处我们已经解析了 querystring 并且储存在了 uc.Request.Query 这个字段里,这里应该是(因为 IDE 的代码自动提示而)笔误了。
修改之后符合预期,没有内存申请:

但是这些都是非常小的问题,随手发现修复了之后,申请内存的量有一点点的减少,但 GC 的 CPU 消耗还是没有变化,接下来还是继续排查主要问题。
3.4 Trace 追踪
虽然内存分析没有找到什么代码上的问题,但是实际上,从上面的 profile 已经能感觉出一些不对了。
随便看一张上面的 Profile 的截图,会发现一行:
Total: 1.27GB
因为我这里每次 Profiling 都是 30s,也就是说我们程序每分钟会申请 2.5G 的内存。
按照正常的策略,Golang 是 2 分钟进行一次 GC,但是服务线上使用的节点是 2C2G,所以肯定会因为内存快不足了而提前 GC,并且因为程序的内存需求很稳定,Golang GC 之后应该不会立马把内存还给 OS,所以预期上,我们这个服务的内存占用应该会很高才对。
但回忆之前排查监控时,却显示内存占用相当低:

整个 Pod 占用的内存才 400MB 不到,再看程序中上报的自身使用的内存:

才 200MB 不到,这明显不符合预期,GC 必须以很快的频率执行,才能保证这个内存使用。
想确定 GC 频率是否真的存在问题,有两种方法:
- 在运行的时候增加 GODEBUG=gctrace=1 的环境变量,让 Golang runtime 在每次 GC 时往 stderr 输出信息;
- 使用 runtime trace。
因为之前增加的调试端口里已经有了 trace 的功能,所以我们就不用环境变量了,直接进行一下 trace:
curl 'https://<测试域名>/debug/pprof/trace?seconds=50' > trace.datago tool trace -http 127.0.0.1:8080 trace.data
这里进行了 50s 的 trace,然后使用 golang 的 trace 工具解析结果,并在浏览器中可视化:

这里因为时间太长被分了段,我们随便点进一段查看:

这里从上到下分别是:
- 协程:显示协程的数量和状态;
- 堆内存:显示堆内存占用;
- 线程:显示操作系统线程数量和状态;
- GC 事件:显示 GC 的开始和结束;
- 网络事件:网络 IO 相关片段;
- 系统调用事件:显示系统调用片段;
- Processor 事件:显示执行器在执行哪个协程和函数。
这里我们一眼就看出一个很特别的图案:Heap 堆内存呈现很明显的锯齿状。而且 GC 也在每次下降沿出现,很明显,这就是 GC 在不停的回收内存。我们通过工具看一下每次 GC 间的间隔是多少:

才 550ms,而且通过点击堆内存的最高点,查看统计可以发现:

我们内存的最高点,应用程序自身才使用了 60MB 左右的堆内存。
总结一下,也就是说:现在我们的程序占用的内存非常小,但是 GC 却特别频繁,甚至达到了一秒两次。
然后我们回到 trace 首页,找到最底下的 MMU 链接:

点进去可以看见一个图表,因为我们只关注影响程序执行的 STW 阶段,所以右边只勾选 STW:

先简单介绍一下这个图的理解方法:
- X 轴表示我们取一个多长的时间窗口;
- Y 轴表示这个时间窗口里,最差情况下有多少比例的 CPU 时间可供业务代码使用。
所以根据这个 X 轴零点我们可以发现,在这段 trace 事件内,最长的 STW 时间有 15ms,然后我们看下以 1s 为时间窗口的情况:

这里的比例大约是 96.9%,也就是说一秒的窗口内,最差会有 31ms 的时间被 GC 的 STW 占用,这个比例可以说很高了。在极端情况下,比如说某次 STW 达到 50ms,确实可能导致某些请求得不到处理而意外超时。
04
刨根问底
现在根本原因找到了,是因为 GC 频率过快导致 STW 时间占比太高,造成极端情况下的超时毛刺。
所以现在问题就变成了,在内存占用如此低的情况下,GC 为什么这么频繁?
众所周知,或者说按常识猜测,Golang runtime 的 GC 应该至少遵循三个策略:
- 在代码手动要求 GC 时执行;
- 间隔固定时间触发 GC,作为托底策略;
- 为了防止 OOM,在内存使用率比较高时触发 GC。
策略 1,可以忽略,我们没有手动执行 GC。策略 2,很容易查到,固定时间为 2 分钟 GC 一次。策略 3,很明显不管是 60M 还是 200M,对比 2G 的可用内存,都算不上使用率比较高。
所以肯定存在其他的 GC 策略,或者我们对某条策略还没有完全理解。
4.1 再看 Trace

我们重新看这张图,这里除了 Allocated 已分配堆内存大小这一项之外,还有一项叫做 NextGC,直觉告诉我们这个值貌似和 GC 频率有关。
查看 Runtime trace 的相关资料,了解到 Heap 图表的红色部分代表占用内存,而绿色部分的上边沿表示下次 GC 的目标内存, 也就是绿色部分用完之后,就会触发 GC。
通过查看一个刚刚 GC 完毕的 trace 点的信息:

可以发现,每轮 GC 我们只有大约 30M 内存可以用,(快)用完了就触发 GC,就是这个限制造成了特别高的 GC 频率。
其实在 metrics 中也有 NextGC 的相关上报;

但是和 Trace 里显示剩余空间不同的是,metrics 上报的 NextGC 是 MemStat 结构里的原始值,也就是当 Heap 达到多少的时候触发 GC,更加清晰的能发现 Heap 上限就是 60MB 左右。
4.2 GOGC 参数
那么是什么在控制 NextGC 的大小呢,顺着线索我们来到 NextGC 这个值的注释:

这个值是在上一轮 GC 结束时,基于可达数据和 GOGC 参数计算出来的。
可达数据,可以理解为 GC 过程当中从 root set 出发,沿着指针可以到达的数据,其实也就是还在使用,不能被 GC 的数据。
而 GOGC 这个参数,查看定义:

它的功能是控制 GC 的触发:会在每次新申请的内存大小和上次 GC 之后 Live Data(就是上面说过的不能被 GC 的数据) 的大小的比值达到 GOGC 设定的百分比时触发一次 GC。可能有点绕,写成公式其实就是 NextGC = LiveData * (1 + GOGC / 100)。
而默认值是 100 就表示,在每次 GC 之后,内存使用量一旦翻倍,就会再触发一次 GC。
很明显,这个默认值就是触发频繁 GC 的罪魁祸首。
05
解决方案
问题找到之后解决方案就很简单了,把 GOGC 调大就行了。
但是因为 GOGC 是个环境变量,如果写死在 Dockerfile 里如果想修改还需要发布上线,不是很灵活。
不过上面的文档也提到了,可以用 SetGCPercent 在运行时修改这个策略参数。
5.1 SetGCPercent
所以这里的解决方案是在运行时配置中新增了一个配置文件,用于存储运行时策略相关的配置,在代码中 Watch,在配置有改动的时调用 SetGCPercent 设置为需要的值:
func runtimeConfigureWatcher(c <-chan config.Response) {for r := range c {var rc runtimeConfig// 将配置文件更新事件 r 解析成结构体 rc,代码省略……if rc.GCPercent != nil {old := debug.SetGCPercent(*rc.GCPercent)log.Infof("Set GC percent from %d to %d", old, *rc.GCPercent)} else {log.Infof("no GC percent value, do not change")}}}
5.2 上线测试
新代码写完了,该上线看看效果了,但上线前配置要先准备好,这个 GOGC 值应该改成多少才合适呢?
我们可以重新看一下 Metrics 里 NextGC 的监控图,这次把 Max 打开:

可以发现,NextGC 的平均值在 60MB 左右,但最大值的峰值大概 130MB,除以 2 就是 Live Data,也就是 65MB。
按照 2G 的 Pod 内存大小,简单除一下,大概是 32,所以我们的 GOGC 的上限不能超过 3200。
为了线上安全考虑,我们逐步加大这个值,从 100 开始,每次 x2,依次测试了 200,400,800,1600,效果符合预期,GC CPU 占比有了每次调整都有非常大的下降:

最终,调整到 1600 之后,GC CPU 占比下降到了千分之 1.2 左右。

每次 GC 的平均 Pause 时间也更加稳定了,且不超过 400us。
另外,作为辅助验证,也新抓了每个阶段的 Trace,基本上 GOGC 翻倍,GC 间隔也翻倍,非常符合预期。
GOGC:100 -> 200 -> 400 -> 800 -> 1600。GC 间隔:0.55s -> 1.2s -> 3.3s -> 6.3s -> 12.8s。
因为测试过很多次,图就不都放出来了,放一下最后 GOGC 为 1600 时的图:

超过了分片大小了,但是可以看出 GC 间隔会超过 12s。顺便也看一下新的 MMU 图:

最长 STW 时间 200us。以 1s 为时间窗口的话:

GC 几乎已经不使用任何 CPU 了,占比可以忽略不计。
简单计算一下:之前 500ms 一次 GC,一分钟 120 次,平均 Pause 500us,一共就是 60ms。现在 12s 一次 GC,一分钟 5 次,平均 Pause 400us,一共 2ms,仅有之前的 1/30!
5.3 Memory Limit
当然,把 GOGC 调大会让 Golang 从 OS 里申请的内存增多,但因为之前实在是太少了,增多之后也还在合理范围内。毕竟申请了内存,不用也是浪费了,这也是空间换时间的思路。以下是调整之后的 SysMem 曲线变化:

但是看着上面这个曲线有一直上升的趋势,好像还是不能放心的全量上线。
首先需要了解,这个一直上涨现象的原因并不是内存泄漏,而是因为随着程序的运行,某次 GC 后的 Live Data 可能会刚好比较大,所以这次的 NextGC 值也会变高。即使下一次 GC 后 LiveData 回到常规水平,Golang 并不会在 GC 之后就把申请的 SysMem 返还给 OS(避免下次用的时候还得从 OS 里申请),所以 SysMem 几乎必然是单调缓慢递增的。
但是这个现象也不是完全没有隐患。如果某次 GC 完成的时候,因为某种原因 Live Data 特别的大,比如到了 100MB,那么按照 GOGC 1600% 的配置,下次就会在 1700MB 时才会 GC,再加上操作系统本身使用的内存,有可能会导致 OOM。
为了避免这个问题,我们还需要设定一个内存占用的上限。
在很久之前,这还是个比较麻烦的问题(参见最后的参考资料部分),但随着时代的发展,Golang 在 2022 年 8 月 2 日发布的 1.19 版本增加了这个功能:runtime/debug.SetMemoryLimit。
内存限制的默认值是很大的值(MaxInt64 byte),基本就是不限制的意思。我们在 GOGC 设置为 1600 的基础上,将 Memory Limit 设置为 1600MB,来防止可能的 OOM。
if rc.MemoryLimit != nil && *rc.MemoryLimit >= 0 {newValue := *rc.MemoryLimit * 1024 * 1024 // MB -> ByteoldValue := debug.SetMemoryLimit(newValue)log.Infof("Set memory limit from %d to %d", oldValue, newValue)} else {log.Infof("No valid memory limit value, do nothing")}
最后增加上 Memory Limit 的配置,就是我们的最终解决方案。06
问题解决
理论上问题都解决了,Metrics 上看 GC 占比也下来了,那自然要全量上线看看效果:

这张图就是文档写到这里的时候,刚刚去截的客户端主动断连监控,对比的是 2 月 9 号,优化上线之前。
可以看到客户端(认为请求超时)断开链接的数量明显比之前下降明显,整体曲线也更加平稳,毛刺更少。
PS:当然客户端断连不可能完全消失,必然存在一些请求确实是达不到客户端的超时要求
07
总结
本文档介绍了从发现到解决这个 Golang GC 问题的过程当中,排查问题的思路,观察的重要监控指标和含义,使用的调试工具和方法。
虽然最后只是用了两个简单的函数来解决问题,但上述资料对其他类型的问题排查也有一定价值,故记录下来。
单独看这个问题,因为 Web 类应用大多不存在很大量的全局数据,所以配合上 GOGC 的默认值 100,很多高 QPS 的在线服务可能都会存在 GC 过于频繁,没有利用上申请的 Pod 内存的问题。不过因为 Golang 的 GC 流程也越来越并行化,STW 几乎只会影响极端情况,带来的最多是毛刺而不是服务的整体性问题,所以这个问题可能大多数情况下都被忽略了。
但如果线上存在单节点流量比较大且内存申请几乎完全只与请求量有关,又对极端情况下的耗时有较高要求的服务,可以酌情调整下 GOGC 参数,降低 GC 消耗,提高稳定性。
Go 语言中各式各样的优化手段 - 知乎 https://zhuanlan.zhihu.com/p/403417640
go语言最全优化技巧总结,值得收藏! https://mp.weixin.qq.com/s/_VGaV8ef65h9goxxfWejtQ
go语言最全优化技巧总结,值得收藏!
导语 | 本文总结了在维护go基础库过程中,用到或者见到的一些性能优化技巧,现将一些理解梳理撰写成文,和大家探讨。
一、常规手段
(一)sync.Pool
临时对象池应该是对可读性影响最小且优化效果显著的手段。基本上,业内以高性能著称的开源库,都会使用到。
最典型的就是fasthttp(网址:https://github.com/valyala/fasthttp/)了,它几乎把所有的对象都用sync.Pool维护。
但这样的复用不一定全是合理的。比如在fasthttp中,传递上下文相关信息的RequestCtx就是用sync.Pool维护的,这就导致了你不能把它传递给其他的goroutine。
如果要在fasthttp中实现类似接受请求->异步处理的逻辑,必须得拷贝一份RequestCtx再传递。这对不熟悉fasthttp原理的使用者来讲,很容易就踩坑了。
还有一种利用sync.Pool特性,来减少锁竞争的优化手段,也非常巧妙。另外,在优化前要善用go逃逸检查分析对象是否逃逸到堆上,防止负优化。
(二)string2bytes & bytes2string
这也是两个比较常规的优化手段,核心还是复用对象,减少内存分配。
在go标准库中也有类似的用法gostringnocopy。
要注意string2bytes后,不能对其修改。
unsafe.Pointer经常出现在各种优化方案中,使用时要非常小心。这类操作引发的异常,通常是不能recover的。
(三)协程池
绝大部分应用场景,go是不需要协程池的。当然,协程池还是有一些自己的优势:
-
可以限制goroutine数量,避免无限制的增长。
-
减少栈扩容的次数。
-
频繁创建goroutine的场景下,资源复用,节省内存。(需要一定规模。一般场景下,效果不太明显。)
go对goroutine有一定的复用能力。所以要根据场景选择是否使用协程池,不恰当的场景不仅得不到收益,反而增加系统复杂性。
(四)反射
go里面的反射代码可读性本来就差,常见的优化手段进一步牺牲可读性。而且后续马上就有泛型的支持,所以若非必要,建议不要优化反射部分的代码。
比较常见的优化手段有:
-
缓存反射结果,减少不必要的反射次数。例如json-iterator
(网址:https://github.com/json-iterator/go)。
-
直接使用unsafe.Pointer根据各个字段偏移赋值。
-
消除一般的struct反射内存消耗go-reflect。
(网址:https://github.com/goccy/go-reflect)
-
避免一些类型转换,如interface->[]byte。
(五)减小锁消耗
并发场景下,对临界区加锁比较常见。带来的性能隐患也必须重视。常见的优化手段有:
-
减小锁粒度:
go标准库当中,math.rand就有这么一处隐患。当我们直接使用rand库生成随机数时,实际上由全局的globalRand对象负责生成。globalRand加锁后生成随机数,会导致我们在高频使用随机数的场景下效率低下。
-
atomic:
适当场景下,用原子操作代替互斥锁也是一种经典的lock-free技巧。标准库中sync.map针对读操作的优化消除了rwlock,是一个标准的案例。对它的介绍文章也比较多,不在赘述。
prometheus里的组件histograms直方图也是一个非常巧妙的设计。一般的开源库,比如go-metrics(网址:https://github.com/rcrowley/go-metrics)是直接在这里使用了互斥锁。指标上报作为一个高频操作,在这里加锁,对系统性能影响可想而知。
参考sync.map里冗余map的做法,prometheus把原来histograms的计数器也分为两个:cold和hot,还有一个hotIdx用来表示哪个计数器是hot。prometheus里的组件histograms直方图也是一个非常巧妙的设计。一般的开源库,比如go-metrics(网址:https://github.com/rcrowley/go-metrics)是直接在这里使用了互斥锁。指标上报作为一个高频操作,在这里加锁,对系统性能影响可想而知。
业务代码上报指标时,用atomic原子操作对hot计数器累加向prometheus服务上报数据时,更改hotIdx,把原来的热数据变为冷数据,作为上报的数据。然后把现在冷数据里的值,累加到热数据里,完成一次冷热数据的更新替换。
还有一些状态等待,结构体内存布局的介绍,不再赘述。
二、另类手段
(一)golink
golink(网址:https://golang.org/cmd/compile/)在官方的文档里有介绍,使用格式:
//go:linkname FastRand runtime.fastrandfunc FastRand() uint32
主要功能就是让编译器编译的时候,把当前符号指向到目标符号。上面的函数FastRand被指向到runtime.fastrand,runtime包生成的也是伪随机数,和math包不同的是,它的随机数生成使用的上下文是来自当前goroutine的,所以它不用加锁。正因如此,一些开源库选择直接使用runtime的随机数生成函数。性能对比如下:
Benchmark_MathRand-12 84419976 13.98 ns/opBenchmark_Runtime-12 505765551 2.158 ns/op
还有很多这样的例子,比如我们要拿时间戳的话,可以标准库中的time.Now(),这个库在会有两次系统调用runtime.walltime1和runtime.nanotime,分别获取时间戳和程序运行时间。大部分场景下,我们只需要时间戳,这时候就可以直接使用runtime.walltime1。性能对比如下:
Benchmark_Time-12 16323418 73.30 ns/opBenchmark_Runtime-12 29912856 38.10 ns/op
同理,如果我们需要统计某个函数的耗时,也可以直接调用两次runtime.nanotime然后相减,不用再调用两次time.Now。
//go:linkname nanotime1 runtime.nanotime1func nanotime1() int64func main() {defer func( begin int64) {cost := (nanotime1() - begin)/1000/1000fmt.Printf("cost = %dms \n" ,cost)}(nanotime1())time.Sleep(time.Second)}运行结果:cost = 1000ms
系统调用在go里面相对来讲是比较重的。runtime会切换到g0栈中去执行这部分代码,time.Now方法在go<=1.16中有两次连续的系统调用。
不过,go官方团队的lan大佬已经发现并提交优化pr。
优化后,这两次系统调将会合并在一起,减少一次g0栈的切换。
linkname为我们提供了一种方法,可以直接调用go标准库里的未导出方法,可以读取未导出变量。使用时要注意go版本更新后,是否有兼容问题,毕竟go团队并没有保证这些未导出的方法变量后续不会变更。
还有一些其他奇奇怪怪的用法:
-
reflect2包,创建reflect.typelinks的引用,用来读取所有包中struct的定义。
-
创建panic的引用后,用一些hook函数重定向panic,这样你的程序panic后会走到你的自定义逻辑里。
-
runtime.main_inittask保存了程序初始化时,init函数的执行顺序,之前版本没有init过程debug功能时,可以用它来打印程序init调用链。最新版本已经有官方的调试方案:GODEBUG=inittracing=1开启init。
-
runtime.asmcgocall是cgo代码的实际调用入口。有时候我们可以直接用它来调用cgo代码,避免goroutine切换,具体会在cgo优化部分展开。
(二) log-函数名称行号的获取
虽然很多高性能的日志库,默认都不开启记录行号。但实际业务场景中,我们还是觉得能打印最好。
在runtime中,函数行号和函数名称的获取分为两步:
-
runtime回溯goroutine栈,获取上层调用方函数的的程序计数器(pc)。
-
根据pc,找到对应的funcInfo,然后返回行号名称。
经过pprof分析。第二步性能占比最大,约60%。针对第一步,我们经过多次尝试,并没有找到有效的办法。但是第二步很明显,我们不需要每次都调用runtime函数去查找pc和函数信息的,我们可以把第一次的结果缓存起来,后面直接使用。这样,第二步约60%的消耗就可以去掉。
var(m sync.Map)func Caller(skip int)(pc uintptr, file string, line int, ok bool){rpc := [1]uintptr{}n := runtime.Callers(skip+1, rpc[:])if n < 1 {return}var (frame runtime.Frame)pc = rpc[0]if item,ok:=m.Load(pc);ok{frame = item.(runtime.Frame)}else{tmprpc := []uintptr{pc,}frame, _ = runtime.CallersFrames(tmprpc).Next()m.Store(pc,frame)}return frame.PC,frame.File,frame.Line,frame.PC!=0}
压测数据如下,优化后稍微减轻这部分的负担,同时消除掉不必要的内存分配。
BenchmarkCaller-8 2765967 431.7 ns/op 0 B/op 0 allocs/opBenchmarkRuntime-8 1000000 1085 ns/op 216 B/op 2 allocs/op
(三)cgo
cgo的支持让我们可以在go中调用c++和c的代码,但cgo的代码在运行期间不受go调度器的管理,为了防止cgo调用引起调度阻塞,cgo调用会切换到g0栈执行,并独占m。由于runtime设计时没有考虑m的回收,所以运行时间久了之后,会发现有cgo代码的程序,线程数都比较多。
用go的编译器转换包含cgo的代码:
go tool cgo main.go
转换后看代码,cgo调用实际上是由runtime.cgocall发起,而runtime.cgocall调用过程主要分为以下几步:
-
entersyscall(): 保存上下文,标记当前mincgo独占m,跳过垃圾回收。
-
osPreemptExtEnter:标记异步抢占,使异步抢占逻辑失效。
-
asmcgocall:真正的cgo call入口,切换到g0执行c代码。
-
恢复之前的上下文,清理标记。
对于一些简单的c函数,我们可以直接用asmcgocall调用,避免来回切换:
package main/*#include <stdio.h>#include <stdlib.h>#include <unistd.h>struct args{int p1,p2;int r;};int add(struct args* arg) {arg->r= arg->p1 + arg->p2;return 100;}*/import "C"import ("fmt""unsafe")//go:linkname asmcgocall runtime.asmcgocallfunc asmcgocall(unsafe.Pointer, uintptr) int32func main() {arg := C.struct_args{}arg.p1 = 100arg.p2 = 200//C.add(&arg)asmcgocall(C.add,uintptr(unsafe.Pointer(&arg)))fmt.Println(arg.r)}
压测数据如下:
BenchmarkCgo-12 16143393 73.01 ns/op 16 B/op 1 allocs/opBenchmarkAsmCgoCall-12 119081407 9.505 ns/op 0 B/op 0 allocs/op
(四)epoll
runtime对网络io,以及定时器的管理,会放到自己维护的一个epoll里,具体可以参考runtime/netpool。在一些高并发的网络io中,有以下几个问题:
-
需要维护大量的协程去处理读写事件。
-
对连接的状态无感知,必须要等待read或者write返回错误才能知道对端状态,其余时间只能等待。
-
原生的netpool只维护一个epoll,没有充分发挥多核优势。
基于此,有很多项目用x/unix扩展包实现了自己的基于epoll的网络库,比如潘神的gnet(网址:https://github.com/panjf2000/gnet),还有字节跳动的netpoll。
在我们的项目中,也有尝试过使用。最终我们还是觉得基于标准库的实现已经足够。理由如下:
-
用户态的goroutine优先级没有gonetpool的调度优先级高。带来的问题就是毛刺多了。近期字节跳动也开源了自己的netpool,并且通过优化扩展包内epoll的使用方式来优化这个问题,具体效果未知。
-
效果不明显,我们绝大部分业务的QPS主要受限于其他的RPC调用,或者CPU计算。收发包的优化效果很难体现。
-
增加了系统复杂性,虽然标准库慢一点点,但是足够稳定和简单。
(五)包大小优化
我们CI是用蓝盾流水线实现的,有一次业务反馈说蓝盾编译的二进制会比自己开发机编译的体积大50%左右。对比了操作系统和go版本都是一样的,tlinux2.2 golang1.15。我们在用linux命令size—A对两个文件各个section做对比时,发现了debug相关的section size明显不一致,而且section的名称也不一样:
size -A test-30MBsection size addr.interp 28 4194928.note.ABI-tag 32 4194956... ... ... ....zdebug_aranges 1565 0.zdebug_pubnames 56185 0.zdebug_info 2506085 0.zdebug_abbrev 13448 0.zdebug_line 1250753 0.zdebug_frame 298110 0.zdebug_str 40806 0.zdebug_loc 1199790 0.zdebug_pubtypes 151567 0.zdebug_ranges 371590 0.debug_gdb_scripts 42 0Total 93653020size -A test-50MBsection size addr.interp 28 4194928.note.ABI-tag 32 4194956.note.go.buildid 100 4194988... ... ....debug_aranges 6272 0.debug_pubnames 289151 0.debug_info 8527395 0.debug_abbrev 73457 0.debug_line 4329334 0.debug_frame 1235304 0.debug_str 336499 0.debug_loc 8018952 0.debug_pubtypes 1072157 0.debug_ranges 2256576 0.debug_gdb_scripts 62 0Total 113920274
通过查找debug和zdebug的区别了解到,zdebug是对debug段做了zip压缩,所以压缩后包体积会更小。查看go的源码(网址:https://github.com/golang/go/blob/master/src/cmd/link/internal/ld/dwarf.go#L2210),发现链接器默认已经对debug段做了zip压缩。
看来,未压缩的debug段不是go自己干的。我们很容易就猜到,由于代码中引入了cgo,可能是c++的链接器没有压缩导致的。
代码引入cgo后,go代码由go编译器编译,c代码由g++编译。后续由ld链接成可执行文件
所以包含cgo的代码在跨平台编译时,需要更改对应平台的c代码编译器,链接器。具体过程可以翻阅go编译过程相关资料,不再赘述
再次寻找原因,我们猜测可能跟tlinux2.2支持go 1.16有关,之前我们发现升级go版本之后,在开发机上无法编译。最后发现是因为go1.16优化了一部分编译指令,导致我们的ld版本太低不支持。所以我们用yum install -y binutils升级了ld的版本。果然,在翻阅了ld的文档之后,我们确认了tlinux2.2自带的ld不支持--compress-debug-sections=zlib-gnu这个指令,升级后ld才支持。
总结:在包含cgo的代码编译时,将ld升级到2.27版本,编译后的体积可以减少约50%。
(六)simd
首先,go链接器支持simd指令,但go编译器不支持simd指令的生成。
所以在go中使用simd一般来说有三种方式:
-
手写汇编。
-
llvm。
-
cgo(如果用cgo的方式来调用,会受限于cgo的性能,达不到加速的目的)。
目前比较流行的做法是llvm:
-
用c来写simd相关的函数,然后用llvm编译成c汇编。
-
用工具把c汇编转换成go的汇编格式,保存为.s文件。
-
在go中调用.s里的方法,最后用go编译器编译。
以下开源库用到了simd,可以参考:
-
simdjson-go
(网址:https://github.com/minio/simdjson-go)
-
soni
(网址:https://github.com/bytedance/sonic)
-
sha256-simd
(网址:https://github.com/minio/sha256-simd)
合理的使用simd可以充分发挥cpu特性,但是存在以下弊端:
-
难以维护,要么需要懂汇编的大神,要么需要引入第三方语言。
-
跨平台支持不够,需要对不同平台汇编指令做适配。
-
汇编代码很难调试,作为使用方来讲,完全黑盒。
(七)jit
go中使用jit的方式可以参考Writing a JIT compiler in Golang,
目前只有在字节跳动刚开源的json解析库中发现了使用场景sonic。
(网址:https://github.com/bytedance/sonic)
这种使用方式个人感觉在go中意义不大,仅供参考。
三、总结
过早的优化是万恶之源,千万不要为了优化而优化:
-
pprof分析,竞态分析,逃逸分析,这些基础的手段是必须要学会的。
-
常规的优化技巧是比较实用的,他们往往能解决大部分的性能问题并且足够安全。
-
在一些着重性能的基础库中,使用一些非常规的优化手段也是可以的,但必须要权衡利弊,不要过早放弃可读性,兼容性和稳定性。
作者简介
赵柯
腾讯音乐后台开发工程师
腾讯音乐后台开发工程师,Go Contributor。

浙公网安备 33010602011771号