#3 高质量编程简介及代码规范 | 青训营笔记

214 阅读12分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 3 天
本次编程学习的基本环境配置如下
OS: macOS 13.1
IDE: Goland 2022.3
Go Version: 1.18

重点内容

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

详细介绍

高质量编程

简介

  1. 高质量意味着: 编写的代码能够达到正确可靠、简洁清晰的目标
  2. 正确性是首要目标,对于所有可预见的异常情况要有处理
  3. 代码是给人看的, 要实现地尽可能简单,易读易维护,方便之后的快速支持和评估
编程原则
  1. 简单性:消除“多余的复杂性”,以简单清晰的逻辑编写代码
  2. 可读性:编写可维护代码的第一步就是确保代码可读
  3. 生产力:团队整体工作效率非常重要。

编码规范

几个比较重要的规则

代码格式
  1. 推荐使用gofmt自动化格式代码, 很多IDE都支持
  2. goimports也是Go官方提供的工具, 相当于gofmt加上依赖包管理
注释

注释的功能:

  1. 应该解释代码作用
  2. 应该解释代码如何做的
  3. 应该解释代码实现的原因, 解释代码的外部因素, 提供额外的上下文
  4. 应该解释代码什么情况会出错和限制条件

好的代码有很多注释, 坏的代码需要很多注释


其他注释规则

  1. 公共符号始终要注释, 无论方法的复杂程度如何
  2. 不需要注释实现接口的代码
  3. 代码是最好的注释. 注释应该提供代码未表达出来的上下文信息
命名规范
  1. 简洁, 不要冗长
  2. 缩略词全大写, 但当其位于变量开头, 而且不需要导出的时候,使用全小写
  3. 变量距离其被使用的地方越远, 则需要携带越多的上下文信息
// Good
func (c *Client) send(req *Request, deadline time.Time)

// Bad
func (c *Client) send(req *Request, t time.Time)
  1. 函数名不需要携带包名的上下文信息
  2. 函数名尽可能剪短
  3. 返回值类型人尽皆知的时候, 函数名称应省略类型信息, 需要强调的时候, 加入类型信息
  4. package只由小写字母组成, 简单并包含一定的上下文信息, 不要与标准库同名
  5. 尽量不要用常用变量名作为包名, 使用单数而不是复数, 尽量不适用缩写, 使用缩写应该不破坏上下文

核心目标是降低阅读理解代码的成本
重点考虑上下文信息, 设计简洁清晰的名称
Good naming is like a good joke, if you have to explain it, it's not funny.

控制流程
  1. 避免嵌套, 保持正常流程清晰. 去除冗余的else
if foo{
    return x
}else{
    return nil
}
// good
if foo {
    return x
}
return nil;

  1. 尽量保持正常代码路径为最小缩进
// Bad
func OneFunc() error{
    err := doSomething()
    if err == nil{
        err := doAnotherThing()
        if err == nil{
            return nil // normal case
        }
        return err
    }
    return err
}
// Good
func OneFunc() error{
    
    if err := doSomething();err != nil{
        return err
    }
    
    if err := doAnotherThing();err != nil{
        return err
    }
    // add other case..
    // normal case 
    return nil;
}
  1. 线性原理. 处理逻辑尽量走直线, 避免复杂的嵌套分支
  2. 正常流程代码沿着屏幕向下移动
  3. 提升代码可维护性和可读性
  4. 故障问题大多出现在复杂的流程控制语句中
错误和异常处理
  1. 简单的错误值得是仅出现一次的错误, 且在其他地方不需要捕获该错误
  2. 优先使用errors.New 来创建匿名变量来直接表示简单错误
  3. 如果格式化的需求, 使用fmt.Errorf()

  1. 错误的Wrap实际上是提供了一个error嵌套另一个error的能力, 从而生成一个error的跟踪链.
  2. fmt.Errorf中使用"%w"将一个错误关联到错误链中
  3. errors.Is()用于判断错误链上是否有指定类型的错误
  4. 在错误链上获取特定种类的错误, 使用errors.As

  1. 不建议在业务代码中使用panic, 除了在程序启动的时候
  2. 建议函数不包含recover会造成程序崩溃。
  3. 若问题可以被屏蔽获取解决, 建议使用error代替panic
  4. recover只能在被defer的函数中使用, 嵌套无法生效, 只能在当前goroutine中生效
  5. recover在log中记录当前的调用栈, 记录有用的上下文信息
  6. 错误处理的时候, 需要提供简明的上下文信息链,方便定位及问题
func main(){
    if true{
        defer fmt.Printf("1")
    }else{
        defer fmt.Printf("2")
    }
    defer fmt.Printf("3")
    // 最终输出 31
}

性能优化建议

