【Go性能优化:Go语言如何进行代码检查和性能优化】

如何进行Go语言的代码检查和优化? 在项目开发中,保证代码质量和性能的手段不只有单元测试和基准测试,还有代码规范检查和性能优化

1.代码规范检查是对单元测试的一种补充,它可以从非业务的层面检查代码是否还有优化的空间,比如变量是否被使用、是否是死代码等。
2.性能优化是通过基准测试来衡量的,这样我们才知道优化部分是否真的提升了程序的性能。

代码规范检查

什么是代码规范检查

​ 代码规范检查,顾名思义,是从Go语言层面出发,依赖Go语言的规范,对代码进行的静态扫描检查,这种检查和业务无关。 比如定义了某个常量,未使用,虽然对代码运行没什么影响,但是为了节省内存,这个常量是可以删除的。这种未使用的常量可以通过代码规范检查检测出来。

​ 在比如调用了一个函数,该函数返回了一个error,但是并没有对error做判断,这种情况下, 程序也可以正常编译运行。但是代码写的不严谨,因为返回的error 被忽略了。如果使用代码规范检查,这类潜在的问题也会被检测出来。除了这两种情况,还有拼写问题、死代码、代码简化检测、命名中带下划线、冗余代码等,都可以使用代码规范检查检测出来。

golangci-lint

​ 要想对代码进行检查,则需要对代码进行扫描,静态分析写的代码是否存在规范问题。可用于Go语言飞马分析的工具有很多,比如golint、gofmt、misspell等,如果一一引用配置,就会很繁琐,所以通常不会单独使用,而是使用 golangci-lint

提示: 静态代码分析是不会运行代码的。

​ golangci-lint 是一个集成工具,它集成了很多静态代码分析工具,便于使用。通过配置这一工具,我们可以很灵活的启动需要的代码规范检查。 要想使用 golangci-lint ,首先要安装。 因为 golangci-lint 本身就是Go语言编写的,所以我们可以从源代码安装,打开终端,输入如下命令即可安装:

go get github.com/golangci/gilangci-lint/cmd/golangci-lint@v1.32.3

​ 使用命令安装的是v1.32.2版本的 golangci-lint,安装完成后,在终端输入如下命令,检测是否安装成功。

golangci-lint version
golangci-lint has version v1.32.2 

提示: 在MacOS下也可以使用brew 来安装golangci-lint。

​ 安装成功后,可以使用它进行代码规范检查了。方法如下:

golangci-lint run test/
// 检测目录中test下的代码,运行

golangci-lint配置

​ golangci-lint配置比较灵活,比如可以自定义要启用哪些 linter。 golangci-lint 默认启用的linter,包括:

deadcode  - 死代码检查
errcheck  - 返回错误是否使用检查
gosimple  - 检查代码是否可以简化
govet     - 代码可疑检查,比如格式化字符串和类型不一致
ineffassign  - 检查是否有未使用的代码
staticcheck  - 静态分析检查
struccheck   - 查找未使用的结构体字段
typecheck    - 类型检查
unused       - 未使用代码检查
varcheck     - 未使用的全局变量和常量检查

提示: golangci-lint 支持的更多linter,可以在终端中输入 golangci-lint 命令查看,并且可以看到每个 linter 的说明。

​ 如果要修改默认启用的 linter,就需要对 golangci-lint 进行配置。即在项目的根目录下创建一个名字为 .golangci.yml 的文件,这就是 golangci-lint 的配置文件。在运行代码规范检查的时候,golangci-lint 会自动使用它,假设只启用 unused 检查,可以这样配置:

linters:
	disable-all: true
	enable:
		- unused

​ 在团队多人协作开发中,有一个固定的 golangci-lint 版本非常重要,这样大家就可以基于同样的标准检查代码。要配置 golangci-lint 使用版本也很简单,在配置文件中添加如下代码:

servcie:
	golangci-lint-version: 1.32.2 // use the fixed version to not introduce new linters unexpectedly

