高质量编程与性能调优实战|青训营笔记

94 阅读10分钟

高质量编程

简介

编写的代码能够达到正确可靠、简洁清晰的目标可称之为高质量代码,考察的标准有以下几点:

  1. 各种边界条件是否考虑完备
  2. 异常情况处理,稳定性保证
  3. 易读易维护

实际应用场景千变万化,各种语言的特性和语法各不相同但是高质量编程遵循的原则是相通的:

  1. 简单性

    1. 消除“多余的复杂性”,以简单清晰的逻辑编写代码
    2. 不理解的代码无法修复改进
  2. 可读性

    1. 代码是写给人看的,而不是机器
    2. 编写可维护代码的第一步是确保代码可读
  3. 生产力

    1. 团队整体工作效率非常重要

编码规范

代码格式

推荐使用gofmt自动格式化代码,gofmt是Go语言官方提供的工具,能自动格式化Go 语言代码为官方统一风格,常见IDE都支持方便的配置。

在Goland中,可以通过添加一个File Watcher来在文件发生变化的时候调用gofmt进行代码格式化,具体方法是,点击Preferences -> Tools -> File Watchers,点加号添加一个go fmt模版,Goland中预置的go fmt模版使用的是go fmt命令,将其替换为gofmt,然后在参数中增加-l -w -s参数,启用代码简化功能。添加配置后,保存源码时,goland就会执行代码格式化。

goimports也是Go语言官方提供的工具,实际等于gofmt加上依赖包管理,拥有自动增删依赖的包引用、将依赖包按字母序排序并分类。

注释

注释的作用:

  1. 解释代码作用
  2. 解释代码如何做的
  3. 解释代码实现的原因
  4. 解释代码什么情况会出错

好的代码有很多注释,坏代码需要很多注释。

— 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 associated file
// descriptor has mode O_RDONLY.
// If there is an error, it will be of type *PathError.
func Open(name string) (*File, error) {
        return OpenFile(name, O_RDONLY, 0)
}

对于方法实现的具体逻辑,需要解释如何代码如何做的:

// 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, req.Header.Get("Referer")); ref != "" {
        req.Header.Set("Referer", ref)
}

对于一些容易令人疑惑的代码,需要解释代码实现的原因,提供背景或上下文:

func redirectBehavior(reqMethod string, resp *Response, ireq *Request) (redirectMethod string, shouldRedirect, includeBody bool) {
        switch resp.StatusCode {
        case 301, 302, 303:
                redirectMethod = reqMethod
                shouldRedirect = true
                includeBody = false

                // RFC 2616 allowed automatic redirection only with GET and
                // HEAD requests. RFC 7231 lifts this restriction, but we still
                // restrict other methods to GET to maintain compatibility.
                // See Issue 18570.
                if reqMethod != "GET" && reqMethod != "HEAD" {
                        redirectMethod = "GET"
                }
        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
                }
        }
        return redirectMethod, shouldRedirect, includeBody
}

使用者容易出现错误的代码,需要解释代码的限制场景:

// parseTimeZone parses a time zone string and returns its length. Time zones
// are human-generated and unpredictable. We can't do precise error checking.
// On the other hand, for a correct parse 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 a T, it's a time zone.
// If there are 3, it's a time zone.
// 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) {
    // ...
}

包中声明的每个公共的符号:变量、常量、函数以及结构都需要添加注释;任何既不明显也不简短的公共功能必须予以注释;无论长度或复杂程度如何,对库中的任何函数都必须进行注释。

// 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) {
    // ...
}

有一个例外,不需要注释实现接口的方法。

命名规范

变量

i和index 的作用域范围仅限于for循环内部时,index的额外冗长几乎没有增加对于程序的理解。

// Bad
for index := 0; index < len(s); index++ {
    // do something
}

// Good
for i := 0; i < len(s); i++ {
    // do something
}

将deadline替换成t降低了变量名的信息量,t常代指任意时间,deadline指截止时间,有特定的含义。

// Good
func ( c *Client ) send( req *Request,deadline time.Time )

// Bad
func ( c *Client ) send( req *Request, t time.Time )

函数

