1. 编程原则
实际场景千变万化,各种语言各不相同,但是高质量编程遵循的原则是相通的。
- 简单性
- 消除“多余的复杂性”,以简单清晰的逻辑写代码
- 不理解的代码无法修复改进
- 可读性
- 代码是写给人看的而不是机器看的
- 编写可维护的代码的第一步是确保代码可读
- 生产力
- 团队整体工作效率非常重要
2. 命名规范
2.1 变量的命名
- 缩略词全部大写,但当其位于变量开头而且不需要导出时,需要全小写
- 例如ServeHTTP而不是用ServeHttp
- 使用XMLHTTPRequest或者xmlHTTPRequest
- 变量距离其被使用的地方越远,则需要携带越多的上下文信息
- 全局变量在其名字中需要更多的上下文信息,使得在不同地方可以轻易辨认出其含义
2.2 函数/方法的命名
- 函数名尽量不携带包名的上下文信息,因为包名和函数名总是成对出现的
- 函数名要尽量简短
- 当名为foo的包的某个函数返回类型foo时,可以省略类型信息而不导致歧义
- 当名为foo的包的某个函数返回类型T时(T并不是Foo),可以在函数名中加入类型信息。
2.3 包的命名
只由小写字母
组成。不包含大写字母和下划线等字符- 简短并包含一定的上下文信息。例如 schema、task 等
- 不要与标准库同名。例如不要使用 sync 或者 strings
3. 编码规范-控制流程
- 线性原理,处理逻辑尽量走直线,避免复杂的分支嵌套
- 正常流程代码沿着屏幕向下移动
- 提升代码可维护性和可读性
- 故障问题大多出现在复杂的条件语句和循环嵌套中
4. 编码规范-错误捕获与处理
4.1 简单错误处理
- 简单的错误指的是仅出现一次的错误,而且在别的地方可以不用捕获该错误
- 优先使用errors.new(“error info”)这样的匿名消息来处理简单错误
- 如果有格式化的要求则使用fmt.Errorf
如:fmt.Errorf("正在处理错误%d", 20036)
4.2 复杂错误处理
- 错误的warp实际上是提供了一个error嵌套另一个error的能力,从而生成一个error的跟踪链。如何理解跟踪链呢,也就是类似于JAVA里面异常处理的机制,当发生异常时,异常会一层层地向上抛,直到它被捕获并且处理。
- 在fmt.Errorf中使用%w关键字来将一个错误关联至错误链中。
- 错误判定,判定一个错误是否为特定错误,使用Error.Is,不同于使用== ,使用该方法可以判定错误链上的所有错误是否含有特定的错误
- 特定错误获取,在错误链上获取特定种类的错误,使用errors.As
panic
,不建议在业务代码中使用panic,调用函数不包含recover会造成程序崩溃,若问题可以被屏蔽解决,建议使用error代替panic- 当程序启动阶段发生不可逆转的错误时,可以在init或main函数中使用panic
recover机制
:只能在被defer的函数中使用,嵌套无法生效,只在当前的goroutine生效.defer语句
:defer是后进先出的,我们来看一个例子func test() { if true { defer fmt.Println("1") } else { defer fmt.Println("2") } defer fmt.Println("3") } //输出的结果是: //3 //1 //代码解析:首先首先是先进到了1所对应的defer中,然后最后进到了3所对应的defer中,根据后进先出原则,先执行3,再执行1
5. 性能优化
5.1 性能优化帮助工具-Benchmark
go语言提供了支持基准性能测试的benchmark工具
在终端中执行go test -bench=. -benchmen
即可启动
5.2 性能优化建议-slice使用
- 尽量在make slice时提供合适的容量信息以适应底层。
- 关于slice:其本质是数组片段的描述
- 包括数组指针
- 片段的长度
- 片段的容量(不改变内存分配情况下的最大片段长度),一旦长度超过该容量就会需要重新为切片分配内存
- 切片操作并不复制切片所指向的元素,而是在创建新切片时
复用
原来的切片。- 陷阱:大内存未释放,在已有切片的基础上创建切片,不会创建新的底层数组
- 实例:创建了一个100MB的大切片,此时引用一个它的3MB的小切片,这时候内存中依然有对大切片的内存引用,大切片得不到释放。
- 策略:使用copy来代替re-slice
5.3 性能优化建议-map的使用
- 对于map的定义,我们有两种方式,一种是提前预设好map的容量,另一种是不提前预设它的容量
data := make(map[int]int)
data := make(map[int]int,size)
- 在向map中填充数据的时候,map会不断扩容,如果我们提前对map中的内存进行预分配,可以减少内存拷贝和rehash的消耗。
5.4 性能优化建议-stringbuilder的使用
程序中对于处理拼接字符串有以下三种策略:
- 直接使用
+
进行拼接func addStr_fir(n int, str string) string { s := "" for i := 0; i < n; i++ { s += str } return s }
- 使用stringbuilder
func addStr_sec(n int, str string) string { builder := strings.Builder{} for i := 0; i < n; i++ { builder.WriteString(str) } return builder.String() }
- 使用byte[]数组
经过测试,性能最佳的使用stringBuilder的方法,最差的为直接使用加号拼接的,原因分析:func addStr_thr(n int, str string) string { buf := new(bytes.Buffer) for i := 0; i < n; i++ { buf.WriteString(str) } return buf.String() }
- 字符串在GO中是不可变类型占用内存的大小是固定,这一点和JAVA中的字符串管理是相同的,因此在使用+后,每次内存都会重新分配
- StringBuilder和StringBuffer其底层都是[]byte数组
- 内存扩容策略:不需要每次拼接都重新分配内存
- 为什么使用StringBuffer比StringBuilder要慢?
在拼接字符串时,其原理是一致的,差别在于最后,将底层的[]byte数组转化为string时存在差异,- stringBuilder是直接将[]byte元素转化为字符串后返回的
- byte.Buffer是新开辟了一块内存空间,把这个字符存到这里面去,再返回的
- 支持内存预分配:如果已知未来拼接后的字符串的长度,可以通过
Grow()
方法来为builder或者buffer预分配内存空间,从而进一步提高性能
5.5 性能优化建议-空结构体的使用
可以使用空结构体来节省内存
- 空结构体struct实例不占据任何的内存空间
- 可作为任何场景下的占位符使用
- 节省资源
- 不占据任何的内存空间,同时也具有很强的语义,仅作为占位符
5.6 性能优化建议-atomic的使用
对于传统的加锁互斥来保证并发安全,也可以通过调用atomic包内方法来保证并发安全,而且效率更高
type atomicCounter struct {
i int32
}
func atomicFunc(c *atomicCounter) {
atomic.AddInt32(&c.i, 1)
}
- 锁的实现是通过操作系统来实现的,属于系统调用
- atomic是通过硬件来实现的,效率比锁要搞
- sync.Mutex应该用来保护一段逻辑,不仅仅用来保护一个变量
- 对于非数值操作,可以用atomic.Value来操作,可以承载一个interface{}
6. 性能分析工具-pprof介绍与使用
6.1 分析指标
- 当flat == cum时,该函数没有调用其他函数
- 当flat = 0,该函数只调用了其他函数
- 使用
list 关键字
可以来定位对应的代码行 - 使用
web
可以来使用可视化对刚才的过程进行图解分析
6.2 火焰图的使用
- 火焰图:从上到下表示调用顺序
- 每一块代表一个函数,越长代表占用CPU的时间更长
- 火焰图是动态的,支持点击块进行分析
6.3 通过完成实验来了解pprof的使用流程、
- 首先我们需要了解到pprof是将当前采集到的数据输出到profile文件中的,所以我们就需要先拿到这个profile文件,我们可以通过该文件来获取对应的信息,输入指令
go tool pprof "http://127.0.0.1:6060/debug/pprof/profile?seconds=10"
同时可以通过topN
来查看运行时间(占用)最高的几个服务 - 接着我们使用
list eat
这个指令来查看代码的所在位置来排除低性能。
可以看到图中,运行时间最多的是这个for循环,于是我们将有关的代码注释掉,然后对数据进行观测。
如果想要使用web指令,则需要先安装插件Graphviz
,安装后配置bin环境变量即可
同样可以定位到炸弹所在位置 - 接下来分析发现内存的利用率依然很高,我们继续来排除原因
对于内存问题,可以通过查看堆内存的方法来查看到底是哪里存在问题go tool pprof -http=:8080 "http://127.0.0.1:6060/debug/pprof/heap"
,如图:
从而定位到原因代码2
还可以通过alloc_space来查看目前是存在无意义的内存分配,比如说这里的话就是一直在申请16MB的内存,但是由于申请了没有用,会马上被GC回收掉,所以就不会显示在insue里面alloc_objects
:程序累计申请的对象数alloc_spoace
:程序累计申请的内存大小insue_objects
:程序当前持有的对象数insue_space
:程序当前占用的内存大小
然后我们继续分析,发现开启的协程到达了100的量级,这对于一个简单的小程序来说是很不正常的,我们使用火焰图
对其进行排查
我们发现此循环不断启动新协程,而且每启动还强制睡眠阻塞,我们将其注释掉
完成后继续分析锁的问题
至此就已经将程序中的所有炸弹排除了
7. 业务服务优化基本概念
- 服务:能够单独部署,承载一定功能的程序
- 依赖:Service A的功能实现依赖Service B的响应结果,称为Service A依赖Service B
- 调用链路:能支持一个接口请求的相关服务集合及其相互之间的依赖关系
- 基础库:公共的工具包、中间件
8. 内存管理基础
8.1 自动内存管理
动态内存:程序在运行时根据需求动态分配的内存:
malloc
自动内存管理(
垃圾回收
):由程序语言的运行时系统管理动态内存- 避免手动管理内存,专注于实现业务逻辑
- 保证内存的使用性和安全性
Mutator
:业务线程,分配新对象,修改对象的指向关系Collector
:GC线程,找到存活对象, 回收死亡对象的内存空间Serial GC
:只有一个CollectorParallel GC
:支持多个Collector同时回收的GC算法Concurrent GC
:Mutator(s)和collector(s)可以同时执行,Collector(s)必须感知到对象指向关系的改变
在GC前一个对象所指向的对象均未被标记,当GC启动后,会将存活的对象都标记上,但是当此时业务进程正在执行,将对象又指向了一个新对象的时候,这时候GC可能会疏忽漏标记了b对象,导致b对象被回收。评价GC算法的指标
- 基本要求:不能回收存活的对象
- 吞吐率:1-(GC时间/程序运行时间)
- 暂停时间:stop the world(STW)业务是否感知到了
- 内存开销:GC元数据开销
追踪垃圾回收
- 对象被回收的条件:指针指向关系不可达的对象
- 标记根对象
- 静态变量、全局变量、常量、线程栈等
- 标记:找到可达对象
- 求指针指向关系的闭包:从根对象出发,找到所有可达对象
- 清理:清理所有不可达对象
- 将存活对象复制到另外的内存空间(copying GC)
- 将死亡对象的内存区域标记为可分配(mark-sweep GC),使用free-list管理内存
- 移动并整理存活对象(mark-compact GC),原地整理
- 根据对象的生命周期,使用不同的标记和清理策略
引用计数
- 每个对象存有一个引用次数的字段,对象存活的条件就是当其引用次数>0的时候
- 优点
- 内存管理的操作被平摊到程序执行的过程中
- 内存管理不需要了解runtime的实现细节,只需要维护引用次数即可
- 缺点
- 当在多线程并发执行的时候,有可能会对同一个对象进行操作,因此需要对对象的操作原子化,通过原子操作保证对引用计数操作的原子性和可见性
- 当存在环形的数据结构的时候,是无法回收的
- 内存开销,每个对象都引用额外内存空间存储引用数目
- 回收内存时可能引发暂停
8.2 分代GC
分代假说
:most objects die young,很多对象在分配出来之后就不再使用了- 每个对象都有年龄:所谓年龄就是该对象经历GC的次数
- 目的:针对不同年龄的对象制定不同的GC策略,降低整体内存的开销
- 不同年龄的对象处于heap的不同区域
- 对于年轻代而言,由于
分代假说
的存在,年轻代总是趋向于年龄较低面临被GC的威胁,因此存活的对象较少,此时可以采用copying GC的策略,这时候的代价将会比较低 - 对于老年代而言,由于对象趋向于一致活着,反复复制开销很大,采用
copying GC
的策略是不合适的,这时候采用Mark-sweep collection的策略较好
9. GO内存管理
9.1 分块
- 目标:为对象在heap上分配内存
- 提前将内存进行分配
- 调用系统调用mmap(),向OS申请一大块内存,例如4MB
- 先将内存分成大块,例如8KB,称为mspan
- 再将大块继续划分成
特定大小
的小块,用于对象分配(有点类似伙伴系统?) - noscan mspan:分配不包含指针的对象,GC不需要扫描
- scan mspan:分配包含指针的对象,GC需要扫描
- 对象分配:根据对象大小,选择最合适的块返回
9.2 缓存
- TCMalloc:thread caching
- 每个p包含一个mcache用于快速分配,用于绑定于p上的g分配对象
- mcache管理一组mspan
- 当mcache中的mspan分配完毕,向mcentral申请带有未分配块的mspan
- 当mspan中没有未分配对象的时候,mspan会被缓存在mcentral中,而不是立即返回给OS
9.3 GO内存管理优化
- 对象分配是非常高频的操作:每秒分配
GB
级的内存 - 小对象的
占比比较高
- Go内存分配比较耗时
- 分配路径长 :g->m->p->mcache->mspan->memory block->return pointer
- pprof:对象分配函数是最频繁调用的函数之一
9.4 Balanced GC
- 将 noscan 对象在 per-g allocation buffer (GAB) 上分配,并使用移动对象 GC 管理这部分内存,提高对象分配和回收效率
- 每个 g 会附加一个较大的 allocation buffer (例如 1 KB) 用来分配小于 128 B 的 noscan 小对象
- 分配对象时,根据对象大小移动 top 指针并返回,快速完成一次对象分配
- 同原先调用 mallocgc() 进行对象分配的方式相比,balanced GC 缩短了对象分配的路径,减少了对象分配执行的指令数目,降低 CPU 使用
- 从 Go runtime 内存管理模块的角度看,一个 allocation buffer
其实是一个大对象
。本质上 balanced GC 是将多次小对象的分配合并成一次大对象的分配。因此,当 GAB 中哪怕只有一个小对象存活时,Go runtime 也会认为整个大对象(即 GAB)存活。为此,balanced GC 会根据 GC 策略,将 GAB 中存活的对象移动到另外的 GAB 中
,从而压缩并清理 GAB 的内存空间,原先的 GAB 空间由于不再有存活对象,可以全部释放. 指针碰撞风格
:无需与其他分配请求互斥,分配动作简单高效。
10. Go编译器优化
10.1 函数内联
- 函数内联:将
被调用函数
的函数体(callee)的副本替换
到调用位置
上,同时重写代码以反映参数的绑定 - 优点
- 消除函数调用的开销,例如传递参数、保存现场等
- 将过程间分析转化为过程内分析,帮助其他优化,例如逃逸优化等
- 缺点
- 函数体变大,对于CPU的instruction cache(可能导致大量的iCache的miss)不友好
- 编译生成的Go镜像变大了
10.2 Beast mode
- Go函数内联受到的限制较多
- 语言特性,例如interface、defer等,限制了函数内联
- 内联策略非常保守
Beast mode
:调整函数内联的策略,使更多函数被内联- 降低函数调用的开销
- 增加了其他优化的机会:
逃逸分析
- 开销
- Go镜像增大~10%
- 编译时间增加
10.3 逃逸分析
逃逸分析
:分析代码中指针的动态作用域,指针在何处可以被访问- 大致思路:从对象分配处出发,沿着控制流,观察对象的数据流
- 若发现指针p在当前作用域s:
- 作为参数传递给其他函数
- 传递给全局变量
- 传递给其他的goroutine
- 传递给已逃逸的指针指向的对象
- 则认为指针p逃出了作用域s,否则没有
- 若发现指针p在当前作用域s:
- Beast mode:函数内联扩展了函数边界,更多对象不逃逸
- 优化:未逃逸的对象可以在
栈上分配
- 对象在栈上分配和回收很快,移动sp
- 减少在heap上的分配,减小GC的负担