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

44 阅读11分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 5 天

高质量编程

高质量代码:编写的代码能够达到正确可好、简洁清晰的目标可称之为高质量代码。

  • 各种编写条件是否考虑完备
  • 异常情况处理、稳定性保证
  • 易读易维护

编程原则:简单性、可读性、生产力

简单性:消除多余的复杂性,简单的逻辑代码有利于理解,在项目代码接手的过程中会省去很多麻烦;另一方面简单的代码有利于代码的进一步优化

可读性:代码需要给人来阅读,好的代码应该是简单可理解可读的

生产力:项目是一个团队工作,如果能够有统一的代码规范,对于提高整体的工作效率十分有效

编码规范

高质量代码的规范,从代码格式、注释、命名规范、控制流程、错误和异常处理,这5个方面来描述规范。

代码格式

  1. 使用 gofmt 自动格式化工具来格式化代码
  2. 使用 goimport 来完成对依赖包管理,例如,对 go.mod 文件中依赖的排序等等

注释

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

注释的需要做的事情:注释应该提供未表达出来的上下文

  • 作用:解释代码作用
  • 实现:解释代码是如何做的
  • 原因:解释代码实现的原因
  • 错误:解释代码什么时候出错
  1. 公共符号始终需要注释

    • 包中声明的每个公共符号:变量、常量、函数以及结构体都需要注释

    • 任何既不明显也不简单的公共功能需要注释

    • 对于库中的任何函数都必须进行注释

    • 不要注释实现接口的方法,因为接口已经很好的描述函数的行为了

  2. 注释应该解释代码的作用

    注释清楚代码做了些什么事情,如果返回什么样的值代表什么样的状态等等

    // WriteString writes the contents of the string s to w, which accepts a slice of bytes.
    // If w implements StringWriter, its WriteString method is invoked directly.
    // Otherwise, w.Write is called exactly once.
    func WriteString(w Writer, s string) (n int, err error) {
    	if sw, ok := w.(StringWriter); ok {
    		return sw.WriteString(s)
    	}
    	return w.Write([]byte(s))
    }
    
  3. 注释应该解释代码是如何做的

    代码的码中复杂的,难以理解的代码段需要解释代码做了什么事情

    // If the reader has a WriteTo method, use it to do the copy.
    // Avoids an allocation and a copy.
    if wt, ok := src.(WriterTo); ok {
    	return wt.WriteTo(dst)
    }
    // Similarly, if the writer has a ReadFrom method, use it to do the copy.
    if rt, ok := dst.(ReaderFrom); ok {
    	return rt.ReadFrom(src)
    }
    
  4. 解释代码实现的原因

    代码中某个行为,例如 shouldRedirect = fasle 但就一个条件和一个语句没办法判断代码

  5. 解释代码什么时候会出错

    解释代码的一些限制条件,解释代码会出错的情况。例如下面这个函数就详细解释了袭击的一些行为。在函数使用的过程中没必要去研究这个函数的实现,提高工作效率。

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

命名规范

好的命名就像一个好的笑话。 如果你必须解释他,那么他就不好笑了。

好的命名有助于降低代码的理解成本。命名的时候需要重点考虑,命名的上下文信息和简洁性。

  1. 变量(variable)

    • 简洁胜于冗长

    • 缩略词全部大写,但是当其位于变量开头且不需要导出时,那么全部小写

      使用 ServerHTTP 而不是 ServerHttp
      使用 XMLHTTPRequest 或者 xmlHTTPRequest
      
    • 变量距离其被使用的地方越远就需要携带越多的上下文信息

      例如,全局变量在名称中就需要更多上下文信息,使得在不同的地方可以轻易辨认出其含义。直观来说,越是全局变量,变量的名称也就越长

  2. 函数(function)

    • 函数名不懈怠上下文信息,函数总是和包名成对出现
    • 函数名尽可能简短
    package http
    func Serve(I net.Listener, handler Handler) error
    func ServeHTTP(I net.Listener, handler Handler) error
    

    对于上面两种命名显然下面的命名是比较好的,包名本身携带了上下文信息,所以没必要将上下文信息保留在函数名中。

  3. 包(package)

    • 只由小写字母组成,不包含大写字母和下划线等特殊字符
    • 简短并包含一定的上下文信息,例如 http, task, time 等等
    • 不要和标准库同名

    下面是一些约定俗成的约定:

    • 不使用常用变量名作为包名
    • 使用单数名词,不要使用复数
    • 谨慎使用缩写,使用大家约定的缩写,例如 encoding 不是 encodings