函数名不携带包名的上下文信息,因为包名和函数名总是成对出现的;函数名尽量简短;当名为foo的包某个函数返回类型Foo时,可以省略类型信息而不导致歧义;当名为foo的包某个函数返回类型T时(T并不是Foo),可以在函数名中加入类型信息。

// Bad
func ServeHTTP(l net.Listener, handler Handler) error
good
// 
func Serve(l net.Listener, handler Handler) error

包名

只由小写字母组成;不包含大写字母和下划线等字符;简短并包含—定的上下文信息,例如schema、task 等;不要与标准库同名,例如不要使用sync或者strings。

不使用常用变量名作为包名,例如使用bufio 而不是buf;使用单数而不是复数,例如使用encoding 而不是encodings;谨慎地使用缩写,例如使用fmt在不破坏上下文的情况下比format更加简短。

控制流程

  1. 避免嵌套,保持正常流程清晰
// Bad
if foo {
    return x
} else {
    return nil
}

// Good
if foo {
    return x
}
return nil
  1. 尽量保持正常代码路径为最小缩进

优先处理错误情况或者特殊情况,尽早返回或继续循环来减少嵌套。

// 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
    }
    return nil // normal case
}

错误和异常处理

  1. 简单错误

简单的错误指的是仅出现一次的错误,且在其他地方不需要捕获该错误;优先使用errors.New来创建匿名变量来直接表示简单错误;如果有格式化的需求,使用fmt.Errorf。

func defaultCheckRedirect(req *Request, via []*Request) error {
        if len(via) >= 10 {
                return errors.New("stopped after 10 redirects")
        }
        return nil
}
  1. 错误的Wrap 和Unwrap

错误的Wrap 实际上是提供了一个error嵌套另一个error的能力,从而生成一个error的跟踪链;在fmt.Errorf中使用%w关键字来将一个错误关联至错误链中。

Go1.13在errors 中新增了三个新API和一个新的format 关键字,分别是errors.Is errors.As、errors.Unwrap以及fmt.Errorf的%w。如果项目运行在小于Go1.13的版本中,导入golang.org/x/xerrors来使用。

list, _, err := cache.GetBytes(c, cache.Subkey(a.actionID, "srcfiles"))
if err != nil {
        return fmt.Errorf("reading srcfiles list: %w", err)
}
  1. 错误判定

判定一个错误是否为特定错误,使用errors.Is;不同于使用==,使用该方法可以判定错误链上的所有错误是否含有特定的错误。

if errors.Is(err, fs.ErrNotExist) {
        // No proxies, or all proxies failed (with 404, 410, or were allowed
        // to fall back), or we reached an explicit "direct" or "off".
        c.base = c.direct
} 

在错误链上获取特定种类的错误,使用errors.As:

for _, tc := range testCases {
        t.Run(fmt.Sprintf("%T(%v)", tc, tc), func(t *testing.T) {
                defer func() {
                        recover()
                }()
                if errors.As(err, tc) {
                        t.Errorf("As(err, %T(%v)) = true, want false", tc, tc)
                        return
                }
                t.Errorf("As(err, %T(%v)) did not panic", tc, tc)
        })
}

不建议在业务代码中使用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只能在被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)
                        }
                }
        }()
        if f == nil {
                f = notSpace
        }
        s.buf = s.buf[:0]
        tok = s.token(skipSpace, f)
        return
}

如果需要更多的上下文信息,可以recover后在log中记录当前的调用栈:

defer func() {
        if e := recover(); e != nil {
                f = nil
                err = fmt.Errorf("gitfs panic: %v\n%s", e, debug.Stack())
        }
}()

性能优化建议

性能优化的前提是满足正确可靠、简洁清晰等质量因素性能优化是综合评估,有时候时间效率和空间效率可能对立针对Go语言特性,介绍Go相关的性能优化建议。

Benchmark

性能表现需要实际数据衡量,Go语言提供了支持基准性能测试的benchmark工具。

go test -bench=. -benchmem

// 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)
    }
}

运行输出:

goos: windows
goarch: amd64
pkg: demo/demo35
cpu: AMD Ryzen 7 4800H with Radeon Graphics
BenchmarkFib10
BenchmarkFib10-16        4604728               261.3 ns/op             0 B/op
               0 allocs/op
