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

117 阅读9分钟

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

课程1 juejin.cn/course/byte…

课程2 juejin.cn/course/byte…

讲师: 张雷

blog.wolfogre.com/posts/go-pp… golang pprof 实战

如何编写更简洁清晰的代码
常用Go语言程序优化手段
熟悉Go程序性能分析工具
了解工程中性能优化的原则和流程

1 高质量编程

高质量编程简介

高质量: 正确可靠, 简洁清晰
各种边界条件是否考虑完备
异常情况处理, 稳定性保证
易读易维护

编程原则
简单性
消除多余的复杂性, 以简单清晰的逻辑编写代码
不理解的代码无法修复改进
可读性
编写可维护代码的第一步是保证代码可读
生产力
对团队整体工作效率至关重要

编码规范 ⭐

想写好代码, 要先会鉴赏代码⭐

熟悉规范, 提高代码审美

代码格式

 使用gofmt自动格式化代码
 goimports也是官方工具, 等于gofmt加上依赖包管理, 自动增删依赖的包引用, 将依赖包按字母序排序并分类

注释

注释应该做的:
注释应该解释代码作用 (适合注释公共符号)
注释应该解释代码如何做的 (适合注释实现过程)
注释应该解释代码实现的原因 (适合注释代码的外部因素, 提供额外的上下文)
注释应该解释代码什么情况会出错 (适合注释解释代码的限制条件)
(注释要提供额外信息, 不能提供额外信息的注释不要写)

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

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

命名规范

variable
简洁胜于冗长
缩略词全大写, 但当其位于变量开头且不需要导出时, 使用全小写
例如使用 ServeHTTP 而不是 ServeHttp
使用 XMLHTTPRequest 而不是 xmlHTTPRequest
变量距离其被使用的地方越远, 则需要携带越多的上下文信息
全局变量在其名字中需要更多的上下文信息, 使得在不同地方可以轻易辨认出其含义

 例1
 // Bad
 for index := 0; index < len(s); index++ {
     // do something
 }
 ​
 // Good
 for i := 0; i < len(s); i++ {
     // do something
 }
 i和index作用域范围仅限于for内部, index的额外冗长没有增加对程序的理解
     
 例2
 // Good
 func (c *Client) send(req *Request, deadline time.Time)
 // Bad
 func (c *Client) send(req *Request, t time.Time)
 将deadline替换成t降低了变量名的信息量, 不利于调用者理解
     
 例3
 time包中
 func Now() Time, 因为返回的是Time, 所以不用NowTime
 func ParseDuration(s string) (Duration, error) 因为返回的不是Time而是Duration, 所以要在函数名中体现出来, 叫ParseDuration

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

package
只由小写字母组成, 不包含大写字母和下划线等字符
简短并包含一定的上下文信息 (例如: schema, task等)
不要与标准库同名 (例如: 不要使用sync或者strings) 以下规则尽量满足, 以标准库包名为例
不使用常用变量名作为包名 (例如: 使用bufio而不是buf)
使用单数而不是复数 (例如: 使用encoding而不是encodings)
谨慎地使用缩写 (例如使用fmt在不破坏上下文的情况下比format更加简短)

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

控制流程

避免嵌套, 保持正常流程清晰

// Bad
if foo {
    return x
} else {
    return nil
}

// Good
if foo {
    return x
}
return nil
如果两个分支中都含return语句, 则可以去除冗余的else
尽量保持正常代码路径为最小缩进
    优先处理错误情况/特殊情况, 尽早返回或继续循环来减少嵌套⭐⭐⭐

// Bad
func OneFunc() error {
    err := doSomething()
    if err == nil {
        err := doAnotherThing()
        if err == nil {
            return nil // normal case
        }
        return err
    }
    return err
}
最常见的正常流程(return nil)的路径被嵌套在两个if条件内
若后续还有操作, 则可能会进一步增加嵌套
return err的触发原因也需要追溯到左括号

// Good
func OneFunc() error {
    if err := doSomething(); err != nil {
        return err
    }
    if err := doAnotherThing(); err != nil {
        return err
    }
    return nil // normal case
}
小结
    线性原理, 处理逻辑尽量走直线, 避免复杂的嵌套分支
    正常流程代码沿着屏幕向下移动 (而不是向右)
    提升代码可维护性和可读性
    故障问题大多出现在复杂的条件语句和循环语句中

错误和异常处理

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

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