控制流程

控制流程控制原则:

  • 线性原理,逻辑尽量走直线,避免复杂的嵌套
  • 正常流程沿着屏幕向下移动
  • 提升代码可读性和可维护性
  • 故障问题大多数出现在复杂的条件语句和循环语句中

两个案例:

  1. 减少代码的嵌套情况,确保代码清晰

    可以使用 return 语句的特性减少代码的嵌套情况

    // Bad
    if (err == nil) {
        return a
    } else {
        return b
    }
    // Good
    if (err == nil) {
        return a
    }
    return b
    
  2. 保持正常代码路径为最小缩进

    对于错误的判断往往需要优先处理,尽早返回或者继续循环来减少嵌套

    // 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()
    errors.New("simple error")
    fmt.Errorf("ERROR CODE %d", code)
    
  2. 错误的 Wrap 和 Unwrap

    • 错误的包装,实际上提供了一个 error 嵌套另一个 error 的能力,从而生成一个 error 的追踪链

    • fmt.Errorf 中使用 %w 符号来将一个错误关联至另一个错误链中

    err := doSomething()
    fmt.Errorf("another error: %w", err)
    

    在 Go 1.13 中,在 errors 中新增了三个新的 API 和一个新的 format 关键字,分别是 errors.Is errors.As errors.Unwrapfmt.Errorf 中的 %w

  3. 错误判定

    判断一个错误是否为特定种类的错误:errors.Is()

    在错误链上获取特定种类的错误:errors.As()

  4. panicrecover

    • 在业务代码中不建议使用 panic

    • 调用函数发生了 panic 会造成程序的崩溃,如果问题可以屏蔽或者解决,建议使用error 代替 panic

    • panic 一般出现在程序启动阶段发生不可逆的错误时,可以在 init 或者 main 函数中使用 panic

    • recover 只能在被 defer 的函数中使用,嵌套无法生效,recover 只在当前的 goroutine 生效

    • 如果需要跟多的上下文信息,可以在 recover() 后在 log 中进行记录当前的调用栈。

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

    Note: defer 语句根据定义的顺序,按照后进先出的顺序执行

注意事项

  • error 尽可能提供键帽的上下文信息链,方便定位问题
  • panic 用于真正异常的情况。这种异常一般导致程序无法正常启动
  • recover 生效范围:在当前 goroutine 的被 defer 的函数中生效

性能优化

性能优化的前提是满足正确可靠,简洁清晰;性能优化是综合上的评估,有时候时间效率和空间效率可能是对立的

性能优化建议

对于性能上的测试,可以使用 Benchmark 来完成,在 Go 语言基础的学习中已经提到。

goos: windows
goarch: amd64
pkg: github.com/wangkechun/go-by-example/fib
cpu: Intel(R) Core(TM) i5-10210U CPU @ 1.60GHz
BenchmarkFib10-8   	 4762810	       248.0 ns/op	       0 B/op	       0 allocs/op
测试函数和GOMAXPROC值    b.N             每次运行的时间   每次执行分配的内存数量  每次执行分配内存次数

GOMAXPROC:在1.5版本后,默认值为 CPU 的核心数

