计算机系统自下而上可分为:硬件、操作系统和应用程序,其中操作系统管理着各种计算机硬件,并控制和协调应用程序对硬件的分配与使用。
一切软件包括操作系统都是在硬件上运行的,一个高性能程序,也不能脱离一个好的硬件环境的支撑。
受益于「 摩尔定律 」,计算机会越来越快,我们可能根本不需要关心代码的性能,硬件制造商会为我们解决性能问题。
但事实真是如此吗。
2005 年,C++ 委员会负责人 Herb Sutter 发布了一篇 《免费午餐结束》[1]的文章,断言未来的程序员将无法通过依赖更快的硬件来修复程序的性能问题。
放到今日来看,确实如此:内存没有跟上 CPU 速度的增长,高速缓存大小受到限制,CPU 时钟速度停滞不前。
时钟速度在 2005-2015 十年来没有增加
免费午餐的时代已经结束。
在性能优化的过程中,我们应该更多地去考虑软件层面,而不是等待更快的硬件来挽救局面。(当然,要是拿个 1c1g 的服务器来部署 GitLab ,那就说不过去了)
我们大部分软件部署都是使用 Linux 操作系统。
在 Linux 中有很多命令可以用于分析服务器的性能,国际知名的计算性能专家 Brendan Gregg[2] 将这些工具称为:Linux Performance Observability Tools :
Linux 性能可观测性工具[3]
摘取一些平时常用的命令:
uptime-系统平均负载
top-进程资源占用状况
iostat-系统设备的IO负载情况
strace-跟踪进程执行时的系统调用和所接收的信号
这些工具我们可以直接通过 man 手册获取其相关用法,这里就不再一一介绍了。
性能优化不能全靠凭空想象,只有合理利用相应的工具,才能更快地分析出我们需要优化的地方。
性能优化离工作所执行的地方越近越好:最好在应用程序里。应用程序包括数据库、Web 服务器、应用服务器、负载均衡器、文件服务器,等等。
一般来说,应用程序的性能指标主要关注以下三个方面:
一个能有效提高应用程序性能的方法是找到对应生产环境工作负载的公用代码路径,并开始对其做优化。如果应用程序是 CPU 密集型的,那么意味着代码路径会频繁占用 CPU。如果应用程序是 I/O 密集型的,则应该查看导致频繁 I/O 的代码路径。这些都能借助应用程序的观测工具来分析和剖析进而确定优化点。
得益于 Go 生态系统的强大,我们有大量的 API 和工具可以用来诊断 Go 程序中的逻辑和性能问题。
大致上可以分成 4 种:
Profiling 分析,包括 CPU 分析、内存分析、goroutine 分析等。
Go 在运行时可以通过 runtime/pprof
写入文件的方式 或 net/http/pprof
标准的 HTTP 接口的方式来 提供 分析数据。
之后就可以借助 go tool pprof
工具来 读取 数据以进行可视化分析。
简单示例:
package main
import (
"bytes"
"net/http"
_ "net/http/pprof"
"time"
)
var buf = bytes.NewBuffer(nil)
func main() {
go http.ListenAndServe(":6060", nil)
t1 := time.NewTicker(1 * time.Second)
t2 := time.NewTicker(10 * time.Second)
for {
select {
case <-t1.C:
mem()
cpu()
case <-t2.C:
buf.Reset()
}
}
}
func mem() {
buf.Write(make([]byte, 100*1024*1024))
}
func cpu() {
for i := 0; i < 10000000000; i++ {
}
}
运行该程序,等待一段时间后,开始进行采样分析。
首先执行 go tool pprof -http=:9090 http://localhost:6060/debug/pprof/profile
可以查看 CPU 占用情况:
查看 top 耗时
定位源码
火焰图宽度也很明显
继续执行 go tool pprof -http=:9091 http://localhost:6060/debug/pprof/allocs
查看内存分配情况:
程序启动以来总的内存分配情况
定位源码
当前堆上内存使用情况
结合源码来看,每隔 1s 会申请 100M 内存,10s 粗算 1G ,由于每隔 10s 会清空 Buffer ,所以程序实时堆内存占用稳定在 1.5G 左右( Buffer Grow 过程会增加容量)也是合理的。
通过 pprof 可以很容易地定位到代码中的热点位置,此时再结合我们的业务逻辑来考虑性能优化就可以事半功倍了。
微服务架构下的服务治理中有一个 Tracing 分布式链路追踪 模块,其做法是在程序代码中使用 OpenTracing/OpenTelemetry API 进行埋点,采集调用的相关信息后发送到追踪服务器(Jaeger 等)进行分析处理。
对于单体应用,所有模块都存在于一个进程中,可以考虑使用 Go 提供的 golang.org/x/net/trace
包,作用包括:
其中 trace.Trace
可以为短期对象(通常是 HTTP 请求)提供追踪,trace.EventLog
可以为长期存在的对象(例如 RPC 连接)提供追踪。
后续两者的追踪数据分别可以从 /debug/requests
和 /debug/events
HTTP 端点中查看。
简单示例:
package main
import (
"math/rand"
"net/http"
"time"
"golang.org/x/net/trace"
)
func main() {
http.HandleFunc("/hello", hello)
http.ListenAndServe(":8080", nil)
}
func hello(w http.ResponseWriter, req *http.Request) {
tr := trace.New("SuperGopher", req.URL.Path)
defer tr.Finish()
time.Sleep(time.Duration(rand.Intn(10)+1) * time.Second)
output := "hello go"
tr.LazyPrintf("输出:%s", output)
w.Write([]byte(output))
}
多次请求 http://localhost:8080/hello
接口后,可以访问 http://localhost:8080/debug/requests?fam=SuperGopher&b=0&exp=1
页面查看追踪得到的数据:
使用 trace.Trace 追踪的数据
Debugging 调试是识别程序异常行为的过程,能够让我们了解程序的执行流程和当前状态。
想调试代码就得使用调试器,Go 主要使用到两种调试器:
以 Delve 为例,安装可以直接使用 go install :
go install github.com/go-delve/delve/cmd/dlv@latest
简单示例:
package main
import "fmt"
func main() {
fmt.Println("hello")
fmt.Println("go")
}
使用 dlv 的调试过程
除了 dlv 这种 CLI 客户端,Delve 还有各种 Editor plugins 或 GUI ,就比如我们平时开发使用的 Goland 或 VS Code 的调试器就是使用了 Delve Editor plugins 。
所以哪个顺手哪个来就行。
Go Runtime 提供了内部事件(调度、系统调用、垃圾回收、堆大小等)的统计信息和报告,用户可以主动监控这些统计数据,以更好地了解 Go 程序的整体运行状况和性能,一些经常监控的统计数据有:
对于这些事件,Go 可以使用 Trace 执行跟踪器捕获后进行可视化分析。
和基于毫秒(ms)频度的 pprof 定时采样相比,基于 event 的 tracer 精确到了纳秒(nanosecond)级精度,对系统的性能影响还是比较大的。在一般情况下我们不需要使用 trace 来定位性能问题,除非需要深入到 runtime 级别,比如想要:
Go 应用开启 Trace ,主要有三种方式:
runtime/trace
包,将收集的数据转存到文件net/http/pprof
包的 /debug/pprof/trace
HTTP 端点导出,适合生产环境go test -trace
参数简单示例:
package main
import (
"context"
"os"
"runtime/trace"
"sync"
)
func main() {
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}
defer f.Close()
err = trace.Start(f)
if err != nil {
panic(err)
}
defer trace.Stop()
wg := sync.WaitGroup{}
wg.Add(2)
go func() {
defer wg.Done()
trace.Log(context.Background(), "goroutine", "SuperGopher1")
}()
go func() {
defer wg.Done()
trace.Log(context.Background(), "goroutine", "SuperGopher2")
}()
wg.Wait()
}
$ go run main.go
$ go tool trace trace.out
2022/08/10 14:52:52 Parsing trace...
2022/08/10 14:52:52 Splitting trace...
2022/08/10 14:52:52 Opening browser. Trace viewer is listening on http://127.0.0.1:55706
trace 支持功能
trace 查看跟踪界面
另外如果设置了 GODEBUG 环境变量,也可以通过日志的方式打印出相应的事件:
简单示例:
package main
import (
"runtime"
"time"
)
func main() {
n := 50000000
m := make(map[int]*int, n)
for i := 0; i < n; i++ {
v := i
m[i] = &v
}
for i := 0; i < 10; i++ {
runtime.GC()
}
time.Sleep(time.Second)
}
$ GODEBUG=gctrace=1 go run main.go
(forced) 结尾的是 runtime.GC() 调用所触发的
Readable means reliable -- Rob Pike
推荐继续阅读:
[1]
《免费午餐结束》: http://www.gotw.ca/publications/concurrency-ddj.htm
[2]
Brendan Gregg: https://www.brendangregg.com/index.html
[3]
Linux 性能可观测性工具: https://www.brendangregg.com/linuxperf.html
[4]
High Performance Go Workshop: https://dave.cheney.net/high-performance-go-workshop/dotgo-paris.html
[5]
Go Diagnostics: https://go.dev/doc/diagnostics