image.png

性能表现需要实际数据衡量, GO语言提供了支持基准性能测试的benchmark工具

slice

  1. slice预分配内存, make()时给cap信息
  2. 切片本质是一个数组片段的描述,包括数组长度,片段的长度,片段的容量等。
  3. 如果容量不够,append会引起扩容
  4. 切片操作不会复制切片指向的元素
  5. 创建一个新的切片会复用原来切片的底层数组(用的是unsafe.Pointer)

image.png 6. 在已有切片上创建切片, 不会创建新的底层数组 7. 原切片较大,代码在原切片期初上创建小切片,原切片数组在内存中有引用,内存不会释放

map

  1. 同样, 可以对map预分配
  2. 提前分配好空间可以减少内存拷贝和Rehash的消耗

字符串处理

  1. 拼接字符串性能: 使用+ << 使用ByteBuffer < 使用strings.Builer
  2. 字符串在Go语言中是不可变类型, 占用内存大小是固定的
  3. 使用+都会重新分配内存
  4. 后两者都是[]byte, 有自己的内存分配策略
  5. bytes.Buffer转换成字符串时重新申请了一块空间, 而strings.Builder直接将底层的b.buf作为字符串返回, 因此strings.Builder更快一点
  6. 如果字符串的长度已知的话, 可以用Grow方法预分配内存(对ByteBufferstrings.Builer)

节省内存空间

  1. 空结构体不占用任何内存空间
  2. 可以用map空结构体实现Set, 即使设置成bool类型也是会占据一个字节

atomic包

  1. 原子变量(硬件) > 加锁 (软件, OS, 2倍以上)
  2. 锁用来保护一段逻辑,atomic用来维护一个变量
  3. 对于非数值操作,可以使用atomic.Value, 能承载一个inferface{}

不要单纯地追求程序性能, 底层优化手段可能会对程序正确性产生影响

性能调优实战

性能调优简介

  1. 要依靠数据而不是猜测
  2. 要定位最大瓶颈而不是细枝末节
  3. 不要过早优化
  4. 不要过度优化

性能分析工具 pprof 实战

  1. 希望知道应用在什么地方耗费了多少CPU、Memory,可以用pprof
pprof功能简介

image.png

pprof排查实战

需要先下载代码
"net/http/pprof" 会 自动注册 pprofhandlerhttp server


// main.go
package main

import (
   "log"
   "net/http"
   _ "net/http/pprof" // 自动注册 pprof 的handler 到 http server
   "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) // 开启锁调用跟踪
   runtime.SetBlockProfileRate(1)     // 开启阻塞调用跟踪

   go func() {
      if err := http.ListenAndServe(":6060", nil); err != nil {
         log.Fatal(err)
      }
      os.Exit(0)
   }()

   for {
      for _, v := range animal.AllAnimals {
         v.Live()
      }
      time.Sleep(time.Second)
   }
}

打开pprof的web页面

启动服务器, 并在浏览器中打开http://localhost:6060/debug/pprof/

image.png

排查CPU性能瓶颈

  1. 资源管理器可以看到 CPU占用率43.4%, 这可太高了

image.png

  1. 使用pprof命令: go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"

image.png

  1. flat 当前函数本身的执行耗时
  2. flat% flat占CPU总时间的比例
  3. sum% 上面每一行的flat%总和
  4. cum 当前函数本身加上其调用时间的总耗时
  5. cum% 当前函数本身加上其调用时间的总耗时占CPU总时间的比例

image.png

可以看到, tiger.Eat占首位, 需要优化

  1. Flat == Cum, 函数不调用任何其他函数
  2. Flat == 0, 函数中只有其他函数的调用

使用list命令, 根据指定的正则表达式查找代码行, 查看代码详情

image.png


使用web命令, 调用关系可视化, 也可以看到Eat占用时间最长

image.png


Eat中无意义的循环去除, 重启服务器, 此时CPU占用就正常了

image.png


排查内存瓶颈

程序的内存占用仍然很高, 可以用pprof查一下有没有内存方面的问题

image.png

  1. 执行go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/heap", 这样, 可以在8080端口提供图形化界面, 更方便了

image.png

  1. 图形界面的View也像终端一样, 提供了一些展示方法, 如top

image.png

  1. 可以看到, 是Mouse.Steal占用了大量内存,在中间的搜索框中输入Steal, 选择Source查看代码,这里不断地对做append消耗内存. 同样地, 注释相关代码后恢复正常

image.png

image.png

  1. 程序还有别的内存问题, 可以在页面的Sample页中继续排查, 最后发现, Dog.Run()会申请内存16M内存但是不用, 很快就被释放了.

image.png


Goroutine的问题

如此简单的代码, 竟然有106个Goroutine