错误判定
errors.Is
判定一个错误是否为特定错误, 使用errors.Is (如例1)
不同于使用==, 使用该方法可以判定错误链上的所有错误是否含有特定的错误
errors.As
在错误链上获取特定种类的错误, 使用errors.As (如例2) (方便分析定位问题)
panic
不建议在业务代码中使用panic
调用函数不包含recover会造成程序崩溃
若问题可以被屏蔽或解决, 建议使用error代替panic
当程序启动阶段发生不可逆转的错误时, 可以在init或main函数中使用panic (如例3)
recover
recover只能在被defer的函数中使用
嵌套无法生效
只在当前goroutine生效
defer的语句是后进先出
(平时开发可能会用到别人的库, 别人的库可能会抛panic, 这是就可以用recover处理)
如果需要更多的上下文信息, 可以recover后在log中记录当前的调用栈, 方便定位和分析问题 (如例4)

1
data, err = lockedfile.Read(targ)
if errors.Is(err, fs.ErrNotExist) {
    return []byte{}, nil
}
return data, err

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

例3
func main() {
    ctx, cancel := context.withCancel(context.Background())
    client, err := sarame.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)
}

例4
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的函数中生效

性能优化建议

性能优化
前提: 代码正确可靠, 简洁清晰
特点: 是综合评估, 时间效率和空间效率可能对立
(这里针对Go语言特性, 介绍Go相关的性能优化建议)

Benchmark

go test -bench=. -benchmen\

结果示例:
BenchmarkFib10-8 1855870 602.5 ns/op 0 B/op 0 allocs/op
-8表示GOMAXPROCS的值, 1.5版本后默认为CPU核数 (根据我的实验应该和逻辑处理器数相同, 我的是16)
表示一共执行1855870次, 即b.N的值
平均每次执行花费 602.5ns
平均每次执行申请 0 B的内存
平均每次执行申请 0 次内存

slice

预分配内存
	尽可能在使用make()初始化时提供容量信息
data := make([]int, 0)
data := make([]int, 0, size) // 性能较上面有较大提升

原理:
type slice struct {
    array unsafe.Pointer
    len int
    cap int
}

切片本质是一个数据片段的描述
包括数组指针
片段的长度
片段的容量 (不改变内存分配情况下的最大长度)
切片操作并不复制切片指向的元素
创建一个新的切片会复用原来切片的底层数组

 避免slice导致大内存未释放
因为已有切片基础上创建切片, 不会创建新的底层数组
场景:
原切片较大, 代码在原切片基础上新建小切片
原底层数组在内存中有引用, 得不到释放
解决:
可使用copy替代re-slice

 例:
 // re-slice, 会导致底层数组得不到释放
 func GetLastBySlice(origin []int) []int {
     return origin[len(origin)-2:]
 }
 ​
 // copy
 func GetLastByCopy(origin []int) []int {
     result := make([]int, 2)
     copy(result, origin[len(origin)-2:])
     return result
 }
 ​
 func tesGetLast(t testing.T, f func([]int) []int) {
     result := make([][]int, 0)
     for k := 0; k < 100; k++ {
         origin := generateWithCap(128 * 1024)
         result = append(result, f(origin))
     }
     printMem(t)
     _ = result
 }

map

 map预分配内存, 同slice预分配内存
data := make(map[int]int, size)
利于减少扩容导致内存拷贝和Rehash的消耗

字符串处理

使用strings.Builder, 而非使用+来拼接字符串
原理:
字符串在Go语言中是不可变类型, 占用内存大小固定
每次+会重新分配内存
而strings.Builder或bytes.Buffer底层都是[]byte数组, 有内存扩容策略, 不需要每次重新分配内存   实际strings.Builder比bytes.Buffer稍快

 预分配
 strings.Builder和bytes.Buffer也可以用预分配
     builder.Grow(len)
     buf.Grow(len)

空结构体

空结构体struct{}实例不占据任何内存空间
可作为各种场景下的占位符使用
优点:
节省资源
空结构体本身具备很强的语义, 即这里不需要任何值, 仅作为占位符
​  可以用来实现Set, 如map[int]struct{}
对于这个场景, 只需要用到map的键, 而不需要值
map[int]struct{}占用内存比map[int]bool还少

atomic包

 锁的实现是通过操作系统实现, 属于系统调用
 atomic操作是通过硬件实现, 效率比锁高
 sync.Mutex应该用来保护一段逻辑, 如果只是保护一个变量的话可以考虑使用atomic

小结

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