高质量编程
1 高质量编程简介
2 编码规范
代码格式
注释
- 注释公共符号(例如常量)
- 注释实现过程,局部代码块关键部分加注释
- 注释 代码实现的原因(即应用背景、上下文)
- 注释 什么情况下会出错
小结
- 代码是最好的注释
- 注释应该提供代码未表达出的上下文信息
命名规范
尽量做到见名知义:
函数命名:
调用格式:
http.Server()//选第一种
http.ServerHttp()
包的命名
控制流程
错误和异常处理
简单错误:
- 使用errors.New
- 如果有格式要求使用fmt.Errorof
示例:启动一个消费组,启动失败使用panic。(类比抛出异常)
panic和recover是成对出现的。类比(throw/throws 和 catch)
例子
结果:
3
1
3 性能优化建议
benchmark
对应的命令:可以查看内存指标
go test -bench . -benchmem
package benchmark
func Fib(n int) int{
if n<2{
return n
}
return Fib(n-1)+Fib(n-2)
}
package benchmark
import "testing"
//testing是一个测试包
func BenchmarkFib(b *testing.B) {
for i := 0; i < b.N; i++ {
Fib(10)
}
}
结果:
Slice
预分配
package benchmark
func NoPreAlloc(size int) []int{
data := make([]int,0)
for k := 0; k < size; k++ {
data = append(data, k)
}
return data
}
func PreAlloc(size int) []int{
data := make([]int,0,size)
for k := 0; k < size; k++ {
data = append(data, k)
}
return data
}
区别:创建slice有没有指定容量。即有没有进行多次分配。
package benchmark
import "testing"
func BenchmarkNoPreAlloc(b *testing.B) {
for i := 0; i < b.N; i++ {
NoPreAlloc(12000)
}
}
func BenchmarkPreAlloc(b *testing.B) {
for i := 0; i < b.N; i++ {
PreAlloc(12000)
}
}
结果:
如果空间足够,那么append()元素直接插入即可。 如果空间不足够,就需要先开辟一段空间,将原来的数据复制过去,再插入,这其实是很浪费时间的。
-
切片本质是一个数组片段的描述,包括了数组的指针,这个片段的长度和容量(不改变内存分配情况下的最大长度)
-
切片操作并不复制切片指向的元素,创建一个新的切片会复用原来切片的底层数组,因此切片操作是非常高效的
-
切片有三个属性,指针(ptr)、长度(len) 和容量(cap)。append 时有两种场景:
- 当 append 之后的长度小于等于 cap,将会直接利用原底层数组剩余的空间
- 当 append 后的长度大于 cap 时,则会分配一块更大的区域来容纳新的底层数组
-
因此,为了避免内存发生拷贝,如果能够知道最终的切片的大小,预先设置 cap 的值能够获得最好的性能
大内存未释放陷阱
结果:
Map
字符串处理
字符串拼接 vs strings.Builder
最普通的字符串用+号每次都会新分配空间,再把字符串赋值过去。
package strings
import "testing"
func BenchmarkPlus(b *testing.B) {
for i := 0; i < b.N; i++ {
Plus(1000,"hello")
}
}
func BenchmarkStrBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
StrBuilder(1000,"hello")
}
}
测试结果:
strings.Builder 和 bytes.Buffer
func BytesBuffer(n int,str string) string{
buffer := bytes.Buffer{}
for i := 0; i < n; i++ {
buffer.WriteString(str)
}
return buffer.String()
}
package strings
import "testing"
func BenchmarkPlus(b *testing.B) {
for i := 0; i < b.N; i++ {
Plus(1000,"hello")
}
}
func BenchmarkStrBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
StrBuilder(1000,"hello")
}
}
func BenchmarkBytesBuffer(b *testing.B) {
for i := 0; i < b.N; i++ {
BytesBuffer(1000,"hello")
}
}
结果:
string.Builder会比bytes.Buffer稍微快一些。图片中是strings.Builder更快。
预分配
Builder和Buffer也可以提前预分配空间,进一步优化
func PreStrBuilder(n int,str string) string{
builder:= strings.Builder{}
builder.Grow(n*len(str))
//len(str) return the number of byte
for i := 0; i < n; i++ {
builder.WriteString(str)
}
return builder.String()
}
func PreBytesBuilder(n int,str string) string{
buffer := bytes.Buffer{}
buffer.Grow(n * len(str))
for i := 0; i < n; i++ {
buffer.WriteString(str)
}
return buffer.String()
}
PreBuffer进行了两次内存分配
空结构体
package emptystruct
func EmptyStructMap(n int) {
m := make(map[int]struct{})
for i := 0; i < n; i++ {
m[i] = struct{}{}
}
}
func BoolMap(n int) {
m := make(map[int]bool)
for i := 0; i < n; i++ {
m[i]=false
}
}
package emptystruct
import "testing"
func BenchmarkEmptyStructMap(b *testing.B) {
for i := 0; i < b.N; i++ {
EmptyStructMap(b.N)
}
}
func BenchmarkBoolMap(b *testing.B) {
for i := 0; i < b.N; i++ {
BoolMap(b.N)
}
}
结果:
- 空结构体并不占用空间
- 根据1可以使用map实现set
atomic
代码:
package atomic
import (
"sync"
"sync/atomic"
)
type atomicCounter struct {
i int32
}
type mutexCounter struct {
i int32
mutex sync.Mutex
}
// AtomicAddOne 保护一个变量
func AtomicAddOne(c *atomicCounter) {
atomic.AddInt32(&c.i,1)
}
// MutexAddOne 保护一段逻辑
func MutexAddOne(m *mutexCounter) {
m.mutex.Lock()
m.i ++
m.mutex.Unlock()
}
package atomic
import (
"sync"
"testing"
)
func BenchmarkAtomicAddOne(b *testing.B) {
counter := atomicCounter{i: 0}
for i := 0; i < b.N; i++ {
AtomicAddOne(&counter)
}
}
func BenchmarkMutexAddOne(b *testing.B) {
counter := mutexCounter{i: 1, mutex: sync.Mutex{}}
for i := 0; i < b.N; i++ {
MutexAddOne(&counter)
}
}
测试结果:
sync.Mutex常用于保护一段逻辑
atomic常用于保护一个变量
总结:
性能调优实践
性能调优简介
不要过早优化:因为产品前期是在不停的变化的,你前期优化可能根本没有用。 不要过度优化:因为产品是在不停的迭代的,如果就是优化手段太极端,可能在后续迭代中是无效的。
性能分析工具pprof实战
pprof功能简介
pprof 排查实战
blog.wolfogre.com/posts/go-pp… 参考网址
github.com/wolfogre/go… github源码网站
下载对应源码然后运行
保持程序运行,打开浏览器访问 http://localhost:6060/debug/pprof/,可以看到如下页面
页面上展示了可用的程序运行采样数据,分别有:
| 类型 | 描述 | 备注 |
|---|---|---|
| allocs | 内存分配情况的采样信息 | 可以用浏览器打开,但可读性不高 |
| blocks | 阻塞操作情况的采样信息 | 可以用浏览器打开,但可读性不高 |
| cmdline | 显示程序启动命令及参数 | 可以用浏览器打开,这里会显示 ./go-pprof-practice |
| goroutine | 当前所有协程的堆栈信息 | 可以用浏览器打开,但可读性不高 |
| heap | 堆上内存使用情况的采样信息 | 可以用浏览器打开,但可读性不高 |
| mutex | 锁争用情况的采样信息 | 可以用浏览器打开,但可读性不高 |
| profile | CPU 占用情况的采样信息 | 浏览器打开会下载文件 |
| threadcreate | 系统线程创建情况的采样信息 | 可以用浏览器打开,但可读性不高 |
| trace | 程序运行跟踪信息 | 浏览器打开会下载文件,本文不涉及,可另行参阅《深入浅出 Go trace》 |
本次主要查看以下几个指标:
- allocs
- block
- heap
- mutex
- pofile(CPU)
借助 go tool pprof 命令来排查问题,这个命令是 go 原生自带的,所以不用额外安装。
cpu
查看运行程序:由于是20核的cpu, 一个核心刚好百分之五左右。
可以看到 CPU 占用相当高,这显然是有问题的,我们使用 go tool pprof 来排场一下:
go tool pprof http://localhost:6060/debug/pprof/profile
等待一会儿后,进入一个交互式终端,
输入 topN 命令(查看前n个): 查看 CPU 占用较高的调用:
很明显,CPU 占用过高是 github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Eat 造成的。
flat 当前函数本身的执行耗时(不包括函数中调用的其他函数)
flat% flat占cpu总时间的比例
sum%上面每一行的%flat的综合
cum当前函数本身加上其调用函数的总耗时
cum% cum占CPU总时间的比例
Flat==0 函数中只有其他函数的调用或者函数体为空
定位到消耗性能的代码行。可以看到这个for循环消耗了8.34s的cpu时间
web命令报错:
接下来有一个扩展操作:图形化显示调用栈信息,这很酷,但是需要你事先在机器上安装 graphviz
安装完成后,我们继续在上文的交互式终端里输入 web,注意,虽然这个命令的名字叫“web”,但它的实际行为是产生一个 .svg 文件,并调用你的系统里设置的默认打开 .svg 的程序打开它。如果你的系统里打开 .svg 的默认程序并不是浏览器(比如可能是你的代码编辑器),这时候你需要设置一下默认使用浏览器打开 .svg 文件,相信这难不倒你。
全图如:E:\Go\ByteDance\pprof001.svg
定位到问题修改代码:
重新运行程序:
cpu的平均占用率直接降到了百分之一以下。
heap堆内存
占了5个G的内存
可视化
使用可视化工具展示:
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
结果如下:
顶部有一个View菜单:
定位到消耗内存最大的位置查看:点击view下面的peek
top图:
source图:
注释代码:
重新运行查看结果:
发现内存占用确实减少了,但是CPU占用率却增大了,这是为什么呢?
func (m *Mouse) Steal() {
log.Println(m.Name(), "steal")
max := constant.Gi
for len(m.buffer)*constant.Mi < max {
//m.buffer = append(m.buffer, [constant.Mi]byte{})
}
}
原因是:当把这行代码注释掉之后,这就变成了一个死循环。
整个for循环注释重新运行代码:
sample
这两个地方是相对应的:
主要是有inuse和alloc两个大类的采样
alloc_space
对应alloc_space的view下面souce视图:
对应alloc_space的graph下面的视图:可以看到总共分配了866MB的空间,其中Dog struct的Run方法就分配了864MB, 每次分配16M。根据上面的source图我们可也发现这块内存分配之后并没有被使用,这会引起频繁的gc。
注释代码:
重新运行查看结果:此时内存分配的数据量减少到512kB左右
goroutine-协程
goroutine泄露也会导致内存泄露
内存泄漏是指程序在运行过程中分配的内存空间没有被及时释放或回收,导致这些内存空间无法再被程序所访问,但又不能被操作系统回收,从而造成系统内存的持续增加。内存泄漏会导致系统的内存资源逐渐耗尽,最终可能导致程序崩溃或系统变得不稳定。
此时程序中有257个goroutine,这个协程的数目是超出预期的,因为程序当中并没有什么特殊的功能。
直接url打开对应的记录文件,可观测性是比较差的。
http://localhost:6060/debug/pprof/goroutine
可以使用图形化界面进行展开
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/goroutine
更换后缀即可查看不同的可视化数据。
点击view下面的火焰图:可以结合view下面的source进行查看
可以看出mouse的Pee方法和wolf的Drink贡献了较多的协程
//每创建一个协程直接就进入无休止的休眠,协程数量会越来越多
func (m *Mouse) Pee() {
log.Println(m.Name(), "pee")
go func() {
time.Sleep(time.Second * 30)
max := constant.Gi
for len(m.slowBuffer)*constant.Mi < max {
//注释之后该协程直接进入休眠状态
//m.slowBuffer = append(m.slowBuffer, [constant.Mi]byte{})
time.Sleep(time.Millisecond * 500)
}
}()
}
//可以看到,Drink 函数每次会释放 10 个协程出去,每个协程会睡眠 30 秒再退出,而 Drink 函数又会被反复调用,这才导致大量协程泄露,试想一下,如果释放出的协程会永久阻塞,那么泄露的协程数便会持续增加,内存的占用也会持续增加,那迟早是会被操作系统杀死的。
func (w *Wolf) Drink() {
log.Println(w.Name(), "drink")
for i := 0; i < 10; i++ {
go func() {
time.Sleep(30 * time.Second)
}()
}
}
直接注释掉对应的问题代码,我们可以看到协程数如下最终协程数量有5个
mutex-锁
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/mutex
到目前为止,我们已经解决这个炸弹程序的所有资源占用问题,但是事情还没有完,我们需要进一步排查那些会导致程序运行慢的性能问题,这些问题可能并不会导致资源占用,但会让程序效率低下,这同样是高性能程序所忌讳的。
我们首先想到的就是程序中是否有不合理的锁的争用,我们倒一倒,回头看看上一张图,虽然协程数已经降到 4 条,但还显示有一个 mutex 存在争用问题。
同样是 top、list、web 大法:定位错误在wolf struct结构体的结构体方法Howl中
func (w *Wolf) Howl() {
log.Println(w.Name(), "howl")
m := &sync.Mutex{}
m.Lock()
go func() {
time.Sleep(time.Second)
m.Unlock()
}()
m.Lock()
}
可以看到,这个锁由主协程 Lock,并启动子协程去 Unlock,主协程会阻塞在第二次 Lock 这儿等待子协程完成任务,但由于子协程足足睡眠了一秒,导致主协程等待这个锁释放足足等了一秒钟。虽然这可能是实际的业务需要,逻辑上说得通,并不一定真的是性能瓶颈,但既然它出现在我写的“炸弹”里,就肯定不是什么“业务需要”啦。
为什么需要加两次锁:
-
第一次,资源的互斥竞争。
-
第二次,等待子线程执行完任务。
注释对应的代码进行重新查看:锁已经不见了。
block阻塞
有两个block的点
找到阻塞的地方是cat类的Pee方法
代码:
func (c *Cat) Pee() {
log.Println(c.Name(), "pee")
<-time.After(time.Second)
}
/*
这是一个 Go 语言中的方法,函数签名为 `func (c *Cat) Pee()`。该方法属于 `Cat` 结构体类型的一个成员函数。
函数体内的代码包含了两部分:
1. `log.Println(c.Name(), "pee")`:这行代码使用 `log` 包打印出猫的名称和字符串 "pee",表示猫在撒尿。
2. `<-time.After(time.Second)`:这是一个时间通道的操作。`time.After` 函数返回一个通道,经过指定的时间后,该通道会发送一个当前时间值。在这里,`time.After(time.Second)` 返回一个通道,经过 1 秒钟后,该通道会发送一个时间值。通过 `<-` 运算符,我们从这个通道接收并阻塞当前的 goroutine,直到通道中有值被发送过来(即经过了 1 秒钟),此时才会继续执行下面的代码。
所以,整个函数的作用是:在猫撒尿时,程序会暂停执行 1 秒钟,然后继续执行后续的代码。
*/
默认的过滤策略。
第二个阻塞操作是什么?
注释掉阻塞代码查看结果:
总结
pprof的采样过程和原理
cpu
采样函数调用占用cpu的时间
进程启动操作系统定时器,定时器每隔10ms向进程发起一次采样信号。 进程收到sigprof信号会记录调用对战的信息。 然后将堆栈的信息写入到缓冲区,每个100ms将缓冲区的数据写入到输出流。 最后保存成profile文件,可以被解析为图像,进行多维度可视化展示。
heap-堆内存
gogc有自己的一些记录机制。 但是也有一些记录不到的数据。栈上的内存采集不到或者其他一些底层的内存分配。
goroutine-协程&ThreadCreate-线程创建
Stop-The-World 简称 STW,是指在执行垃圾回收的过程冻结所有用户线程的运行,直到垃圾回收线程执行结束。
Goroutie采样会记录所有用户发起,也就是入口不是runtime开头的goroutine,以及main所在goroutine的信息和创建这些goroutine的调用栈。 它们都是会在STW后,漏历所有goroutine/线程的列表〔图中的m就是GMP模型中的m,在golang中和线程对应)并输出堆栈,最后STW继续运行。该采样是立刻触发的全量记录,可以比较两个时间点的差值来得到某一时间段的指标。
GMP 模型是 Go 语言调度器采用的并发编程模型,它包含三个重要的组件:Goroutine(G)、逻辑处理器(P)和操作系统线程(M)。这些组件协同工作以实现 Go 程序的高效并发执行。
block阻塞&Mutex锁
两指标在流程和原理上相似,不过指标的采样率含义不同:
- 阻塞操作的采样率是个阈值,超过阈值时间的阻塞操作才会被记录,1为每次操作都会记录。炸弹程序的main里面设置rate=1
- 锁竟争的采样率是个比例,运行时会通过随机数来记录固定比例的锁操作,1为每次操作都记录
实现也基本相同,在阻塞或锁操作发生时,会算出消耗的时间,连同调用栈一起主动上报给采样器,采样时,采样器会遍历已记录的信息,统计出具体操作次数、调用栈和总耗时。同样可以算两个时间点的差值算出段时间内的操作指标。
package main
import (
"log"
"net/http"
_ "net/http/pprof"
"os"
"runtime"
"time"
"github.com/wolfogre/go-pprof-practice/animal"
)
func main() {
log.SetFlags(log.Lshortfile | log.LstdFlags)
log.SetOutput(os.Stdout)
runtime.GOMAXPROCS(1) //限制cpu使用数量
runtime.SetMutexProfileFraction(1) // 开启对锁调用的跟踪
//值为1代表每次加锁都记录
runtime.SetBlockProfileRate(1) // 开启对阻塞操作的跟踪,每次阻塞都记录
//值为1代表每次阻塞都记录
go func() {
if err := http.ListenAndServe(":6060", nil); err != nil {
//启动一个 http server,注意 pprof 相关的 handler 已经自动注册过了
log.Fatal(err)
}
os.Exit(0)
}()
for {
for _, v := range animal.AllAnimals {
v.Live()
}
time.Sleep(time.Second)
}
}
总结
性能调优案例
简介:
业务服务优化:真正提供功能的。 基础库:例如日志库。 GO语言优化:堆go语言的运行时和编译器进行优化,看看嫩不能编译出更高效的程序。
业务服务优化
调用链路:例如现在一个功能是A.afunc()->B.func(), A.afunc()->C.func() 基础库:监控、日志记录、中间件
什么是中间件?
中间件是指不同应用程序用于相互通信的软件。它提供智能、高效连接应用程序的功能,从而使您能够更快速地创新。中间件相当于不同技术、工具和数据库之间的桥梁,因此,您可以将它们无缝集成到单个系统中。单个系统随后向其用户提供统一服务。例如,Windows 前端应用程序通过 Linux 后端服务器发送和接收数据,但应用程序用户并不知道这一差别。
性能评估手段
单机压测
单机压测结果: 随着cpu负责的升高,能承载的qps并不是线性增长的
QPS:Queries Per Second意思是“每秒查询率”,是一台服务器每秒能够相应的查询次数,是对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准。
TPS是 TransactionsPerSecond的缩写,也就是事务数/秒。它是软件测试结果的测量单位。一个事务是指一个客户机向服务器发送请求然后服务器 做出反应的过程。客户机在发送请求时开始计时,收到服务器响应后结束计时,以此来计算使用的时间和完成的事务个数,最终利用这些信息来估计得分。客户机使 用加权协函数平均方法来计算客户机的得分,测试软件就是利用客户机的这些信息使用加权协函数平均方法来计算服务器端的整体TPS得分。
火焰图
线上应用的火焰图,采用pprof采用,这个时候交互功能就比较有用了,就可以点开对应的模块(函数),查看对应的信息,方便定位一个线上问题。
压测报告
压测结果会产生一个压测报告:
上面的曲线是压测曲线,从200qps开始压测到240再提高到280,到280时,可以看到不是所有的请求都能成功了,这就是性能调优需要解决的问题。
评估指标:
- 耗时:平均耗时、最大耗时、latency pct50... 从图中我们可以看出有百分之五十的请求耗时小于198.11ms
- 请求统计
- QPS
压测结果中 latency pct50是什么意思
gpt3.5;
在压测结果中,latency pct50是指延迟的百分位数。具体来说,它表示在所有请求中,有50%的请求的延迟小于或等于该值。换句话说,它是一个统计值,用于衡量系统在处理请求时的响应速度。
通常,latency pct50是压测结果中最常用的指标之一,因为它能够反映出系统的整体响应速度的中间水平。比如,如果一个系统的latency pct50为100毫秒,那么意味着50%的请求的响应时间在100毫秒以内,而剩下的50%请求的响应时间在100毫秒以上。
较低的latency pct50值通常表示系统响应速度较快,而较高的值则表示系统响应速度较慢。在性能测试和优化中,开发人员可以通过关注latency pct50来评估系统的性能,并找出需要改进的地方,以提高系统的响应速度和性能。
分析性能数据,定位性能瓶颈
使用库不规范
分析火焰图可以看到ReplaceByConfig中的json反序列化操作耗费了大量的资源。 但实际上config是有缓存的,应该去缓存中拿,而不是直接反序列化。 只需要在变更的时候进行更新缓存即可。
日志是在开发中常用的库,如果使用不规范不仅会产生很多无用的数据,而且还会对服务的性能造成影响。
有的时候,把日志规范规范一下,对性能也有不小的提升。
高并发场景优化不足
高峰期火焰图(cpu:80%)
低峰期火焰图(cpu:40%):
将低峰期的性能数据和高峰期的性能数据作一个对比就可以发现,metrics这个库资源占用明显提高。 可以发现metrics监控数据上报,一直在用同步请求上报,会造成阻塞,影响业务处理。 可以将监控数据上报改成异步请求。
- 保存旧接口的请求数据和返回数据
- 然后运行新的接口得到结果
- 二者再做diff比较,从而保证正确性。
逐步放量:逐步增大服务器能处理的请求数量。
例如ServiceA调用ServiceB serviceB是不是只需要返回更少的数据就能满足需求。从而对整个链条进行优化。
再比如ServiceA中有对B中接口的重复调用,是否能抽取出来只用调用一次呢。
基础库优化
红色方框内是序列化函数,数据序列化协议优化后,性能提高了2%。
go语言优化
内部压测验证: 上线之前必须经过压测和功能正确性验证。
总结
首先保证功能的正确性
问题
这两个函数的区别
func (w *Wolf) Howl() {
m := &sync.Mutex{}
m.Lock()
go func() {
time.Sleep(time.Second)
m.Unlock()
}()
m.Lock()
}
func (w *Wolf) Howl() {
m := sync.Mutex{}
m.Lock()
go func() {
time.Sleep(time.Second)
m.Unlock()
}()
m.Lock()
}
m := &sync.Mutex{} 用不用&符号有区别吗,兄弟们