image.png

  1. 在终端中输入go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/goroutine", 分析goroutine, 首页给出的调用图太长了, 不太直观, 可以用火焰图

image.png

  1. Flame graph更加直观一些, 每一段小块代表一个函数, 越长占用的时间越多, 可以看到Wolf.Drink()占用的时间最长

image.png

  1. 搜索一下Wolf.Drink(), 开10个协程, 等待30s退出

image.png

  1. 注释掉, 重启, 现在只有 5 个 goroutine 了

image.png


同理可以解决锁的问题, 不再赘述

image.png

image.png

阻塞的解决

image.png

image.png

有一些简单的过滤条件, 全量数据展示不方便定位问题, 如果想看全部的, 直接在首页点进去看就可以

image.png

可以看到, 这个阻塞是和http/server相关的, 无需处理

pprof的采样过程和原理

CPU采样过程

  1. 采样对象: 函数调用和它们占用的时间
  2. 采样率: 100次/s, 固定值
  3. 一共有三个相关角色:进程本身、操作系统和写缓冲。启动采样时,进程向OS注册一个定时器,OS会每隔10ms向进程发送一个SIGPROF信号, 进程接收到信号后就会对当前的调用栈进行记录。 与此同时,进程会启动一个写缓冲的goroutine,它会每隔100ms从进程中读取已经记录的堆栈信息,并写入到输出流。 当采样停止时,进程向OS取消定时器,不再接收信号,写缓冲读取不到新的堆栈时,结束输出。

image.png

堆内存的采样

  1. 通过内存分配器在堆上分配和释放的内存,记录分配/释放的大小和数量, 栈上内存没有弄
  2. 采样率:每分配512KB记录一次,可在运行开头修改,1为每次分配均记录
  3. 采样时间:从程序运行开始到采样时
  4. 采样指标:alloc.space,alloc.objects,.inuse_.space,inuse_objects
  5. 计算方式:inuse=alloc-free

Goroutine的采样

  1. 记录所有用户发起的且在运行中的Goroutine的runtime.main的调用栈信息
  2. ThreadCreate记录程序创建的所有系统线程的信息

阻塞

  1. 采样阻塞操作的次数和耗时(采样争抢锁的次数和耗时)
  2. 采样率:阻塞耗时超过阈值的才会被记录,1为每次阻塞均记录(固定比例的锁操作, 每次加锁均记录)

image.png

性能调优案例

介绍实际业务服务性能优化的案例, 对逻辑相对复杂的程序进行性能调优
1. 业务服务优化
2. 基础库优化
3. Go语言优化

基本概念

  1. 服务:能单独部署,承载一定功能的程序
  2. 依赖:Service A的功能实现依赖Service B的响应结果,称为Service A依赖Service B
  3. 调用链路:能支持一个接口请求的相关服务集合及其相互之间的依赖关系
  4. 基础库:公共的工具包、中间件

image.png

单个服务的优化流程

  1. 建立服务性能评估指标
  • 服务性能评估方式
    • 单独Benchmark无法满足复杂逻辑分析
    • 不同负载情况下性能表现差异
  • 请求流量构造
    • 不同请求参数覆盖逻辑不通
    • 线上真实流量情况
  • 压测 ==> 给出压测报告
    • 单机器压测
    • 集群压测
  • 性能数据采集
    • 单机性能数据
    • 集群性能数据
  1. 分析性能数据, 定位性能瓶颈
  • 使用库不规范, 序列化, 日志
  • 高并发场景优化不足, 不同CPU利用率下的采样数据 可以对比
  1. 重点优化项改造
  • 正确性是基础
  • 线上请求数据录制回放, 新旧逻辑接口数据diff
  1. 优化效果验证
  • 重复压测验证
  • 上线评估优化效果
    • 关注服务监控
    • 逐步放量
    • 收集性能数据

进一步优化, 服务整体链路分析

  • 规范上游服务接口调用, 明确场景需求
  • 分析链路, 通过业务流程优化提升服务性能

基础库优化

  1. 分析基础库的核心逻辑和性能瓶颈
    1. 设计完善改造方案
    2. 数据按需获取
    3. 更高效的数据序列化协议
  2. 内部压测验证
  3. 推广业务服务落地验证

Go语言优化

  1. 编译器&运行时优化
    1. 优化内存分配策略
    2. 优化代码编译流程, 生成更高效的程序
    3. 内部压测验证
    4. 推广业务服务落地验证
  2. 优点 3. 接入简单, 只需要调整编译配置 4. 通用性强

总结

今天主要学习了高质量编程的一些原则, pprof工具的使用以及常见的性能调优方法

引用

  1. 掘金字节内部课:juejin.cn/course/byte…