​ 此外,还可以针对每个启用的 linter 进行配置,比如要设置拼写检测语言为US,可以使用如下代码设置,golangci-lint 的配置比较多,可以自己灵活配置。关于golangci-lint 的更多配置可以参考 golangci-lint 官方文档 , 这里给一个常用配置,如下

linters-settings:
	misspell:
		locale: US




linters-settings:
  golint:
    min-confidence: 0
  misspell:
    locale: US
linters:
  disable-all: true
  enable:
    - typecheck
    - goimports
    - misspell
    - govet
    - golint
    - ineffassign
    - gosimple
    - deadcode
    - structcheck
    - unused
    - errcheck
service:
  golangci-lint-version: 1.32.2 # use the fixed version to not introduce new linters unexpectedly

集成 golangci-lint 到CI

代码检查一定要集成到CI流程中,效果才会更好,这样开发者提交代码的时候,CI就会自动检查代码,及时发现问题并纠正。 不管是使用 Jenkins,还是 Gitlab Ci,或者Github Action,都可以通过 Makefiel的方式运行golangci-lint。在项目跟目录下创建一个 Makefile文件,添加如下代码:

getdeps:
   @mkdir -p ${GOPATH}/bin
   @which golangci-lint 1>/dev/null || (echo "Installing golangci-lint" && go get github.com/golangci/golangci-lint/cmd/golangci-lint@v1.32.2)
lint:
   @echo "Running $@ check"
   @GO111MODULE=on ${GOPATH}/bin/golangci-lint cache clean
   @GO111MODULE=on ${GOPATH}/bin/golangci-lint run --timeout=5m --config ./.golangci.yml
verifiers: getdeps lint

性能优化

性能优化的目的是让程序更好、更快的运行,但是它不是必要的,这一点一定要记住。所以在程序开始的时候,不必刻意追求性能优化,先大胆的写代码就好了,写正确的代码是性能优化的前提

堆分配还是栈

​ 在比较古老的C语言中,内存分配是手动申请的,内存释放也需要手动完成。

  • 手动控制有一个很大的好处就是需要多少就申请多杀,可以最大化的利用内存
  • 但是这种方式也有一个明显的缺点,就是如果忘记释放内存,就会导致内存泄露

​ 所以,为了让程序员更专注于业务代码的实现,Go语言增加了垃圾回收机制,自动的回收不在使用的内存。Go语言有两部分内存空间: 栈内存和堆内存

  • 栈内存 由编译器自动分配和回收,开发者无法控制。栈内存一般存储函数中的局部变量、参数等,函数创建的时候,浙西内存会被自动创建,函数返回的时候,这些内存会被自动释放
  • 堆内存 的生命周期比栈内存要长,如果函数返回的值还会在其他地方使用,那么这个值就会被编译期自动分配到堆上。堆内存相比栈内存来说,不能自动被编译器释放,只能通过垃圾回收才能释放,所以栈内存效率会很高

逃逸分析

​ 既然栈内存的效率更高,肯定优先使用栈内存。那么Go语言是如何判断一个变量应该分配在堆上还是栈上呢? 这就需要逃逸分析了。

func newString() *string{
    s := new(string)
    *s = "奔跑的蜗牛"
    return s
}

// 提示: newString 函数是没有意义的,只是为了方便演示。


// 现在通过逃逸分析来看是否发生了逃逸,命令如下;
go build -gcflags="-m -l" ./test/main.go
# command-line-arguments
test/main.go:16:8  new(string) escapes to heap

// 命令中, -m 表示打印出逃逸分析信息, -l 表示禁止内联,可以更好的观察逃逸。从以上输出结果看,发生了逃逸,也就是说指针作为函数返回值都时候,一定会发生逃逸。
1. 通过 new 函数 申请了一块内存
1. 然后把它赋值给了变量s
1. 通过return关键字 返回

​ 逃逸到堆内存的变量不能马上被回收,只能通过垃圾回收标记清除,增加了垃圾回收的压力,所以要尽可能的避免逃逸,让变量分配在栈内存上,这样函数返回时就可以回收资源,提升效率。对上述示例进行优化:

func newString() sting{
    s := new(string)
    *s = "奔跑的蜗牛"
    return *s
}

// 再次执行逃逸分析
go build -gcflags="-m -l" ./test/main.go
# command-line-arguments
test/main.go:16:8  new(string) daes not escape

// 通过结果可以看到,虽然还是声明了指针变量s,但是函数返回的并不是指针,所以没有发生逃逸。

​ 上面就是关于指针作为函数返回逃逸的例子,那么是不是不使用指针就不会发生逃逸了呢? 看下面的例子

fmt.Println("独臂阿童木")

go build -gcflags="-m -l" ./test/main.go
#comman-line-arguments
test/main.go:13:13:  ... argument does not escape
test/main.go:13:14:  "独臂阿童木" escapes to heap
test/main.go:17:8:   new(string) does not escape

// 观察这个结果,发现 “独臂阿童木”字符串逃逸到了堆上,这是因为 “独臂阿童木”这个字符串被已经逃逸的指针变量引用,所以它也跟着逃逸了,引用代码如下:

func (p *pp) printArg(arg interface{}, verb rune){
	p.arg = arg
}

​ 所以被已经逃逸的指针引用的变量也会跟着发生逃逸。在Go语言中有3个比较特殊的类型,它们是slice、map和chan,被这三种类型引用的指针也会发生逃逸,如下

func main(){
    m := map[int]string{}
    s := "奔跑的蜗牛"
    m[0] = &s
}

// 运行逃逸分析,结果如下
go build -gcflags="-m -l" ./test/main.go
#command-line-arguments
test/main.go:14:2: moved to heap: s
test/main.go:13:20 map[int]string literal does not escape

// 从结果看,变量m没有逃逸,反而被变量m引用的变量s 逃逸到了堆上。

所以被map、slice和chan 这三种类型引用的指针一定会发生逃逸的。逃逸分析是判断变量是分配在堆上还是栈上的一种方法,在实际项目中要尽量避免逃逸,这样就不会被GC拖慢速度,从而提升效率。

提示: 从逃逸分析看,指针虽然可以减少内存的拷贝,但它同样会引起逃逸,所以要根据实际情况选择是否使用指针。

优化技巧

​ 我们已经了解了堆内存和栈内存,以及变量何时会逃逸,那么优化的时候思路也就比较清晰了,因为都是基于以上原理进行的。总结几个优化的小技巧:

  1. 首先需要介绍的技巧是尽可能避免逃逸,因为栈内存效率更高,还不用GC。比如对象传单,array要比slice效果好。
  2. 如果避免不了逃逸,还是在堆上费配了内存,那么对于频繁的内存申请操作,我们要学会重用内存,比如使用sync.Pool,这是第二个技巧。
  3. 第三个技巧是选用合适的算法,达到高性能的目的,比如空间换时间。

提示:性能优化的时候,要结合基准测试,来验证优化是否有提升。

​ 以上是基于Go语言的内存管理机制总结出 3个方向的技巧,基于这3个大方向可以优化出想要的效率。除此之外, 还有一些小技巧,比如要尽可能避免使用锁、并发加锁的范围要尽可能小、使用 StringBuilder 做 string 和[]byte 之间的转换、defer嵌套不要太多等。

​ 最后推荐一个Go语言自带的性能剖析工具 pprof, 通过它可以查看CPU分析、内存分析、阻塞分析、互斥锁分析等,它的使用不是太复杂。

总结

​ 主要介绍了代码规范检查和性能优化两部分,其中代码规范检查是从工具使用的角度展开,而性能优化涉及的点太多,所以是从原理的角度展开,明白了原理,才能更好的优化代码。

​ 是否进行性能优化取决于两点:业务需求和自我驱动。所以不要刻意的做性能优化,尤其是不要提前做,先保证代码正确并上线,然后在根据业务需要,决定是否进行优化以及花多少时间优化。自我驱动其实是一种编码能力的体现,比如有经验的开发者在编码的时候,潜意识就避免了逃逸,减少内存拷贝,在高并发的场景中设计低延迟的架构。