Slice

  1. 尽可能在使用 make() 初始化切片的时候提供容量信息

    data := make([]int, 0, size)
    for k := 0; k < size; k++ {
        data = append(data, k)
    }
    

    切片

    • 切片的本质是一个数组片段的描述
      • 数组指针:array
      • 片段长度:len
      • 片段容量:cap
    • 切片操作不会复制切片指向的元素
    • 创建一个新的切片会复用原来切片的底层数组
  2. 大内存未释放

    根据切片的性质,创建新的切片会复用原来切片的底层数组,这样就导致大数组无法被及时释放出去;因此对于切片来说,可以使用 copy() 来替代 re-slice 创建切片。

    result := make([]int, 2)
    // 直接复制内容创建切片
    copy(result, origin[len(origin)-2:])
    return result
    

Map 预分配内存

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

字符串处理

  1. 使用 string.Builder 进行字符串的拼接

    func StrBuilder(n int, str string) string {
        var builder strings.Builder
        for i := 0; i < n; i++ {
            builder.WriteString(str)
        }
        return builder.String
    }
    

    使用 + 进行拼接的性能最差,strings.Builderbytes.Buffer 相近,strings.Builder 更快

    • 字符串在 Go 语言中是不可变类型,占用内存大小是固定的
    • 使用 + 每次都会重新分配内存
    • strings.Builderbytes.Buffer 底层都是 []byte
    • 内存扩容策略,不需要每次拼接重新分配内存
  2. strings.Builder 的内存预分配

    var builder strings.Builder
    builder.Grow(n * len(str))
    

空结构体

使用空结构体可以节省内存。空结构体 string{} 实例不占据任何内存空间,可以在下面的几个场景作为占位符使用:

  • 节省资源
  • 空结构体本身具备很强的语义,也就是这里不需要任何的值,仅仅为占位符
// 这两种请路况中:bool 作为键会使用更多的内存空间
// bool 值即使占用空间小也需要 1 字节来存储
m := make(map[int]struct{})
m := make(map[int]bool)

实现 Set,可以考虑从使用map来代替,对于map的值可以使用空结构体减少内存占用。

atomic

type atomicCounter struct {
    i int32
}
func AtomicIncrement(c *atomicCounter) {
    atomic.AddInt32(&c.i, 1)
}

锁的实现通过操作系统调用来实现,属于系统调用,性能较低。atomic 包通过硬件实现,效率比锁要高。

sync.Mutex 应该保护的是临界区的代码,而不是去保护一个变量

对于非数值操作,可以使用 atomic.Value 这个可以承载一个 interface{}

注意事项

  1. 避免出现常见的性能陷阱就可以保证大部分程序的性能
  2. 普通的代码没必要一味追求程序性能
  3. 越高级的性能优化手段越容易出现问题
  4. 在满足正确可靠,简洁清晰的质量要求下提高程序的性能

性能分析工具

pprof 性能分析工具:golang pprof 实战

打开 pprof 排查工具

go tool pprof http://localhost:6060/debug/pprof/profile?seconds=10

CPU

  • top:查看当前CPU占用情况

    列名含义
    flat当前函数本身的时间耗时
    flat%flat 占用 CPU时间的比例
    sum%上面一行的 flat% 总和
    cum当前函数本身加上其调用函数的总耗时
    cum%cum 占 CPU 总时间比例

    flat == cum :当前函数未调用其他函数

    flat == 0 :当前函数只调用了其他函数,自身没有占用时间

  • list:查找具体的代码行

  • web:展示当前调用链的调用情况

    PPROF 调用链情况

内存

使用命令打开UI

# 堆内存情况
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
# 内存分配情况
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/allocs

Pprof 内存分析

Goroutine

使用命令打开UI

go tool pprof -http=:8080 http://localhost:6060/debug/pprof/goroutine

Mutex 锁情况

使用命令打开UI

go tool pprof -http=:8080 http://localhost:6060/debug/pprof/mutex