字节跳动青训营系列文章
“关于性能分析这一块,我在之前也写过两篇文章:java性能分析与常用工具和Java性能分析之火焰图
这是我参与「第三届青训营 -后端场」笔记创作活动的的第3篇笔记
同时这也是课表的第三天课程
PC端阅读效果更佳。源码已经上传到这里:
“Github:https://github.com/nateshao/gogogo/tree/master/day03-05-11/go-pprof-practice
这篇文章可以收获什么
今天的内容主要分成两大部分
什么是高质量:编写的代码能够达到正确可靠、简洁清晰的目标可称之为高质量代码
编程原则
实际应用场景千变万化,各种语言的特性和语法各不相同。但是高质量编程遵循的原则是相通的
如何编写高质量的Go代码
推荐使用gofmt自动格式化代码
gofmt
自动格式化代码,保证所有的Go
代码与官方推荐格式保持一致。而且可以很方便的进行配置,像Goland
内置 了相关功能,直接开启即可在保存文件的时候自动格式化。
gofmtGo
语言官方提供的工具,能自动格式化Go
语言代码为官方统一风格。常见IDE都支持方便的配置
goimports也是Go
语言官方提供的工具
goimports
会对依赖包进行管理,自动增删依赖的包引用,按字母序排序分类,具体可以根据团队实际情况配置使用
之所以将格式化放在第一条, 因为这是后续规范的基础,团队合作review
其他人的代码时就能体会到这条规范的作用了
注释应该做的
Good code has lots of comments, bad code requires lots of comments
好的代码有很多注释,坏代码需要很多注释
---Dave Thomas and Andrew Hunt
“多年以后,你再回去看看以前的代码,你是否还看得懂?
注释应该解释代码作用
适合注释公共符号
// Open opens the named file for reading. If successful, methods on
// the returned file can be used. for reading; the, 28 6 eC iated file
// descriptor has mode 0_ RDONLY.
// If there is an error, it will be of type *PathEr-or.5206
func Open(name string) (*File, error) {
return OpenFile(name, 0_RDONLY,0)
}
原文:https://github.com/golang/go/blob/master/src/os/file.go#L313
// Returns true if the table cannot hold any more entries
func IsTableFull( ) bool
注释应该解释代码如何做的
适合注释实现过程
// Add the Referer header from the most recent
// request URL to the new one,if it's not https->http:
if ref := refererForURL( reqs[len(reqs)-1].URL,req.URL); ref !="" {
req.Header.Set( "Referer", ref)
}
原文链接:https://github.com/golang/go/blob/master/src/net/http/client.go#L678
// Process every element in the list
for e := range e Lements {
process(e)
}
注释应该解释代码实现的原因
适合解释代码的外部因素
提供额外上下文
switch resp.StatusCode {
// ...
case 307, 308:
redirectMethod = reqMethod
shouldRedirect = true
includeBody = true
if ireq.GetBody == nil && ireq.outgoingLength() != 0 {
// We had a request body, and 307/308 require
// re-sending it, but GetBody is not defined. So just
// return this response to the user instead of an
// error, like we did in Go 1.7 and earlier.
shouldRedirect = false
}
}
原文链接:https://github.com/golang/go/blob/master/src/net/http/client.go#L521
注释应该解释代码什么情况会出错
适合解释代码的限制条件
// parseTimeZone parses a time zone string and returns its length. Time zones
// are human-generated and unpredictable can't do precise error checking.
// 0n the other hand, fora correct parsl there must be a time zone at the
// beginning of the string, so it's almost always true that there's one
// there. We look at the beginning of the string for a run of upper-case letters.
// If there are more than 5,it's an error.
// If there are 4 or 5 and the last is aT,it's a time zone.
// If there are3,it's a time zone.06
// Otherwise, other than special cases, it's not a time zone.
// GMT is special because it can have an hour offset.
func parseTimeZone(value string) (length int, ok bool)
原文链接:https://github.com/golang/go/blob/master/src/time/format.go#L1344
公共符号始终要注释
包中声明的每个公共的符号:变量、常量、函数以及结构都需要添加注释
任何既不明显也不简短的公共功能必须予以注释
无论长度或复杂程度如何,对库中的任何函数都必须进行注释
// ReadAll reads from r until an error or EOF and returns the data it read.
// A successful call returns err == nil, not err == EOF. Because ReadAll is
// defined to read from src until EOF, it does not treat an EOF from Read
// as an error to be reported.
func ReadAll(r Reader) ([]byte,error)
原文链接:https://github.com/golang/go/blob/master/src/io/io.go#L638
公共符号始终要注释
有一个例外,不需要注释实现接口的方法。具体不要像下面这样做
// Read implements the io.Rladet, interface
func (r *FileReader) Read(bur []byte) (int error)
公共符号始终要注释
对于公共符号都有注释说明尽管L imitedReader.Read本身没有注释,但它紧跟LimitedReader结构的声明,明确它的作用
// LimitReader. returns a Reader that reads from r
// but stops with EOF after n bytes.
// The underlying imp lementation is a *L im i tedReader.
func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} }
// A LimitedReader reads from R but limits the amount of
// data returned to just N bytes. Each call to Read
// updates N to reflect the new amount remaining.
// Read returns EOF when N <= 0 or when the underlying R returns EOF.
type LimitedReader struct {
R Reader // underlying reader
N int64 // max bytes remaining
}
func (l *LimitedReader) Read(p []byte) (n int, err error) {
if l.N<=0{
return 0,EOF
}
if int64(len(p)) > l.N {
p = p[0:l.N]
n.err = l.R.Read(p)
l.N -= int64(n)
return
}
variable
ServeHTTP
而不是ServeHttp
XMLHTTPRequest
或者xmIHTTPRequest
举例:
// Good
func (C *Client) send(req *Request, deadline time.Time)
// Bad
func (C *Client) send(req *Request, t time.Time)
deadline
替换成t降低了变量名的信息量t
常代指任意时间deadline
指截止时间,有特定的含义“所以建议不要把
deadline
换成t
,这样降低了变量名的信息量
function
foo
的包某个函数返回类型Foo
时,可以省略类型信息而不导致歧义foo
的包某个函数返回类型T时(T并不是Foo
),可以在函数名中加入类型信息“http包中创建服务的函数如何命名更好?
func Serve(l net._istener, handler Handler) error
func ServeHTTP(l net.Listener, handler Handler) error
推荐使用第一种,原因在于你在调用http包的时候是.Serve
package
schema
、task
等sync
或者strings
以下规则尽量满足,以标准库包名为例
bufio
而不是buf
encoding
而不是encodings
format
更加简短“Good naming is like a good joke. If you have to explain it, it s not funny 好的命名就像一个好笑话。如果你必须解释它,那就不好笑了 --Dave Cheney
避免嵌套,保持正常流程清晰
如果两个分支中都包含return语句,则可以去除冗余的else。
// Bad
if foo {
return X
} else {
return nil
}
// Good
if foo {
return X
}
return nil
尽量保持正常代码路径为最小缩进优先处理错误情况特殊情况,尽早返回或继续循环来减少嵌套
// Bad
func OneFunc( ) error {
err := doSomething( )
if err == nil{
err := doAnotherThing( )
if err == nil{
return nil // normal case
}
return err
}
return err
}
// Good
func OneFunc( ) error {
if err := doSomething(); err != nil {
return err
}
if err. := doAnotherThing(); err != nil {
return err
}
returrpnil // normal case
}
func (b *Reader) UnreadByte( ) error {
if b.lastByte < 0 II b.r == 0 && b.W> 0{
return ErrInvalidUnreadByte
}
// b.r> 0//b.w== 0
ifb.r>0{
b.r--
206
} else {
// b.r==20 && b.w == 0
b.w=1
}
b.buf[b.r] = byte(b. lastByte)
b.lastByte = -1
b. lastRuneSize = -1 ( 6
return nil
}
简单错误
errors.New
来创建匿名变量来直接表示简单错误fmt.Errorf
func defaultCheckRedirect(req *Request, via []*Request) error {
if len(via) >= 10 {
return errors . New( "stopped after 10 redirects")
}
return nil
}
原文链接:https://github.com/golang/go/blob/master/src/net/http/client.go
错误的Wrap和Unwrap
错误的Wrap实际上是提供了一个error
嵌套另一个error
的能力,从而生成一个error
的跟踪链
在fmt.Errorf
中使用: %w关键字来将一个错误关联至错误链中
list,_,err := c.GetBytes( cache. Subkey(a.actionID, "srcfiles"))
if err != nil{
return fmt. Errorf("reading srcfiles list: %W",err )
}
https://github.com/golang/go/blob/master/src/cmd/go/internal/work/exec.go#L.983
“Go1.13在errors中新增了三个新API和一个新的format关键字,分别是errors.Is errors.As,errors.Unwrap以及fmt.Errorf的%W。 如果项目运行在小于Go1.13的版本中,导入golang.org/x/ xerrors来使用
错误判定
判定一个错误是否为特定错误,使用errors.Is
不同于使用==,使用该方法可以判定错误链.上的所有错误是否含有特定的错误
data, err = lockedfile. Read( targl2209
if errors.Is(err, fs.ErrNotExist) {
// Treat non-existent as empty,to bootstrap the "latest" file
// the first time we connect to a given database.
return []byte{}, nil
}
return data, err
https://github.com/golang/go/blob/master/src/cmd/go/internal/modfetch/sumdb.go#L208
错误判定
在错误链上获取特定种类的错误,使用errors.As
if _,err := os.0pen( "non-existing"); err != nil {
var pathError *fs. PathEr r
if errors.As(err, &pathEroor) {
fmt. Println("Failed at path:", pathError .Path)
} else {
fmt.Println(err)
}
}
panic
不建议在业务代码中使用panic
调用函数不包含recover
会造成程序崩溃
若问题可以被屏蔽或解决,建议使用error代替panic
当程序启动阶段发生不可逆转的错误时,可以在init或main函数中使用panic
func main() {
ctx, cancel := context.WithCancel(context.Background( ))
client,err := sarama.NewConsumerGroup(strings.Split(brokers,"), group, config)
if err!=nil{
log.Panicf("Error creating consumer group client: %V",err)
}
}
// Panicf is equivalent to Printf() followed by a call to panic().
func Panicf(format string, V ... interface{}) {
S := fmt.Sprintf(format, V... )
std.output(2,s)
panic(s)
}
recover
recover
只能在被defer的函数中使用
嵌套无法生效
只在当前goroutine
生效
defer
的语句是后进先出
func (s *ss) Token(skipSpace bool, f func( rune) bool)
(tok []byte, err error) {
defer func () {
if e := recover(); e != nil {
if se, ok := e.(scanError); ok {
err = se.err
} else {
panic(e)
}
}
}()
// ...
}
如果需要更多的上下文信息,可以recover 后在log中记录当前的调用栈
func (t *treeFS) Open(name string) (f fs.File, err error) {
defer func() {
if e := recover(); e != nitt {
f = nil
err = fmt.Errorf("gitfs panic: %V\n%S", e, debug.Stack())
}
// ...
}
}
error
尽可能提供简明的.上下文信息链,方便定位问题panic
用于真正异常的情况recover
生效范围,在当前goroutine
的被defer
的函数中生效哪种命名方式更好?
package time
// A functione returns the current local t ime.
// which one is better?
func Now() Time
// or
func NowTime() Time
看看调用的时候,time.Now()
更简洁
t := time.Now()
t := time.NowTime()
package time
// A function parses a duration string.
// such as "300ms", "-1.5h” or "2h45m'
func Parse(s string) (Duration, error)
// or
func ParseDuration(s string) (Duration, error)
实际调用的时候
duration := time.Parse(s)
duration := time.ParseDuration(s)
程序的输出是什么?
func main() {
if true {
defer fmt.Printf("1")
} else {
defer fmt.Printf("2")
}
defer fmt.Printf("3")
}
// 输出31
defer
语句会在函数返回前调用 多个defer
语句是后进先出
Go
语言特性,介绍Go
相关的性能优化建议高质量的代码能够完成功能,但是在大规模程序部署的场景,仅仅支持正常功能还不够,我们还要尽可能的提升性能,节省资源成本。
接下来就主要介绍性能相关的建议
Go
相关的性能优化建议如何使用?
Go
语言提供了支持基准性能测试的benchmark
工具// from fib.go
func Fib(n int) int {
if n < 2 {
return n
}
return Fib(n-1) + Fib(n-2)
}
// from fib_test.go
func BenchmarkFib10(b *testing.B) {
// run the Fib function b.N times
for n := 0; n < b.N; n++ {
Fib(10)
}
}
我们如何评估性能, 性能表现要用数据说话,实际情况和想象中的并不一定一致, 要用数据来验证我们写的代码是否真的有性能提升
Go
自带了性能评估工具
以计算斐波拉契数列的函数为例,分两个文件,fib.go
编写 函数代码,fib._test.go
编写benchmark
的逻辑,通过命令运行benchmark
可以得到测试结果benchmem
表示也统计内存信息
slice预分配内存
make()
初始化切片时提供容量信息func NoPreAlloc(size int) {
data := make([]int, 0)
for k := 0; k < size; k++ {
data = append(data, k)
}
}
func PreAlloc(size int) {
data := make([]int, 0, size)
for k := 0; k < size; k++ {
data = append(data, k)
}
}
slice
是go
中最常用的结构,也很方便,那么在使用过程中有哪些点需要注意几点:
图来源:https://ueokande.github.io/go-slice-tricks
我们看看slice
的结构:切片本质是一个数组片段的描述,包括数组指针,片段的长度,片段的容量(不改变内存分配情况下的最大长度)
,切片操作并不复制切片指向的元素。创建一个新的切片 会复用原来切片的底层数组,以切片的append
为例,append
时有两种场景 :
append
之后的长度小于等于cap
,将会直接利用原底层数组剩余的空间。append
后的长度大于cap
时,则会分配-块更大的区域来容纳新的底层数组。因此,为了避免内存发生拷贝,如果能够知道最终的切片的大小,预先设置cap
的值能够避免额外的内存分配,获得更好的性能
type slice struct {
array unsafe.Pointer
len int
cap int
}
另一个陷阱:大内存未释放在已有切片基础上创建切片,不会创建新的底层数组
场景
可使用copy替代re-slice
func GetLastBySlice(origin []int) []int {
return origin[len(origin)-2:]
}
func GetLastByCopy(origin []int) []int {
result := make([]int, 2)
copy(result, origin[len(origin)-2:])
return result
}
func testGetLast(t *testing.T, f func([]int) []int) {
result := make([][]int, 0)
for k := 0; k < 100; k++ {
origin := generateWithCap(128 * 1024) // 1M
result = append(result, f(origin))
}
printMem(t)
_ = result
}
Go 语言高性能编程:https://geektutu.com/post/hpg-slice.html
map预分配内存
除了slice
,map
也是编程中常用的结构,是不是也有预分配的性能优化点呢?可以对比验证下
func NoPreAlloc(size int) {
data := make(map[int]int)
for i := 0; i < size; i++ {
data[i] = 1
}
}
func PreAlloc(size int) {
data := make(map[int]int, size)
for i := 0; i < size; i++ {
data[i] = 1
}
}
分析
map
中添加元素的操作会触发map
的扩容Rehash
的消耗使用strings.Builder
编程过程中除了slice
和map
,平时很多得功能都和子字符用处理相关的,字符串处理也是高频操作。
常见的字符串拼接方式
func Plus(n int, str string) string {
s := ""
for i := 0; i < n; i++ {
s += str
}
return s
}
func StrBuilder(n int, str string) string {
var builder strings.Builder
for i := 0; i < n; i++ {
builder.WriteString(str)
}
return builder.String()
}
func ByteBuffer(n int, str string) string {
buf := new(bytes.Buffer)
for i := 0; i < n; i++ {
buf.WriteString(str)
}
return buf.String()
}
使用+拼接性能最差,strings.Builder, bytes.Buffer 相近,strings.Buffer 更快分析
Go
语言中是不可变类型,占用内存大小是固定的strings.Builder
, bytes.Buffer
底层都是[]byte 数组当使用+拼接2个字符串时,生成一个新的字符串,那么就需要开辟一段新的空间, 新空间的大小是原来两个字符串的大小之和。拼接第三个字符串时,再开辟一段新空间, 新空间大小是三个字符串大小之和,以此类推。
为什么stringbuilder会比bytebuffer更快一些,可以看看实际的代码
注意注释里也提到如果想用更高效的字符串构造方法,可以使用string builder
联系上面的内容,有没有办法再次提升字符串拼接的效率?关键字是什么?预分配
// To build strings more efficiently, see the strings . Builder type.
func (b *Buffer) String() string {
if b == nil {
// Special case, useful in debugging.
return "<nil>"
}
return string(b.buf[b.off:])
}
bytes.Buffer
转化为字符串时重新申请了一块空间strings.Builder
直接将底层的[]byte转换成了字符串类型返回
// String returns the accumulated string.
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}
字符串拼接和slice
一样,同样支持预分配,在预知字符串长度的情况下,我们可以进一步提升拼接性能 注意这里能确认stringbuiler
只有一次内存分配,bytebuffer
有两次
func PreStrBuilder(n int, str string) string {
var builder strings.Builder
builder.Grow(n * len(str))
for i := 0; i < n; i++ {
builder.WriteString(str)
}
return builder.String()
}
func PreByteBuffer(n int, str string) string {
buf := new(bytes.Buffer)
buf.Grow(n * len(str))
for i := 0; i < n; i++ {
buf.WriteString(str)
return buf.String()
}
}
性能优化有时是时间和空间的平衡,之前提到的都是提高时间效率的点,而空结构体是节省内存空间的一个手段。
使用空结构体节省内存
structf
实例不占据任何的内存空间func EmptyStructMap(n int) {
m := make(map[int]struct{})
for i := 0; i < n; i++ {
m[i] = struct{}{}
}
}
func BoolMap(n int) {
m := make(map[int]bool)
for i := 0; i < n; i++ {
m[i] = false
}
}
空结构体占用内存更少些,在元素更多的情况下会更明显。实际应用场景有哪些,容易想到的是set实现
一个开源实现:https://github.com/deckarep/golang-set/blob/main/threadunsafe.go
在工作中迟早会遇到多线程编程的场景,比如实现一个多线程共用的计数器,如何保证计数准确,线程安全,有不同的方式
如何使用atomic包
type atomicCounter struct {
i int32
}
func AtomicAddOne(c *atomicCounter) {
atomic.AddInt32(&c.i, 1)
}
type mutexCounter struct {
i int32
m sync.Mutex
}
func MutexAdd0ne(c *mutexCounter) {
c.m.Lock()
c.i++
c.m.Unlock()
}
上面我们讲了高质量编程的原则和一些实践规范,同时给出了一些性能优化建议,那么在实际工作中,如何要针对某个应用程序进行性能调优,应该如何做呢?
性能调优原则
既然性能调优前提是对应用程序性能表现有实际的数据指标,那么有什么工具能够获得这种数据呢?
对于go程序,有一个很方便的工具就是pprof。接下来我们从三个方面来熟悉下pprof工具
具体pprof有哪些内容?可以看下图片
搭建pprof实践项目
我们的目标是熟悉pprof工具,能够排查性能问题,那么首先我们需要构造一个有问题的程序, 看看如何用pprof来定位性能问题点 这里有个开源项目,已经制造了一些问题代码,需要我们进行排查。大家用pprof示例命令实验下能否正常打开pprof页面,是否缺少graphviz的组件
我们来看看「炸弹I程序是怎么做的。图中代码是main.go中初始化htp服务和pprof接口的代码,无关逻辑有所省略。可以看到,引入http pprof这个包,它会将pprof的入口注册到/debug/pprof这个路径下, 我们可通过浏览器打开这个路径,来查着些基本的性能统计。运行「炸弹」, 并等待它运行稳定
package main
import (
"log"
"net/http"
_ "net/http/pprof"
"os"
"runtime"
"time"
"github.com/wolfogre/go-pprof-practice/animal"
)
func main() {
log.SetFlags(log.Lshortfile | log.LstdFlags)
log.SetOutput(os.Stdout)
runtime.GOMAXPROCS(1)
runtime.SetMutexProfileFraction(1)
runtime.SetBlockProfileRate(1)
go func() {
if err := http.ListenAndServe(":6060", nil); err != nil {
log.Fatal(err)
}
os.Exit(0)
}()
for {
for _, v := range animal.AllAnimals {
v.Live()
}
time.Sleep(time.Second)
}
}
浏览器查看指标
在浏览器中打开http://localhost:6060/debug/pprof,可以看到这样的页面,这就是我们刚刚引入的net/http/pprof注入的入口了。页面上展示了可用的程序运行采样数据,下面也有简单说明, 分别是:
cmdline显示运行进程的命令,threadcreate比较复杂,不透明,trace需要另外的工具解析,暂不涉及。
炸弹在CPU,堆内存,goroutine, 锁竞争和阻塞操作上埋了炸弹,可以使用pprofI具进行分析
allocs表示分配了12次内存,block表示有3次阻塞,goroutine表示有55个协程正在运行,heap为堆上内存使用,mutex表示有1个锁竞争,threadcreate表示有6个线程创建。
浏览器访问:http://localhost:6060/debug/pprof/allocs?debug=1
CPU
命令: topN 查看占用资源最多的函数
我们先从CPU问题排查开始,不同的操作系统具可能不同,我们首先使用自己熟悉的工具看看程序进程的资源占用,CPU占用了13.2%,显然这里是有问题的
pprof的采样结果是将一段时间内的信息汇 总输出到文件中,所以首先需要拿到这个profile文件。你可以直接使用暴露的接口链接下载文件后使用,也可以直接用pprofI具连接这个接口下载需要的数据。
这里我们使用go tool pprof +采样链接来启动采样。链接中就是刚刚「炸弹」 程序暴露出来的接口,链接结尾的profile代表采样的对象是CPU使用。如果你在浏览器里直接打开这个链接,会启动一个60秒的采样,并在结束后下载文件。这里我们加上seconds=10的参数,让它采样+秒。稍等片刻,我们需要的采样数据已经记录和下载完成,并展示出pprof终端
go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"
很明显github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Eat
的调用占用了绝大部分CPU。
输入top, 查看CPU占用最高的函数。这五列从左到右分别是:
flat | 当前函数本身的执行耗时 |
---|---|
flat% | flat占CPU总时问的比例 |
sum % | 上面每一行的flat%总和 |
cum | 指当前函数本身加上其调用函数的总耗时 |
cum % | cum占CPU总时间的比例 |
表格前面描述了采样的总体信息。默认会展示资源占用最高的10个函数,如果只需要查看最高的N个函数,可以输入topN, 例如查看最高的3个调用,输入top3 可以看到表格的第一行里, Tiger.Eat函数本身 占用3.56秒的CPU时间,占总时间的95.44%, 显然问题就是这里引起的
什么情况下Flat ==Cum?
什么情况下Flat ==O?
Flat == Cum,函数中没有调用其他函数
Flat == 0,函数中只有其他函数的调用
命令:list 根据指定的正则表达式查找代码行
接着,输入list Eat
查找这个函数,看看具体是哪里出了问题
List命令会根据后面给定的正则表达式查找代码,并按行展示出每一行的占用。可以看到,第24行有 一个100亿次的空循环,占用了5.07秒的CPU时间,问题就在这儿了,定位成功。
代码注释一下
命令:web 调用关系可视化
“输入
web命令
,这个命令前提是需要下载graphviz
https://graphviz.org/download/
除了这两种视图之外,我们还可以输入web命令,生成一张调用关系图,默认会使用浏览器打开。图中除了每个节点的资源占用以外,还会将他们的调用关系穿起来。图中最明显的就是方框最红最大,线条最粗的 *Tiger.Eat函数, 是不是比top视图更直观些呢?到这里,CPU的炸弹已经定位完成,我们输入q退出终端。
为了方便后续的展示,我们先将这个「炸弹|拆除,注意这一部分我们的主 要目的是展示如何定位问题,所以不会花太多时间在看代码上,我们将问题代码直接注释掉,重新打开「炸弹」程序。
Heap-堆内存
注释CPU问题代码,重新运行后。打开活动监视器,可以发现进程的CPU已经降下来了。然而内存使用还是很高接我们来排查内存问题。
在刚刚排查CPU的过程中,我们使用的是pprof终端,这里我们介绍另一种展示方式。通过-http=:8080参数, 可以开启pprof自带的Web UI,性能指标会以网页的形式呈现。再次启动pprof工具,注意这时的链接结尾是heap。等待采样完成后,浏览器会被自动打开,展示出熟悉的web视图,同时展示的资源使用从「CPU时间」变为了「内存占用」 可以明显看到,这里出问题的是*Mouse.Steal0函数, 它占用了1GB内存。在页面顶端的View菜单中,我们可以切换不同的视图。
go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/heap“
命令输入之后,会自动跳转到http://localhost:8080/ui/ ,就可以看到如下画面了
我们再切换到Source视图,可以看到页面上展示出了刚刚看到的四个调用和具体的源码视图。如果觉得内容太多,也可以在顶部的搜索框中输入Steal来使用正则表达式过滤需要的代码。
根据源码我们会发现,在*Mouse.Steal()这 函数会向固定的Buffer中不断追加1MB内存,直到Buffer达到1GB大小为止, 和我们在Graph视图中发现的情况一致。我们将这里的问题代码注释掉,至此,「炸弹 」已被拔除了两个。
重新运行「炸弹」程序,发现内存占用已经降到了23.6MB,刚才的解决方案起效果了 在采样中,也没有出现异常节点了(实际上没有任何节点了)。不过,内存的问题真的被全部排除了吗? 大家在使用工具的过程中,有没有注意过右上角有个unknown jinuse. space
我们打开sample菜单,会发现堆内存实际上提供了四种指标:
在堆内存采样中,默认展示的是inuse_ space视图,只展示当前持有的内存, 但如果有的内存已经释放,这时inuse采样就不会展示了 。我们切换到alloc_ space指标。后续分析下alloc的内存问题
马上就定位到了*Dog.Run0中还藏着一个[炸弹」,它会 每次申请16MB大小的内存,并且已经累计申请了超过3.5GB内存。在Top视图中还能看到这个函数被内联了。
看看定位到的函数,果然,这个函数在反复申请16MB的内存,但因为是无意义的申请,分配结束之后会马上被GC,所以在inuse采样中并不会体现出来。
我们将这一行问题代码注释掉, 继续接下来的排查。至此,内存部分的「炸弹」已经被全部拆除。
github.com/wolfogre/go-pprof-practice/animal/muridae/mouse.(*Mouse).Steal
D:\Golang\GoWordPlace\pkg\mod\github.com\wolfogre\go-pprof-practice@v0.0.0-20190402114113-8ce266a210ee\animal\muridae\mouse\mouse.go
Total: 1GB 1GB (flat, cum) 100%
45 . .
46 . . func (m *Mouse) Steal() {
47 . . log.Println(m.Name(), "steal")
48 . . max := constant.Gi
49 . . for len(m.buffer) * constant.Mi < max {
50 1GB 1GB m.buffer = append(m.buffer, [constant.Mi]byte{})
51 . . }
52 . . }
runtime.main
D:\Golang\Go\src\runtime\proc.go
Total: 0 1GB (flat, cum) 100%
198 . . // A program compiled with -buildmode=c-archive or c-shared
199 . . // has a main, but it is not executed.
200 . . return
201 . . }
202 . . fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
203 . 1GB fn()
204 . . if raceenabled {
205 . . racefini()
206 . . }
207 . .
208 . . // Make racy client program work: if panicking on
github.com/wolfogre/go-pprof-practice/animal/muridae/mouse.(*Mouse).Live
D:\Golang\GoWordPlace\pkg\mod\github.com\wolfogre\go-pprof-practice@v0.0.0-20190402114113-8ce266a210ee\animal\muridae\mouse\mouse.go
Total: 0 1GB (flat, cum) 100%
18 . . m.Eat()
19 . . m.Drink()
20 . . m.Shit()
21 . . m.Pee()
22 . . m.Hole()
23 . 1GB m.Steal()
24 . . }
25 . .
26 . . func (m *Mouse) Eat() {
27 . . log.Println(m.Name(), "eat")
28 . . }
main.main
F:\GitHub--Gitee\zijie\go-pprof-practice\main.go
Total: 0 1GB (flat, cum) 100%
26 . . os.Exit(0)
27 . . }()
28 . .
29 . . for {
30 . . for _, v := range animal.AllAnimals {
31 . 1GB v.Live()
32 . . }
33 . . time.Sleep(time.Second)
34 . . }
35 . .
36 . . }
goroutine-协程
goroutine
泄露也会导致内存泄露
Golang
是门自带垃圾回收的语言,一般情况 下内存泄露是没那么容易发生的 但是有种例外: goroutine
是很容易泄露的,进而会导致内存泄露。所以接下来,我们来看看goroutine
的使用情况。打开http://ocalhost:6060/debug/pprof/ 发现「炸弹」程序已经有54条goroutine
在运行了,这个量级并不是很大,但对于一个简单的小程序来说还是很不正常的。
大家对链接含义应该很熟悉了,把链接末尾换成goroutine
, 可以看到,pprof
生成了一张非常长的调用关系图,尽管最下方的问题用红色标了出来,不过这么多节点还是比较难以阅读的。这里我们介绍另一种更加直观的展示方式一火焰图。
go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/goroutine”
打开View
菜单,切换到Flame Graph视图。可以看到,刚才的节点被堆叠了起来 图中,自顶向下展示了各个调用,表示各个函数调用之间的层级关系 每一行中,条形越长代表消耗的资源占比越多。显然,那些[又平又长」的节点是占用资源多的节点 可以看到,*Wolf.Drink()这 个调用创建了超过90%的goroutine
,问题可能在这里
火焰图是非常常用的性能分析工具,在程序逻辑复杂的情况下很有用,可以重点熟悉
CPU
的时间更长这里为了模拟泄漏场景,只等待了30秒就退出了试想下,如果发起的goroutine
没有退出,同时不断有新的goroutine
被启动, 对应的内存占用持续增长,CPU调度压力也不断增大, 最终进程会被系统il掉
Source
视图下搜索wolf
mutex-锁
修改链接后缀,改成mutex,然后打开网页观察,发现存在1个锁操作
同样地,在Graph
视图中定位到出问题的函数在*Wolf.HowI()。然后在Source
视图中定位到具体哪一行发生了锁竞争
在这个函数中,goroutine
足足等待 了1秒才解锁,在这里阻塞住了,显然不是什么业务需求,注释掉。
block-阻塞
浏览器访问:http://localhost:6060/debug/pprof/block
我们开始排查最后一个阻塞问题。在程序中,除了锁的竞争会导致阻塞之外,还有很多逻辑(例如读取一个channel
) 也会导致阻塞,在页面中可以看到阻塞操作还剩两个强调)。链接地址末尾再换成block
和刚才一样,Graph
到Source
的视图切换。可以看到,在*Cat.Pee0函数中读取 了一个time.After()生成的channel
,这就导致了这个goroutine
实际上阻塞了1秒钟,而不是等待了1秒钟。
两个Block为什么只展示了一个
go tool pprof "http://localhost:6060/debug/pprof/block"
])
不过通过上面的分析,我们只定位到一个block的问题 有同学可能会发现。刚刚的计数页面上有两个阻塞操作,但是实际上只有一个, 那么另一个为什么没有展示呢? 提示,可以关注一下pprof Top
视图中表格之外的部分,从框住的地方可以发现,有4个节点因为cumulative小于1.41秒被drop掉了 ,这就是另一个阻塞操作的节点,但他因为总用时小于总时长的千分之5,所以被省略掉了。这样的过滤策略能够更加有效地突出问题所在,而省略相对没有问题的信息。如果不作任何过滤全部展示的话,对于-个复杂的程序可能内容就会非常庞大了,不利于我们的问题定位。
我们知道了另-个阻塞是确实存在的,接下来大家可以进一步猜测一下:没有展示的这个阻塞操作发生在哪里? 需要点击哪个链接查看?
提示:程序除了在跑性能「炸弹」之外, 还有什么工作呢?我们是怎样访问它的采样信息的?
尽管占用低的节点不会在pprof
工具中展示出来,但实际上他是被记录下来的。我们还可以通过暴露出来的接口地址直接访问它。所以,打开Block指标的页面,可以看到,第二个阻塞操作发生在了http handler中,这个阻塞操作是符合预期的。
至此,我们已经发现了代码中存在的所有「炸弹J。以上我们介绍了五种使用pprof
采集的常用性能指标: CPU、堆内存、Goroutine、 锁竞争和阻塞;
两种展示方式交 互式终端和网页:四种视图: Top、Graph、 源码和火焰图。pprof除了http的获取方式之外,也可以直接在运行时调用runtime/pprof包将指标数据输出到本地文件。视图中还有一个更底层的[反汇编」视图。有兴趣的同学可以尝试一下它们的用法。
俗话说,知其然还要知其所以然,接下来我们来看看它们内部的实现
首先来看CPU
。CPU
采样会记录所有的调用栈和它们的占用时间。在采样时,进程会每秒暂停一百次, 每次会记录当前的调用栈信息。汇总之后,根据调用栈在采样中出现的次数来推断函数的运行时间。
你需要手动地启动和停止采样。每秒100次的暂停频率也不能更改。这个定时暂停机制在unix
或类unix
系统上是依赖信号机制实现的。每次「暂停」都会接收到一个信号,通过系统计时器来保证这个信号是固定频率发送的。接下来看看具体的流程。
CPU
一共有三个相关角色:进程本身、操作系统和写缓冲。
启动采样时,进程向OS注册一个定时器,OS会每隔10ms向进程发送一个SIGPROF信号, 进程接收到信号后就会对当前的调用栈进行记录。
与此同时,进程会启动一个写缓冲的goroutine
,它会每隔100ms从进程中读取已经记录的堆栈信息,并写入到输出流。
当采样停止时,进程向OS取消定时器,不再接收信号,写缓冲读取不到新的堆栈时,结束输出。
接下来看看堆内存采样。提到内存指标的时候说的都是「堆内存」而不是「内存」, 这是因为pprof的内存采样是有局限性的。
内存采样在实现上依赖了内存分配器的记录,所以它只能记录在堆上分配,且会参与GC的内存,一些其他的内存分配, 例如调用结束就会回收的栈内存、一 些 更底层使用cgo调用分配的内存,是不会被内存采样记录的。
它的采样率是一个大小, 默认每分配512KB内存会采样一 次,采样率是可以在运行开头调整的, 设为1则为每次分配都会记录。
与CPU和goroutine
都不同的是,内存的采样是一个持续的过程, 它会记录从程序运行起的所有 分配或释放的内存大小和对象数量,并在采样时遍 历这些结果进行汇总。
还记得刚才的例子中,堆内存采样的四种指标吗? alloc的两项指标是从程序运行开始的累计指标,而inuse的两项指标是通过累计分配减去累计释放得到的程序当前持有的指标。你也可以通过比较两次alloc的差值来得到某一段时间程序 分配的内存[大小和数量]
Heap-堆内存
alloc space
, alloc objects
, inuse_ space
, inuse_ objects
inuse = alloc- free
Goroutine-协程 & ThreadCreate-线程创建
接下来我们来看看goroutine
和系统线程的采样。这两个采样指标在概念上和实现上都比较相似,所以在这里进行对比。
goroutine
采样会记录所有用户发起,也就是入口不是runtime开头的goroutine
, 以及main函数所在goroutine
的信息和创建这些goroutine
的调用栈:
他们在实现上非常的相似,都是会在STW之后,遍历所有goroutine/所有线程的列表(图中的m就是 GMP模型中的m,在golang中和线程一对应) 并输出堆栈。
最后Start The Word继续运行。这个采样是立刻触发的全量记录,你可以通过比较两个时间点的差值来得到某一时间段的指标。
goroutine
(即入口非runtime开头的) runtime.main的调用栈信息Block-阻塞&Mutex-锁
阻塞和锁竞争这两种采样
这两个指标在流程和原理上也非常相似。两个采样记录的都是对应操作发生的调用栈、次数和耗时,不过这两个指标的采样率含义并不相同。
它们在实现上也是基本相同的。都是一个「主动上报」的过程。
在阻塞操作或锁操作发生时,会计算出消耗的时间,连同调用栈一起主动上报给采样器,采样器会根据采样率可能会丢弃些记录。
在采样时,采样器会遍历已经记录的信息,统计出具体操作的次数、调用栈和总耗时。和堆内存一样,你可以对比两 个时间点的差值计算出段时间内的操作指标。
阻塞操作
锁竞争
小结
pprof
工具功能pprof
工具分析解决性能问题pprof
的采样过程和工作原理在实战过程中,虽然我们只是模拟了一个使用pprof
分析性能问题的小场景,但是大部分排查思路和想法是通用的,熟悉pprof
工具后, 分析真正的服务问题也会得心应手,对解决性能问题和性能调优很有帮助。
简介
在实际工作中,当服务规模比较小的时候,可能不会触发很多性能问题,同时性能优化带来的效果也不明显,很难体会到性能调优带来的收益。而当业务量逐渐增大,比如一个服务使用了几千台机器的时候,性能优化一个百分点,就能节省数百台机器,成本降低是非常可观的
接下来我们来了解下工程中进行性能调优的实际案例
程序从不同的应用层次上看,可以分为业务服务、基础库和Go语言本身三类,对应优化的适用范围也是越来越广
基本概念
那么针对逻辑相对复杂的业务服务,它的性能调优流程是怎么样的呢?在介绍真正流程之前,可能有的同学对部分名词不太了解,先介绍一下
右边是系统部署的简单示意图,客户端请求经过网关转发,由不同的业务服务处理,业务服务可能依赖其他的服务,也可能会依赖存储、消息队列等组件。接下来我们以业务服务优化为例,说明性能调优的流程,图中的Service B被Service A依赖,同时也依赖了存储和Service D
流程
那么接下来就来看一下业务服务优化的主要流程,主要分四步,这些流程也是性能调优相对通用的流程,可以适用其他场景和上面评估代码优化效果的benchmarkI
具类似,对于服务的性能也需要一个评估手段和标准优化的核心是发现服务性能的瓶颈,这里主要也是用ppro
采样性能数据,分析服务的表现发现瓶颈后需要进行服务改造,重构代码,使用更高效的组件等
最后一步是优化效果验证,通过压测对比和正确性验证之后,服务可以上线进行实际收益评估
整体的流程可以循环并行执行,每个优化点可能不同,可以分别评估验证
建立服务性能评估手段
benchmark
无法满足复杂逻辑分析性能数据采集
之所以不用benchmark
是因为实际服务逻辑比较复杂,希望从更高的层面分析服务的性能问题,同时机器在不同负载下的性能表现也会不同,右图是负载和单核qps
的对应数据。
另外因为逻辑复杂,不同的请求参数会走不同的处理逻辑,对应的性能表现也不相同,需要尽量模拟线上真实情况,分析真正的性能瓶颈 压测会录制线上的请求流量,通过控制回放速度来对服务进行测试,测试范围可以是单个实例,也可以是整个集群,同样性能采集也会区分单机和集群
建立服务性能评估手段
评估手段建立后,它的产出是什么呢?实际是一个服务的性能指标分析报告 实际的压测报告截图,会统计压测期间服务的各项监控指标,包括qps,延迟等内容,同时在压测过程中,也可以采集服务的ppro
数据,使用之前的方式分析性能问题
分析性能数据,定位性能瓶颈
使用库不规范
有了服务优化前的性能报告和一些性能采样数据, 我们可以进行性能瓶颈分析了业务服务常见的性能问题可能是使用基础组件不规范
比如这里通过火焰图看出json
的解析部分占用了较多的CPU资源,那么我们就能定位到具体的逻辑代码,是在每次使用配置时都会进行json
解析, 拿到配置项,实际组件内部提供了缓存机制,只有数据变更的时候才需要重新解析json
还有是类似日志使用不规范,一部分是调试日志发布到线上,一部分是线上服务在不同的调用链路上数据有差别,测试场景日志量还好,但是到了真实线上全量场景,会导致日志量增加,影响性能
高并发场景优化不足
另外常见的性能问题就是高并发场景的优化不足,左边是服务高峰期的火焰图,右边是低峰期的火焰图,可以发现metrics, 即监控组件的CPU资源占用变化较大,主要原因是监控数据上报是同步请求,在请求量上涨,监控打点数据量增加时,达到性能瓶颈,造成阻塞,影响业务逻辑的处理,后续是改成异步上报的机制提升了性能
重点优化项改造
定位到性能瓶颈后,我们也有了对应的修复手段,但是修改完后能直接发布上线吗? 性能优化的前提是保证正确性,所以在变动较大的性能优化上线之前,还需要进行正确性验证,因为线上的场景和流程太多,所以要借助自动化手段来保证优化后程序的正确性
同样是线上请求的录制,不过这里不仅包含请求参数录制,还会录制线上的返回内容,重放时对比线上的返回内容和优化后服务的返回内容进行正确性验证。比如图中作者信息相关的字段值在优化有有变化,需要进一步排查原因
优化效果验证
改造完成后,可以进行优化效果验证了
验证分两部分,首先依然是用同样的数据对优化后的服务进行压测,可以看到现在的数据比优化前好很多,能够支持更多的qps 正式上线的时候会逐步放量,记录真正的优化效果。同时压测并不能保证和线上表现完全一致, 有时还要通过线上的表现再进行分析改进,是个长期的过程
进一步优化,服务整体链路分析
以上的内容是针对单个服务的优化过程,从更高的视角看,性能是不是还有优化空间? 在熟悉服务的整体部署情况后,可以针对具体的接口链路进行分析调优。比如Service A调用Service B是否存在重复调用的情况,调用Service B服务时,是否更小的结果数据集就能满足需求,接口是否一定要实时数据,能否在Service A层进行缓存,减轻调用压力
这种优化只使用与特定业务场景,适用范围窄 ,不过能更合理的利用资源
AB实验SDK的优化
适用范围更广的就是基础库的优化
比如在实际的业务服务中,为了评估某些功能上线后的效果,经常需要进行AB实验,看看不同策略对核心指标的影响,所以公司内部多数服务都会使用AB实验的SDK,如果能优化AB组件库的性能,所有用到的服务都会有性能提升
类似业务服务的优化流程,也会先统计下各个服务中AB组件的资源占用情况,看看AB组件的哪些逻辑更耗费资源,提取公共问题进行重点优化
图中看到有部分性能耗费在序列化上,因为AB相关的数据量较大,所以在制定优化方案时会考虑优化数据序列化协议,同时进行按需加载,只处理服务需要的数据。完成改造和内部压测验证后,会逐步选择线上服务进行试点放量,发现潜在的正确性和使用上的问题,不断迭代后推广到更多服务
编译器&运行时优化
接下来是适用范围最广的优化,就是针对Go
本身进行的优化,会优化编译器和运行时的内存分配策略,构建更高效的go
发行版本
这样的优化业务服务接入非常简单,只要调整编译配置即可,通用性很强,几乎对所有go的程序都会生效, 比如右图中服务只是换用新的发行版本进行编译,CPU
占用降低8%
性能调优原则
pprof
pprof
工具排查性能问题并了解其基本原理性能调优的流程很长,这里总结下重要的点
pprof
工具,可以通过分析实际的程序熟悉相关功能,理解基本原理,后续能够更好地解决性能问题