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

49 阅读8分钟

高质量编程与性能调优实战

高质量编程与性能调优实战

介绍编码规范,帮助大家写出高质量程序 介绍 Go 语言的性能优化建议,分析对比不同方式对性能的影响和背后的原理 讲解常用性能分析工具 pprof 的使用和工作原理,熟悉排查程序性能问题的基本流程 分析性能调优实际案例,介绍实际性能调优时的工作内容

1 高质量编程

高质量编程简介 编码规范 性能优化建议

1.1 简介 什么是高质量 编写的代码能够达到正确可靠、简洁清晰的目标。

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

编程原则 简单性、可读性、生产力 1.2 编码规范 1.2.1 代码格式 gofmt Go语言官方提供的工具,能自动格式化Go语言代码为官方统一风格。 goimports 实际等于gofmt加上依赖包管理,自动增删依赖的包的引用、将依赖包按字母序排序并分类。 1.2.2 注释 注释应该做的

解释代码作用

适合注释公共符号

解释代码如何做的

适合注释实现过程

解释代码实现的原因

适合解释代码的外部因素 提供额外上下文

解释代码什么情况会出错

适合解释代码的限制条件

公共符号始终要注释

包中声明的每个公共的符号:变量、常量、函数以及结构都需要添加注释 任何几部明显也不简短的公共功能必须予以注释 无论长度或者复杂程度如何,对库中的任何函数都要进行注释 有一个例外,不需要注释实现接口的方法

小结

代码是最好的注释 注释应该提供代码未表达出的上下文信息

1.2.3 命名规范 变量(variable)命名

简洁胜于冗长

缩略词全大写,但当其位于变量开头且不需要导出时,使用全小写

例如使用 ServeHTTP 而不是 ServeHttp 使用XMLHTTPRequest或者xmlHTTPRequest

变量距离其被使用的地方越远,则需要携带越多的上下文信息

全局变量在其名字中需要更多的上下文信息,使得在不同地方可以轻易辨认出其含义

函数(function)命名

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

go复制代码http包中创建服务的函数如何命名更好?​func Serve(l net.Listener, handler Handler) error// √ func ServeHTTP(l net.Listener, handler Handler) error// ×

包(package)命名

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

以下规则尽量满足,以标准库包名为例

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

小结

核心目标是降低阅读理解代码的成本 重点考虑上下文信息,设计简洁清晰的名称

1.2.4 控制流程 避免嵌套,保持正常的流程清晰 kotlin复制代码// Bad if foo { return x } else { // 冗余的else return nil } // Good if foo { return x } return nil

尽量保持正常代码路径为最小缩进

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

go复制代码// Bad func OneFunc() error {    err := doSomeing()    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.2.5 错误和异常处理 简单错误

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

go复制代码func defaultCheckRedirect(req *Request, via []*Request) error {    if len(via) >= 10 {        return errors.New("stopped after 10 redirects")   }    return nil }

错误的 Wrap 和 Unwrap

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

go复制代码list, _, err := c.GetBytes(cache.Subkey(a.actionID, "srcfiles")) if err != nil {    return fmt.Errorf("reading srcfiles list : %w", err) }

错误判定

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

kotlin复制代码data, err = lockedfile.Read(targ) 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

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

go复制代码if _, err := os.Open("non-existing"); err != nil { var pathError *fs.PathError if errors.As(err, &pathError) { fmt.Println("Failed at path:", pathError.Path) } else { fmt.Println(err) }

panic panic是Go语言中,用于终止程序的一种函数,往往用在下面两种情况:

程序出现了很大的故障,例如不能在提供服务了。 程序在运行阶段碰到了内存异常的操作,例如空指针的取值,改写只读内存等。

不建议在业务代码中使用panic 调用函数不包含 recover 会造成程序崩溃 若问题可以屏蔽或解决,建议使用 error 代替 panic 当程序启动阶段发生不可逆转的错误时,可以在 init 或 main 函数中使用 panic

go复制代码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 是Go提供的一个用来截获 panic 信息,重新获取协程控制的函数。

recover 只能在被 defer 的函数中使用 嵌套无法生效 只在当前 goroutine 生效 defer 的语句是后进先出

go复制代码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 中记录当前的调用栈

go复制代码func (t *treeFS) Open(name string) (f fs.File, err error) { defer func() { if e := recover(); e != nil { f = nil err = fmt.Errorf("gitfs panic: %v\n%s", e, debug.Stack()) } }() // ... }

小结

error 尽可能提供简明的上下文信息链,方便定位问题 panic 用于真正异常的情况 recover 生效范围,在当前 goroutine 的被 defer 的函数中生效

1.3.1 Benchmark 如何使用 性能表现需要实际数据衡量 go test -bench=. -benchmem

1.3.2 Slice 预分配内存 尽可能的在使用make初始化切片的时候提供容量信息,如data:=make([]int,0,size) 原理:在创建一个新的切片时实际上会复用原来切片的底层数组。比如append场景,当append之后的长度小于等于容量的时候,会直接利用原底层数组剩余的空间;否则,就分配一块更大的区域来容纳新的底层数组。 这样会导致的另一个问题就是大内存未释放。 在已有切片基础上创建切片,不会创建新的底层数组 比如在原切片较大时,如果代码在原切片基础上新建小切片,原底层数组在内存里有引用,无法释放。这时候应该用copy来替代直接引用。 1.3.3. map

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

1.3.4 字符串处理

常见的字符串拼接方法 strings.Builder bytes.Buffer

strings.Builder>bytes.Buffer >+ 原理 字符串在 Go 语言中是不可变类型,占用内存大小是固定的 使用+的时候每次都会重新分配内存 strings.Builder 和 bytes.Buffer 底层都是 []byte 数组 内存扩容策略,不需要每次拼接重新分配内存 bytes.Buffer 转化为字符串时重新申请了一块空间 strings.Builder 直接将底层的 []byte 转换成了字符串类型返回 其他优化 可以通过Grow来实现内存的预分配,提高效率 1.3.5 使用空结构体节省内存 空结构体struct{}实例不占据任何内存空间,可以作为任何场景下的占位符使用,有利于节省资源。 比如实现简单的set,就可以用map来替代 1.3.7 使用atomic包 多线程开发的时候,可以使用sync.Mutex加锁的方式,也可以用atomic.AddInt32方法。后者的效率更高。 原理 锁的实现是通过操作系统来实现,属于系统调用;而atomic是通过硬件实现,效率高。 使用场景 sync.Mutex应该用于保护一段逻辑 非数值操作可以使用atomic.Value

小结

避免常见的性能陷阱可以保证大部分程序的性能 针对普通应用代码,不要一味地追求程序的性能 越高级的性能优化手段越容易出现问题 在满足正确可靠、简洁清晰等质量要求的前提下提高程序性能