PASS

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)
   }
}

添加基准测试:

func BenchmarkPreAlloc(b *testing.B) {
   for n := 0; n < b.N; n++ {
      PreAlloc(10)
   }
}

func BenchmarkNoPreAlloc(b *testing.B) {
   for n := 0; n < b.N; n++ {
      NoPreAlloc(10)
   }
}

测试结果:

BenchmarkPreAlloc
BenchmarkPreAlloc-16            30648130                34.29 ns/op           80    B/op          1 allocs/op
BenchmarkNoPreAlloc
BenchmarkNoPreAlloc-16           6121230               202.2 ns/op           248    B/op          5 allocs/op

使用slice切片时需要注意内部元素的引用关系,在已有切片基础上创建切片,不会创建新的底层数组:

  1. 原切片较大,代码在原切片基础上新建小切片
  2. 原底层数组在内存中有引用,得不到释放

解决方案: 可使用copy替代re-slice,参考地址

func lastNumsBySlice(origin []int) []int {
        return origin[len(origin)-2:]
}

func lastNumsByCopy(origin []int) []int {
        result := make([]int, 2)
        copy(result, origin[len(origin)-2:])
        return result
}

上述两个函数的作用是一样的,取 origin 切片的最后 2 个元素。

  • 第一个函数直接在原切片基础上进行切片。
  • 第二个函数创建了一个新的切片,将 origin 的最后两个元素拷贝到新切片上,然后返回新切片。

编写性能测试后结果:

$ go test -run=^TestLastChars  -v
=== RUN   TestLastCharsBySlice
--- PASS: TestLastCharsBySlice (0.31s)
    slice_test.go:73: 100.14 MB
=== RUN   TestLastCharsByCopy
--- PASS: TestLastCharsByCopy (0.28s)
    slice_test.go:74: 3.14 MB
PASS
ok      example 0.601s

结果差异非常明显,lastNumsBySlice耗费了 100.14 MB 内存,也就是说,申请的 100 个 1 MB 大小的内存没有被回收。因为切片虽然只使用了最后 2 个元素,但是因为与原来 1M 的切片引用了相同的底层数组,底层数组得不到释放,因此,最终 100 MB 的内存始终得不到释放。而 lastNumsByCopy 仅消耗了 3.14 MB 的内存。这是因为,通过 copy,指向了一个新的底层数组,当 origin 不再被引用后,内存会被垃圾回收(garbage collector, GC)。

map

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
   }
}

编写测试文件:

func BenchmarkNoPreAlloc(b *testing.B) {
   for n := 0; n < b.N; n++ {
      NoPreAlloc(10)
   }
}

func BenchmarkPreAlloc(b *testing.B) {
   for n := 0; n < b.N; n++ {
      PreAlloc(10)
   }
}

测试结果:

goos: windows
goarch: amd64
pkg: demo/demo36
cpu: AMD Ryzen 7 4800H with Radeon Graphics
BenchmarkNoPreAlloc
BenchmarkNoPreAlloc-16           3254276               370.7 ns/op           292
 B/op          1 allocs/op
BenchmarkPreAlloc
BenchmarkPreAlloc-16             4861070               248.9 ns/op           292
 B/op          1 allocs/op
PASS

不断向map中添加元素的操作会触发map的扩容;提前分配好空间可以减少内存拷贝和Rehash的消耗;建议根据实际需求提前预估好需要的空间。

字符串处理

字符串拼接方式对比:

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()
}

编写性能测试:

func BenchmarkPlus(b *testing.B) {
   for n := 0; n < b.N; n++ {
      Plus(1024, "test")
   }
}

func BenchmarkStrBuilder(b *testing.B) {
   for n := 0; n < b.N; n++ {
      StrBuilder(1024, "test")
   }
}

func BenchmarkByteBuffer(b *testing.B) {
   for n := 0; n < b.N; n++ {
      ByteBuffer(1024, "test")
   }
}

测试结果:

goos: windows
goarch: amd64
pkg: demo/demo37
cpu: AMD Ryzen 7 4800H with Radeon Graphics
BenchmarkPlus
BenchmarkPlus-16                    1893            610802 ns/op         2235877
 B/op       1023 allocs/op
BenchmarkStrBuilder
BenchmarkStrBuilder-16            184557              5556 ns/op           12536
 B/op         12 allocs/op
BenchmarkByteBuffer
BenchmarkByteBuffer-16            145191              8077 ns/op           13488
 B/op          8 allocs/op
PASS

使用+拼接性能最差,strings.Builder,bytes.Buffer相近,strings.Buffer更快。字符串在Go语言中是不可变类型,占用内存大小是固定的使用+每次都会重新分配内存,strings.Builder、bytes.Buffer底层都是[]byte数组内存扩容策略,不需要每次拼接重新分配内存,因此性能更高。

bytes.Buffer转化为字符串时重新申请了一块空间,strings.Builder直接将底层的[]byte转换成了字符串类型返回。查看以下对比:

// String returns the contents of the unread portion of the buffer
// as a string. If the Buffer is a nil pointer, it returns "<nil>".
//
// 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:])
}

// String returns the accumulated string.
func (b *Builder) String() string {
   return *(*string)(unsafe.Pointer(&b.buf))
}

测试代码:

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()
}

性能测试:

func BenchmarkPreStrBuilder(b *testing.B) {
   for n := 0; n < b.N; n++ {
      PreStrBuilder(1024, "test")
   }
}

func BenchmarkPreByteBuffer(b *testing.B) {
   for n := 0; n < b.N; n++ {
      PreByteBuffer(1024, "test")
   }
}

测试结果:

goos: windows
goarch: amd64
pkg: demo/demo38
cpu: AMD Ryzen 7 4800H with Radeon Graphics
BenchmarkPreStrBuilder
BenchmarkPreStrBuilder-16         295794              4043 ns/op            4096
 B/op          1 allocs/op
BenchmarkPreByteBuffer
BenchmarkPreByteBuffer-16         172657              6328 ns/op            8192
 B/op          2 allocs/op
PASS

空结构体

空结构体struct实例不占据任何的内存空间可作为各种场景下的占位符使用,可以节省资源,同时空结构体本身具备很强的语义,即这里不需要任何值,仅作为占位符。

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
    }
}

性能测试:

func BenchmarkEmptyStructMap(b *testing.B) {
   for n := 0; n < b.N; n++ {
      EmptyStructMap(1024)
   }
}

func BenchmarkBoolMap(b *testing.B) {
   for n := 0; n < b.N; n++ {
      BoolMap(1024)
   }
}

测试结果:

goos: windows
goarch: amd64
pkg: demo/demo39
cpu: AMD Ryzen 7 4800H with Radeon Graphics
BenchmarkEmptyStructMap
BenchmarkEmptyStructMap-16         17932             66572 ns/op           47749    B/op         65 allocs/op
BenchmarkBoolMap
BenchmarkBoolMap-16                16744             70239 ns/op           53322    B/op         73 allocs/op
PASS

实现 Set,可以考虑用map来代替;对于这个场景,只需要用到map的键,而不需要值;即使是将map的值设置为bool类型,也会多占据1个字节空间。

一个开源实现: set

atomic

使用atomic与mutex两种方式计数对比:

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()
}

性能测试:

func BenchmarkAtomicAddOne(b *testing.B) {
   for n := 0; n < b.N; n++ {
      AtomicAddOne(&atomicCounter{})
   }
}

func BenchmarkMutexAdd0ne(b *testing.B) {
   for n := 0; n < b.N; n++ {
      MutexAdd0ne(&mutexCounter{})
   }
}

测试结果:

goos: windows
goarch: amd64
pkg: demo/demo40
cpu: AMD Ryzen 7 4800H with Radeon Graphics
BenchmarkAtomicAddOne
BenchmarkAtomicAddOne-16        120816380                9.601 ns/op           4    B/op          1 allocs/op
BenchmarkMutexAdd0ne
BenchmarkMutexAdd0ne-16         53917075                21.45 ns/op           16    B/op          1 allocs/op
PASS

