这是我参与「第五届青训营」伴学笔记创作活动的第3天
本节课重点
本节课主要讲了 Golang 性能优化,以及编码规范。
详细知识点
- 如何编写更简洁清晰的代码
- 常用 Go 语言程序优化手段
- 熟悉 Go 程序性能分析工具
- 了解工程中性能优化的原则和流程
实践例子
高质量编程
编写的代码能够达到正确可靠,简洁清晰的目标可称为高质量代码。
- 各种边界条件是否完备
- 异常情况处理,稳定性保证
- 易读易维护
编程原则
-
简单性
- 消除多余的复杂性,以简单的逻辑编写代码
-
可读性
- 便携可维护代码的第一步是确保代码可读性
-
生产力
- 团队整体工作效率很重要
编码规范
注释
- 公共符号、函数始终要注释
包中声明的每一个公共符号、变量、常量、函数以及结构体都需要注释
任何不明显也不简短的公共功能也需要给予注释
无论长度或复杂度如何,对库中的任何函数都必须进行注释
有一例外,不需要注释实现接口的方法
- 注释应该有的作用
应该解释代码作用
应该解释代码如何做的
应该解释代码实现的原因
应该解释代码为什么会出错
小结
- 注释应该提供未表达出来的上下文信息
- 代码是最好的注释
代码格式
gofmt 推荐使用 go fmt 自动格式化代码
go imports 在 go fmt 的基础上加上了依赖包管理,自动增加删除依赖包应用、将依赖包按字母排序并分类
命名规范
变量命名
- 简洁胜于冗长
- 缩略词全部大写,但当位于变量头部并且不需要导出的时候,全部小写,比如使用 ServerHTTP 而不是 ServerHttp
- 变量距离被使用的地方越远,则需要携带越多的上下文信息
函数命名
- 函数名不携带包的上下文信息,因为总是成对出现
- 函数名尽可能简短
- 当名为 foo 的包中的某个函数返回类型 foo 时,可以省略类型信息而不导致歧义
- 当返回类型为 T 时,则需要在函数名中加入类型信息
// Good
func Serve(I net.Listener, handler Handler) error
// bad
func ServeHTTP(I net.Listener, handler Handler) error
包名
-
只能由小写字母组成,不包含下划线
-
简洁,并包含一定的上下文
-
不能与标准库同名
-
建议满足
- 不实用常用变量名,如使用
bufio而不是buf - 使用单数而不是复数
- 谨慎使用缩写。例如
fmt在不破坏上下文的情况下可以替代format
- 不实用常用变量名,如使用
控制流程
- 避免嵌套,保持正常流程清晰
// Bad
if foo {
return x
} else {
return nil
}
// Good
if foo {
return x
}
return nil
-
尽量保持正常代码路径为最小缩进
- 优先处理错误情况/特殊情况,尽早返回
- 尽量避免错误嵌套
// Bad
func OneFunc() error {
err := doSth()
if err == nil {
err := doASth()
if err == nil {
return nil
}
return nil
}
return nil
}
// Good
func OneFunc() error {
if err := doSth(); err != nil {
return err
}
if err := doASth(); err != nil {
return err
}
return nil
}
- 处理逻辑尽量走直线
- 故障问题大多数出现在循环语句以及复杂的判断语句中
错误与异常处理
简单错误
- 简单错误是指只会出现一次的错误,并且在其他地方不需要捕获该错误
- 优先使用 errors.New 来创建匿名变量直接表示简单错误
- 如果有格式化的要求,使用 fmt.Errorf
错误的Wrap和Unwarp
- 错误的 Wrap 实际上是提供一个 error 嵌套另一个 error 的能力,从而能够跟踪错误链
- 在 fmt.Errorf 中使用 %w 将一个错误关联到错误链中
错误判定
- 判定一个错误是不是特定错误,使用 errors.ls() ,不同于使用 ==,使用该方法可以判定错误链上的所有错误是否含有特定错误
- 在错误链上获取特定错误内容使用 errors.As
if _, err := os.Open("no_existing"); err != nil {
var pathError *fs.PathError
if error.As(err, &pathError) {
fmt.Println("Failed at path", pathError.Path)
} else {
fmt.Println(err)
}
}
panic
- 不建议在业务中使用panic
- 调用函数不包含 recovery 会造成程序崩溃
- 若问题可以被屏蔽或者解决,建议使用 error 代替 panic
- 序启动的初始阶段出现不可逆转的错误时,可以在 init 或者 main 函数中使用 panic
recover
- cover只能在被defer的函数中使用
- 嵌套无法生效
- 只能在当前的协程中使用
- defer语句是后进先出
- 如果需要更多的上下文信息,可以在recover后log中记录当前的调用栈
性能优化建议
- 性能表现需要数据说明
- go中提供了支持基准性能测试的
benchmark工具,使用命令go test -bench=. -benchmem
slice预分配内存
尽可能使用 make() 初始化切片的时候提供容量信息
// Bad
func NoPreAlloc(size int) {
data := make([]int, 0)
for k := 0; k <= data; k++ {
data = append(data, k)
}
}
// Good
func NoPreAlloc(size int) {
data := make([]int, 0, size)
for k:= 0; k <= data; k++ {
data = append(data, k)
}
}
切片原理
-
切片本质上是一个数组片段的描述
- 包括数组指针
- 片段的长度
- 片段的容量
-
切片操作并不复制切片指向的元素
-
创建一个新的切片会复用原来切片的底层数组
type slice struct {
array unsafe.pointer
len int
cap int
}
陷阱: 大内存没有释放
在已有切片的基础上创建切片不会创建新的切片数组
原切片较大,代码在原切片的基础上新建小切片,底层数组在内存中有引用,得不到释放,因此我们可以使用 copy 代替 re-slice
func Bad(origin []int) []int {
return origin[len(origin)-2:]
}
// 大幅降低内存占用
func Good(origin []int) []int {
result := make([]int, 2)
copy(result, origin[len(origin)-2:])
return result
}
map预分配内存
- 不断向map中添加元素会触发 map 扩容
- 提前分配好空间可以减少内存拷贝和 Rehash 的消耗
- 建议根据实际情况提前预估好分配的空间
字符串拼接
使用 strings.Builder 或 bytes.Buffer 拼接字符串会减少性能消耗,因为 string 类型是不可变的,每次使用 + 都会重新分配内存
// 性能最高
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()
}
使用空结构体节省内存
空结构体并不会占据任何的内存空间,可以作为占位符使用,并且可以节省资源
在map中使用这个特点,可以用来实现set,因为只需要key不需要value,会更加节省内存
使用 atomic 包
使用atomic包来替换sync.Mutex加锁可以提高性能
type atomicCounter struct {
i int32
}
func AtomicAddOne(c *atomicCounter) {
atomic.AddInt32(&c.i, 1)
}
sync.Mutex 应该用于保护一段逻辑,不仅仅用于保护一个变量
Atomic 操作是通过硬件实现,效率比锁高
总结
编码中我们需要注意
- 要降低阅读理解代码的成本
- 重点考虑上下文信息
- 设计简洁清晰的名称
编码时,要注意性能优化