这篇文章是青训营第三节课高质量编程与性能调优实战的一些过程记录,主要包括编程需要注意的地方,比如各种错误的处理;除此之外,还会详细pprof调优工具的使用,并有相关调优案例。
高质量编程
1. 命名规范
- 缩略词全部大写,但当其位于变量开头且不需要导出时,使用全小写。
- 比如使用ServeHTTP而不是ServeHttp
- 使用XMLHTTPRequest或者xmlHTTPRequest
- 全局变量命名的时候最好多携带一些信息
- 包名全小写
2. 错误处理
简单错误
- 简单的错误指的是仅出现一次的错误,且在其他地方不需要捕获该错误。
- 优先使用
errors.New
来创建匿名变量来直接表示简单错误 - 如果有格式化的需求,使用
fmt.Errorf
var ErrSimple = errors.New("简单的错误: 数据无效")
或者
fmt.Errorf("数据无效: 输入长度不足 (长度: %d)", len(data))
错误的Wrap和Unwrap
- 错误的Wrap实际上是提供了一个error嵌套另一个error的能力,从而生成一个error的跟踪链,比如使用
fmt.Errorf
的%w
将一个错误包裹在另一个错误中,形成错误链。下面代码把读文件函数返回的错误用w%包裹起来了。
err := readFile()
if err != nil {
return fmt.Errorf("处理文件时出错: %w", err)
}
return nil
- 如果我们拿到了fmt这个完整的错误,但是我们只想要w%,直接用unwrap函数就好
originalErr := errors.Unwrap(err)
错误判定
- 判定一个错误是否为特定错误,使用
errors.ls
- 不同于使用 ==,使用该方法可以判定错误链上的所有错误是否含有特定的错误
package main
import (
"errors"
"fmt"
)
// 定义特定错误
var ErrNotFound = errors.New("资源未找到")
// 模拟一个函数,返回一个错误
func getResource(id int) error {
if id == 0 {
return fmt.Errorf("查询资源失败: %w", ErrNotFound) // 使用 %w 包裹特定错误
}
return nil
}
func main() {
// 调用函数并获取错误
err := getResource(0)
// 判断是否包含特定错误
if errors.Is(err, ErrNotFound) {
fmt.Println("错误: 未找到资源") // 输出: 错误: 未找到资源
} else {
fmt.Println("其他错误:", err)
}
}
使用 `errors.New` 定义一个常量错误 `ErrNotFound`,在函数中通过 `fmt.Errorf("...: %w", ErrNotFound)` 包裹基础错误,使用 `errors.Is` 判断错误链中是否包含特定错误 `ErrNotFound`。由此可见,error.Is是可以判断整个错误链上是否包含某个错误的。
* 在错误链上获取特定种类的错误,使用`error.As`函数
```
err := fmt.Errorf("发生错误: %w", &MyError{Message: "这是一个自定义错误"})
// 定义一个变量来存储目标类型错误
var myErr *MyError
// 使用 errors.As 检查并获取特定类型的错误
if errors.As(err, &myErr) {
fmt.Println("捕获到自定义错误:", myErr.Message)
}
```
这段代码就是把err赋值给了myErr
panic
* 不建议在业务代码中使用 panic
* 调用函数不包含 recover 会造成程序崩溃
* 若问题可以被屏蔽或解决,建议使用error 代替 panic
* 当程序启动阶段发生不可逆转的错误时,可以在 init 或 main 函数中使用 panic
recover
- recover 只能在被 defer 的函数中使用
- 嵌套无法生效
- 只在当前 goroutine 生效
- defer 的语句是后进先出
- 如果需要更多的上下文信息,,可以recover后再log中记录当前的调用栈
package main
import "fmt"
func divide(a, b int) {
defer func() {
// 捕获 panic
if r := recover(); r != nil {
fmt.Println("捕获到的 panic:", r)
}
}()
// 如果分母是 0,触发 panic
if b == 0 {
panic("除数不能为零")
}
result := a / b
fmt.Println("结果:", result)
}
func main() {
fmt.Println("程序开始")
// 正常调用
divide(10, 2) // 输出: 结果: 5
// 触发 panic
divide(10, 0) // 输出: 捕获到的 panic: 除数不能为零
// 程序继续执行
fmt.Println("程序结束")
}
这段代码的输出
程序开始
结果: 5
捕获到的 panic: 除数不能为零
程序结束
panic
: 应用于程序不可恢复的错误场景,例如访问无效的内存地址。
recover
: 用于捕获 panic
,防止整个程序崩溃,使程序可以优雅地处理错误。如果不使用 recover
,当调用 panic
时,程序会立即崩溃并终止运行,后续代码将无法执行。
性能优化
1.基准测试-benchmark
Benchmark 是一种性能测试,用于衡量代码在特定操作下的性能表现,主要关注 执行时间 和 资源使用。在 Go 中,基准测试主要用于分析代码的运行效率,例如某个函数执行多快、分配多少内存、内存分配次数等。
benchmark的基本写法
- 函数名以
Benchmark
开头 - 接收一个
*testing.B
类型参数 - 基准测试函数通常被放在
_test.go
文件中 - 函数参数类型必须是*testing.B
以下是一个常见的项目结构
fib/
├── fibseq.go // 普通代码
├── fibseq_test.go // 测试代码(单元测试或基准测试)
└── go.mod // Go 模块配置文件
下面以这个结构举一个例子 文件fibseq.go:
package fib
func Fib(n int) int {
if n < 2 {
return n
}
return Fib(n-1) + Fib(n-2)
}
文件fibseq_test.go:
package fib
import (
"testing"
)
func BenchmarkFib10(b *testing.B) {
//运行b.N次
for n := 0; n < b.N; n++ {
Fib(10)
}
}
注意测试文件命名必须以_test.go结尾,同时测试函数以Benchmark开头,然后我们进入到fib这个目录,用go test -bench=.
或者go test -bench=. -benchmem
运行基准测试,这两个命令的区别是后面这个会包括内存分配的信息。下面看看运行结果
tips:在powershell里面运行的命令是go test -bench=10,这个10是基准测试函数名的后缀
- 这个BenchmarkFib10-20,后面这个-20是表示cpu的核数,前面是测试函数名字
- 第2项表示运行次数,即b.N
- 第三个是每次执行所花费的时间
- 第4项表示每次执行申请的内存
- 最后一项表示每次执行申请了几次内存
2. 性能优化措施
slice预分配
- 尽可能在使用make()初始化切片时提供容量信息
可以提前分配内存分配次数和执行速度都要快一些,因为slice切片在容量不够的时候底层数组会先进行扩容,如果我们一开始给定容量,扩容次数会变少
- copy代替re-slice
- 在已有切片基础上创建切片,不会创建新的底层数组
- 原切片较大,代码在原切片基础上新建小切片
- 原底层数组在内存中有引用,得不到释放
// GetLastBySlice 返回一个切片,包含 origin 切片的最后两个元素。
// 注意:这里直接返回原切片的子切片,并没有创建新的数据。
func GetLastBySlice(origin []int) []int {
// 使用切片语法获取 origin 的最后两个元素
// len(origin)-2: 从倒数第二个元素开始
// len(origin): 到最后一个元素为止
return origin[len(origin)-2:]
}
// GetLastByCopy 返回一个新切片,包含 origin 切片的最后两个元素。
// 注意:这里会创建新的切片,并将数据复制到新切片中。
func GetLastByCopy(origin []int) []int {
// 创建一个长度和容量为 2 的新切片
result := make([]int, 2)
// 使用 copy 函数将 origin 切片的最后两个元素复制到 result 中
// len(origin)-2: 从倒数第二个元素开始复制
// len(origin): 到最后一个元素为止
copy(result, origin[len(origin)-2:])
// 返回新的切片 result
return result
}
map预分配
- 和前面slice一样的道理,不断向 map 中添加元素的操作会触发 map 的扩容
- 提前分配好空间可以减少内存拷贝和 Rehash 的消耗
- 建议根据实际需求提前预估好需要的空间
使用strings.Builder代替直接加
- 使用+拼接性能最差,strings.Builder,bytes.Buffer 相近,strings.Builder 更快
- 字符串在 Go 语言中是不可变类型,占用内存大小是固定的
- 使用 +每次都会重新分配内存
- strings.Builder,bytes.Buffer 底层都是 []byte 数组
- 内存扩容策略,不需要每次拼接重新分配内存
strings.Builder要比bytes.Buffer快一些,原因在于他们的底层实现存在一点区别
bytes.Buffer
的 String
方法:
// String 方法将 bytes.Buffer 的内容转换为字符串。
func (b *Buffer) String() string {
// 如果当前 Buffer 是 nil,则直接返回 "<nil>",这通常用于调试场景。
if b == nil {
// 特殊情况处理:返回 "<nil>" 字符串,方便调试。
return "<nil>"
}
// 将 Buffer 中的有效字节范围(从 b.off 到末尾)转换为字符串并返回。
// b.buf 是底层的字节切片,b.off 是当前的偏移量。
return string(b.buf[b.off:])
}
strings.Builder
的 String
方法:
// String 方法返回 strings.Builder 中累积的字符串。
func (b *Builder) String() string {
// 使用 unsafe 包的 Pointer,直接将 b.buf 转换为字符串。
// 这里避免了额外的分配和拷贝操作,提高性能。
return *(*string)(unsafe.Pointer(&b.buf))
}
bytes.Buffer通过拷贝生成新的字符串,与底层 []byte
数据无关,但是strings.Builder使用 unsafe 包的 Pointer,直接将 b.buf 转换为字符串。避免了额外的分配和拷贝操作,提高性能。
tips: strings.Builder和bytes.Buffer同样也可以通过预分配提高性能,预分配都是通过Grow函数实现:
var builder strings.Builder
builder.Grow(n*len(str))
或者
buf := new(bytes.Buffer)
buf.Grow(len(str)*n)
使用空结构体节省内存
- 空结构体 struct{} 实例不占据任何的内存空间
- 可作为各种场景下的占位符使用
// EmptyStructMap 创建一个以整数为键,空结构体为值的 map。
// 空结构体(`struct{}`)是一种内存占用为零的特殊类型,适用于仅需表示存在性的数据结构。
func EmptyStructMap(n int) {
// 创建一个 map,其中键为 int 类型,值为 struct{} 类型。
m := make(map[int]struct{})
// 遍历从 0 到 n-1 的整数
for i := 0; i < n; i++ {
// 将键值对 (i, struct{}) 添加到 map 中
m[i] = struct{}{}
}
}
或者
// BoolMap 创建一个以整数为键,布尔值为值的 map。
// 每个键的值初始化为 `false`。
func BoolMap(n int) {
// 创建一个 map,其中键为 int 类型,值为 bool 类型。
m := make(map[int]bool)
// 遍历从 0 到 n-1 的整数
for i := 0; i < n; i++ {
// 将键值对 (i, false) 添加到 map 中
m[i] = false
}
}
下面这个函数用bool当作占位符,这是不如上面空结构体的,因为bool也占一个字节。
- 实现 Set,可以考虑用 map 来代替
- 对于这个场景,只需要用到 map 的键,而不需要值
- 即使是将 map 的值设置为 bool 类型,也会多占据1个字节空间
atomic包
type atomicCounter struct {
i int32 // 使用 int32 类型的变量作为计数器
}
// AtomicAddOne 使用 atomic 包提供的原子操作给计数器加 1。
// 原子操作是线程安全的,适合并发场景下的简单计数器操作。
func AtomicAddOne(c *atomicCounter) {
// 使用 atomic.AddInt32 以原子方式将 c.i 加 1。
// 该操作是线程安全的,不需要使用锁。
atomic.AddInt32(&c.i, 1)
}
或者
type mutexCounter struct {
i int32 // 使用 int32 类型的变量作为计数器
m sync.Mutex // 使用 Mutex 保证计数器操作的线程安全
}
// MutexAddOne 使用 sync.Mutex 加锁来实现线程安全的计数器加 1 操作。
func MutexAddOne(c *mutexCounter) {
c.m.Lock() // 加锁,确保只有一个 Goroutine 能访问计数器
c.i++ // 执行计数器加 1 操作
c.m.Unlock() // 解锁,允许其他 Goroutine 继续访问
}
- 锁的实现是通过操作系统来实现,属于系统调用
- atomic 操作是通过硬件实现,效率比锁高
- sync.Mutex 应该用来保护一段逻辑,不仅仅用于保护一个变量
- 对于非数值操作,可以使用 atomic.Value,能承载一个 interface{}
- 但是atomic只能对特定类型(如
int32
或int64
)的变量进行操作,适用场景相对有限 - 锁最好是用于一段逻辑
pprof的使用
pprof是性能调试工具,可以生成类似火焰图、堆栈图,内存分析图等。
整个分析的过程分为两步:1. 导出数据,2. 分析数据。
先把示例代码克隆下来:github.com/wolfogre/go…
在这个主页有一个实验教程,跟着做其实就好了,我下面简单总结一下pprof的用法:
先将代码编译运行,之后在浏览器输入:/debug/pprof/,端口号是代码里面自己定义的,我这里改了一下。
这个页面列出了pprof工具可以分析的几个点,其实我们主要关注的是allocs内存分配、block阻塞、goroutine协程、heap堆内存占用、mutex锁、profile。
我们可以在终端输入命令go tool pprof http://localhost:8081/debug/pprof/profile
这个可以查看cpu的占用,就是在url后面加上/profile就能查看cpu占用情况了,同理把/profile
换成/alloc
就能查看内存分配情况。
然后可以使用top命令查看cpu占用最高的程序。
-
flat:当前函数本身的执行耗时。
-
flat% :flat 占 CPU 总时间的比例。
-
sum% :上面每一行的 flat% 总和。
-
cum:指当前函数本身加上其调用函数的总耗时。
-
cum% :cum 占 CPU 总时间的比例。
可以使用list命令定位到实际的代码,比如这里说了Pee函数占用高,那可以输入list Pee
,就能查看哪里的代码占用cpu了。
也可以把这些进行可视化,先安装graphviz依赖,windows在cmd里面输入conda install python-graphviz
就行,安装好之后,在刚才的那里输入web就能可视化这些资源占用情况了。
其它资源比如heap,查看流程和上面一样,就不再赘述了。需要注意的是使用top命令展示各种资源占用的时候,他会过滤一些cum时间很短的,比如我上面那张图,drop 25 nodes,如果想要查看全部的话,就在http://localhost:8081/debug/pprof 展示的页面点击查看就行。