锁的实现是通过操作系统来实现,属于系统调用;atomic操作是通过硬件实现,效率比锁高;sync.Mutex应该用来保护—段逻辑,不仅仅用于保护─个变量对于非数值操作,可以使用atomic.Value,能承载一个interface{}。

性能调优实战

简介

性能调优原则:

  1. 要依靠数据不是猜测
  2. 要定位最大瓶颈而不是细枝末节
  3. 不要过早优化
  4. 不要过度优化

性能分析工具pprof

功能简介

pprof是用于可视化和分析性能分析数据的工具,可以知道应用在什么地方耗费了多少CPU、Memory。

pprof实战

pprof的功能如下图:

image.png

排查实战

下载测试代码,运行,访问http://localhost:6060/debug/pprof/可以查看各种指标:

allocs:

cup:

执行go tool pprof "http://1ocalhost:6060/debug/pprof/profile? seconds=10"命令:

Fetching profile over HTTP from http://localhost:6060/debug/pprof/profile?seconds=10
Saved profile in C:\Users\WolfMan\pprof\pprof.samples.cpu.001.pb.gz
Type: cpu
Time: Jun 8, 2023 at 8:20pm (CST)
Duration: 10.17s, Total samples = 3.23s (31.76%)
Entering interactive mode (type "help" for commands, "o" for options)

然后执行top命令:

Dropped 37 nodes (cum <= 0.02s)
Showing top 10 nodes out of 16
      flat  flat%   sum%        cum   cum%
     3.06s 94.74% 94.74%      3.07s 95.05%  github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Eat
     0.10s  3.10% 97.83%      0.10s  3.10%  runtime.stdcall3
     0.01s  0.31% 98.14%      0.13s  4.02%  runtime.systemstack
         0     0% 98.14%      3.07s 95.05%  github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Live
         0     0% 98.14%      3.08s 95.36%  main.main
         0     0% 98.14%      0.10s  3.10%  runtime.(*pageAlloc).scavenge.func1
         0     0% 98.14%      0.10s  3.10%  runtime.(*pageAlloc).scavengeOne
         0     0% 98.14%      0.10s  3.10%  runtime.(*pageAlloc).scavengeOneFast
         0     0% 98.14%      0.10s  3.10%  runtime.(*pageAlloc).scavengeRangeLocked
         0     0% 98.14%      0.02s  0.62%  runtime.callers

参数含义如下:

  • flat:当前函数本身的执行耗时
  • flat%:flat占CPU总时间的比例
  • sum%:上面每一行的flat%总和
  • cum:指当前函数本身加上其调用函数的总耗时
  • cum%:cum占CPU总时间的比例

当Flat == Cum,函数中没有调用其他函数,例如第5行;当Flat == 0,函数中只有其他函数的调用,例如第6行以后的函数。

命令list可以根据指定的正则表达式查找代码行:

Total: 3.23s
ROUTINE ======================== github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Eat in F:\go\我的项目\青训营\go-pprof-practice
-master\go-pprof-practice-master\animal\felidae\tiger\tiger.go
     3.06s      3.07s (flat, cum) 95.05% of Total
         .          .     19:}
         .          .     20:
         .          .     21:func (t *Tiger) Eat() {
         .          .     22:   log.Println(t.Name(), "eat")
         .          .     23:   loop := 10000000000
     3.06s      3.07s     24:   for i := 0; i < loop; i++ {
         .          .     25:           // do nothing
         .          .     26:   }
         .          .     27:}
         .          .     28:
         .          .     29:func (t *Tiger) Drink() {

输入web命令可以查看调用关系可视化图:

如果报错:Failed to execute dot. Is Graphviz installed? Error: exec: "dot": executable file not found in %PATH%

Heap:

访问http://localhost:8080/ui/查看堆内存:

点击view按钮,可以查看各种指标:

top图:

source图:

SAMPLE按钮可以查看各种内存信息:

指标含义:

  1. alloc_objects:程序累计申请的对象数
  2. alloc_space:程序累计申请的内存大小
  3. inuse_objects:程序当前持有的对象数
  4. inuse_space:程序当前占用的内存大小

根据内存图可以看出,Steal()方法占用了很大的内存:

运行go tool pprof -http=:8080 "``http://localhost:6060/debug/pprof/goroutine``"查看协程信息。

由上到下表示调用顺序,每一块代表一个函数,越长代表占用CPU的时间更长。点击VIEW中的Flame Graph可以查看火焰图,火焰图是动态的,支持点击块进行分析。

可以查看占用时长最多的协程:

我们可以在Source中找到此段代码,发现此协程睡眠了30秒。

mutex:

运行go tool pprof -http=:8080 "``http://localhost:6060/debug/pprof/mutex``"命令查看锁分析:

block:

运行go tool pprof -http=:8080 "``http://localhost:6060/debug/pprof/block``"命令:

根据引用图,我们可以在源码中找到对应的代码:

采样过程和原理

CPU:

采样对象:函数调用和它们占用的时间。

采样率:100次/秒,固定值。

采样时间:从手动启动到手动结束。

image.png

操作系统:每10ms向进程发送一次SIGPROF信号。

进程:每次接收到SIGPROF会记录调用堆栈。

写缓冲:每100ms读取已经记录的调用栈并写入输出流。

image.png

Heap

  1. 采样程序通过内存分配器在堆上分配和释放的内存,记录分配/释放的大小和数量。
  2. 采样率:每分配512KB记录一次,可在运行开头修改,1为每次分配均记录。
  3. 采样时间:从程序运行开始到采样时。
  4. 采样指标:alloc_space、alloc_objects、inuse_space、inuse_objects。
  5. 计算方式:inuse = alloc - free。

Goroutine-协程& ThreadCreate-线程创建

Goroutine:记录所有用户发起且在运行中的goroutine(即入口非runtime开头的)runtime.main的调用栈信息。

image.png

ThreadCreate:记录程序创建的所有系统线程的信息。

image.png

Block-阻塞& Mutex-锁

  • 阻塞操作:
    • 采样阻塞操作的次数和耗时。
    • 采样率:阻塞耗时超过阈值的才会被记录,1为每次阻塞均记录。

image.png

  • 锁竞争:
    • 采样争抢锁的次数和耗时
    • 采样率:只记录固定比例的锁操作,1为每次加锁均记录

image.png

性能调优案例

介绍实际业务服务性能优化的案例,对逻辑相对复杂的程序如何进行性能调优。

业务服务优化

优化流程:

  1. 建立服务性能评估手段
  2. 分析性能数据,定位性能瓶颈
  3. 重点优化项改造
  4. 优化效果验证

服务性能评估方式

由于单独benchmark无法满足复杂逻辑分析,并且不同负载情况下性能表现差异,因此需要不同的方式进行性能评估:

  1. 请求流量构造:

    1. 不同请求参数覆盖逻辑不同
    2. 线上真实流量情况
  2. 压测范围:

    1. 单机器压测
    2. 集群压测
  3. 性能数据采集:

    1. 单机性能数据
    2. 集群性能数据

分析性能数据,定位性能瓶颈

  1. 使用库不规范可能导致cpu占用过高或内存消耗过大
  2. 高并发场景优化不足,在低并发时性能足够,当高并发时性能不足

重点优化项改造

  1. 正确性是基础

  2. 响应数据diff

    1. 线上请求数据录制回放
    2. 新旧逻辑接口数据diff

优化效果验证

  1. 重复压测

  2. 验证上线评估优化效果

    1. 关注服务监控
    2. 逐步放量
    3. 收集性能数据

进一步优化,服务整体链路分析

  1. 规范上游服务调用接口,明确场景需求
  2. 分析链路,通过业务流程优化提升服务性能

基础库优化

AB实验SDK的优化:

  1. 分析基础库核心逻辑和性能瓶颈

    1. 设计完善改造方案
    2. 数据按需获取
    3. 数据序列化协议优化
  2. 内部压测验证

  3. 推广业务服务落地验证

Go语言优化

编译器&运行时优化:

  1. 优化内存分配策略
  2. 优化代码编译流程,生成更高效的程序
  3. 内部压测验证
  4. 推广业务服务落地验证

以上优化方式的优点是接入简单,只需要调整编译配置,且通用性强。