Golang
高性能技法
常用数据结构
优先使用
strconv
包而不是fmt
包基本数据类型与字符串之间的转换,优先使用
strconv
包 而不是fmt
包,因为fmt
使用了反射在运行时进行类型判断,性能有所损耗。可以使用少量的重复代码
少量的重复代码,优于使用反射。这样就避免了反射涉及的类型判断和大量内存分配。
慎用
binary.Read
和binary.Write
binary.Read 和 binary.Write 使用反射并且很慢。如果有需要用到这两个函数的地方,我们应该手动实现这两个函数的相关功能,而不是直接去使用它们。
避免多次字符串到字节切片的转换
不要反复从固定字符串创建字节
slice
,因为重复的切片初始化会带来性能损耗。相反,请执行一次转换并捕获结果。指定容器容量
尽可能指定容器容量,以便为容器预先分配内存。这将在后续添加元素时减少通过复制来调整容器大小。
make(map[T1]T2, hint)
和make([]T, length, capacity)
。行内拼接字符串
推荐使用运算符
+
而不是fmt.Sprintf()
,除非涉及类型转换的变量较多。非行内拼接字符串
字符串拼接还有其他的方式,比如
strings.Join()
、strings.Builder
、bytes.Buffer
和[]byte
,这几种不适合行内使用。当待拼接字符串数量较多时可考虑使用。如果结果字符串的长度是可预知的,使用
[]byte
且预先分配容量的拼接方式性能最佳。gopackage strc import ( "bytes" "strings" "testing" ) var ( s1 = "foo" s2 = "bar" s3 = "baz" ) // strings.Join func BenchmarkJoinStrWithStringsJoin(b *testing.B) { for i := 0; i < b.N; i++ { _ = strings.Join([]string{s1, s2, s3}, "") } } // strings.Builder func BenchmarkJoinStrWithStringsBuilder(b *testing.B) { for i := 0; i < b.N; i++ { var builder strings.Builder builder.WriteString(s1) builder.WriteString(s2) builder.WriteString(s3) } } // 预分配内存的 strings.Builder func BenchmarkJoinStrWithStringsBuilderPreAlloc(b *testing.B) { for i := 0; i < b.N; i++ { var builder strings.Builder builder.Grow(9) builder.WriteString(s1) builder.WriteString(s2) builder.WriteString(s3) } } // strings.Buffer func BenchmarkJoinStrWithBytesBuffer(b *testing.B) { for i := 0; i < b.N; i++ { var buffer bytes.Buffer buffer.WriteString(s1) buffer.WriteString(s2) buffer.WriteString(s3) } } // []byte 拼接 func BenchmarkJoinStrWithByteSlice(b *testing.B) { for i := 0; i < b.N; i++ { var bs []byte bs = append(bs, s1...) bs = append(bs, s2...) bs = append(bs, s3...) } } // 预分配内存的 []byte 拼接 func BenchmarkJoinStrWithByteSlicePreAlloc(b *testing.B) { for i := 0; i < b.N; i++ { bs := make([]byte, 0, 9) bs = append(bs, s1...) bs = append(bs, s2...) bs = append(bs, s3...) } }
Benchmark 结果:
textgoarch: amd64 pkg: code.lmcw.art/example/strc cpu: AMD A10-7870K Radeon R7, 12 Compute Cores 4C+8G BenchmarkJoinStrWithStringsJoin BenchmarkJoinStrWithStringsJoin-4 14803089 78.17 ns/op BenchmarkJoinStrWithStringsBuilder BenchmarkJoinStrWithStringsBuilder-4 11825329 100.7 ns/op BenchmarkJoinStrWithStringsBuilderPreAlloc BenchmarkJoinStrWithStringsBuilderPreAlloc-4 20037103 62.86 ns/op BenchmarkJoinStrWithBytesBuffer BenchmarkJoinStrWithBytesBuffer-4 11668932 107.4 ns/op BenchmarkJoinStrWithByteSlice BenchmarkJoinStrWithByteSlice-4 12921136 93.53 ns/op BenchmarkJoinStrWithByteSlicePreAlloc BenchmarkJoinStrWithByteSlicePreAlloc-4 132355752 8.915 ns/op PASS
使用 range 遍历 slice 或者 array 时,只迭代下标并使用下标访问元素。
gos := []string{s1, s2, s3} for i := range s { fmt.Println(s[i]) }
内存管理
使用空结构体节省内存
go// 空结构体不占内存空间 fmt.Println(unsafe.Sizeof(struct{}{})) // 不发送数据的信道 ch := make(chan struct{}) go doSomething(ch) ch <- struct{}{} close(ch) // 仅包含方法的结构体 type Door struct{} func (d Door) Open() { fmt.Println("Open the door") } func (d Door) Close() { fmt.Println("Close the door") }
struct 内存对齐
CPU 访问内存时,并不是逐个字节访问,而是以字长(word size)为单位访问。比如 32 位的 CPU ,字长为 4 字节,那么 CPU 访问内存的单位也是 4 字节。
这么设计的目的,是减少 CPU 访问内存的次数,加大 CPU 访问内存的吞吐量。比如同样读取 8 个字节的数据,一次读取 4 个字节那么只需要读取 2 次。
CPU 始终以字长访问内存,如果不进行内存对齐,很可能增加 CPU 访问内存的次数。
Go 内存对齐规则:
- 对于任意类型的变量
x
,unsafe.Alignof(x)
至少为1
。 - 对于结构体类型的变量
x
,计算 x 每一个字段f
的unsafe.Alignof(x.f)
,unsafe.Alignof(x)
等于其中的最大值。 - 对于数组类型的变量
x
,unsafe.Alignof(x)
等于构成数组的元素类型的对齐系数。
其中函数
unsafe.Alignof
用于获取变量的对齐系数。对齐系数决定了字段的偏移和变量的大小,两者必须是对齐系数的整数倍。合理的 struct 布局:
gotype demo1 struct { a int8 b int16 c int32 } type demo2 struct { a int8 c int32 b int16 } func main() { fmt.Println(unsafe.Sizeof(demo1{})) // 8 fmt.Println(unsafe.Sizeof(demo2{})) // 12 }
空结构与空数组对内存对齐的影响:
空结构与空数组在 Go 中比较特殊。没有任何字段的空
struct{}
和没有任何元素的array
占据的内存空间大小为0
。因为这一点,空
struct{}
或空array
作为其他struct
的字段时,一般不需要内存对齐。但是有一种情况除外:即当struct{}
或空array
作为结构体最后一个字段时,需要内存对齐。因为如果有指针指向该字段,返回的地址将在结构体之外,如果此指针一直存活不释放对应的内存,就会有内存泄露的问题(该内存不因结构体释放而释放)。gotype demo3 struct { a struct{} b int32 } type demo4 struct { b int32 a struct{} } func main() { fmt.Println(unsafe.Sizeof(demo3{})) // 4 fmt.Println(unsafe.Sizeof(demo4{})) // 8 }
- 对于任意类型的变量
减少逃逸,将变量限制在栈上
变量逃逸一般发生在如下几种情况:
- 变量较大
- 变量大小不确定
- 变量类型不确定
- 返回指针
- 返回引用
- 闭包
sync.Once
延迟初始化功能,可以用于提高系统启动的速度,同时还能保证只初始化一次,可以用于数据库连接池等场景。
var (
dbOnce sync.Once
dbClient *gorm.DB
)
func DB() (*gorm.DB, error) {
var err error
dbOnce.Do(func() {
l := logger.New(
logrus.NewWriter(),
logger.Config{
SlowThreshold: time.Millisecond,
Colorful: false,
LogLevel: logger.Info,
},
)
dbClient, err = gorm.Open(mysql.Open(consts.MySQLDefaultDSN),
&gorm.Config{
PrepareStmt: true,
Logger: l,
},
)
if err == nil {
err = dbClient.Use(tracing.NewPlugin())
}
})
return dbClient, err
}
sync.Pool
对象复用。如:json 的反序列化在文本解析和网络通信过程中非常常见,当程序并发度非常高的情况下,短时间内需要创建大量的临时对象。而这些对象是都是分配在堆上的,会给 GC 造成很大压力,严重影响程序的性能。
sync.Pool 的大小是可伸缩的,高负载时会动态扩容,存放在池中的对象如果不活跃了会被自动清理。
假设我们有个对外开放的接口,每次接收到来自第三方的请求后,需要解析请求体并进行签名验证。这个过程中,请求体参数则会频繁的创建销毁。
type body struct {
Appid string `json:"appid"`
SignType string `json:"sign_type"` // RSA2 | SM2
Nonce string `json:"nonce"`
Timestamp string `json:"timestamp"`
Version string `json:"version"`
Data string `json:"data"`
Sign string `json:"sign"`
}
var bodyPool = sync.Pool{
New: func() interface{} {
return &body{}
},
}
func Sign() app.HandlerFunc {
return func(ctx context.Context, c *app.RequestContext) {
// 解析请求体
body := bodyPool.Get().(*body)
defer bodyPool.Put(body)
if err := json.Unmarshal(c.GetRawData(), body); err != nil {
c.JSON(consts.StatusOK, utils.H{
"code": response.CodeBadRequest,
"msg": "请求体解析失败",
})
c.Abort()
return
}
// 验证签名
}
}
singleflight
重复的函数调用抑制机制。即将相同的并发请求合并成一个请求,以减少下游压力。常用于缓存击穿。当大量请求查询同一个共享资源,而这个共享资源正好失效,则所有的请求都会打到数据库,导致数据库的负载陡然上升。
import (
"context"
"golang.org/x/sync/singleflight"
)
type Material struct{}
var sfg singleflight.Group
func Mat(ctx context.Context, mchtId string) (Material, error) {
// 从本地缓存中获取商户资料
mat, err := local_cache.Get(ctx, mchtId)
if err != nil {
return Material{}, err
}
if mat != nil {
return mat, nil
}
// 从redis缓存中获取商户资料
mat, err = redis_cache.Get(ctx, mchtId)
if err != nil {
return Material{}, err
}
if mat != nil {
return mat, nil
}
mat, err, _ = sfg.Do(mchtId, func() (interface{}, error) {
// 从数据库中获取商户资料
mat, err := db.Get(ctx, mchtId)
// 回写本地缓存和redis缓存
local_cache.Set(ctx, mchtId, mat)
redis_cache.Set(ctx, mchtId, mat)
return mat, err
})
return mat, err
}
当
Do
操作存在超时或阻塞的情况时,使用DoChan
是更好的选择。如果在失败时需要更多的尝试机会,可以另开 goroutine 定时
Forget
。
测试
竞态检测及代码覆盖率
$ go test -race -cover -coverprofile=./coverage.out -timeout=10m -short -v ./...
# 结果命令行输出
$ go tool cover -func ./coverage.out
# 结果以HTML形式输出
$ go tool cover -html=coverage.out -o coverage.html
基准测试
以字符串拼接函数作为示例。创建 strcontact_test.go
。
// + 连接
func BenchmarkPlus(b *testing.B) {
s := ""
for n := 0; n < b.N; n++ {
s += "hello world"
}
s = ""
}
// bytes buffer
func BenchmarkBytesBuffer(b *testing.B) {
var buf bytes.Buffer
for i := 0; i < b.N; i++ {
buf.WriteString("hello world")
}
_ = buf.String()
}
// strings builder
func BenchmarkStringsBuilder(b *testing.B) {
var sb strings.Builder
for i := 0; i < b.N; i++ {
sb.WriteString("hello world")
}
_ = sb.String()
}
// 预分配内存
func BenchmarkPreAlloc(b *testing.B) {
str := "hello world"
buf := make([]byte, 0, b.N*len(str))
for i := 0; i < b.N; i++ {
buf = append(buf, str...)
}
}
// strings builder 预分配内存
func BenchmarkStringsBuilderPreAlloc(b *testing.B) {
str := "hello world"
var sb strings.Builder
sb.Grow(b.N * len(str))
for i := 0; i < b.N; i++ {
sb.WriteString(str)
}
_ = sb.String()
}
使用 go test
命令测试:
# 测试当前目录下的所有基准测试
go test -bench .
# 递归测试当前目录及下级目录下的所有基准测试
go test -bench ./...
# 使用正则表达式匹配测试用例
go test -bench='StringsBuilder' .
解释
benchmark 用例的参数 b *testing.B
,有个属性 b.N
表示这个用例需要运行的次数。b.N
对于每个用例都是不一样的。
那这个值是如何决定的呢?b.N
从 1 开始,如果该用例能够在 1s 内完成,b.N
的值便会增加,再次执行。b.N
的值大概以 1, 2, 3, 5, 10, 20, 30, 50, 100 这样的序列递增,越到后面,增加得越快。
一个测试的输出类似下面这样:
goos: linux
goarch: amd64
pkg: gitee.com/dlpay/strcontact
cpu: 13th Gen Intel(R) Core(TM) i7-13700K
BenchmarkPlus-24 335173 326902 ns/op
BenchmarkBytesBuffer-24 71131600 14.29 ns/op
BenchmarkStringsBuilder-24 143914178 10.28 ns/op
BenchmarkPreAlloc-24 495671646 4.749 ns/op
BenchmarkStringsBuilderPreAlloc-24 1000000000 1.268 ns/op
PASS
ok gitee.com/dlpay/strcontact 117.193s
BenchmarkPlus-24
中的 -24
表示 GOMAXPROCS
,即使用了 24 个 CPU 核心数来测试, 使用 -cpu
即可改变 GOMAXPROCS
,同时 -cpu
也支持传入列表,如:-cpu=2,4
。使用 go test -cpu=2,4 -bench .
的输出如下:
goos: linux
goarch: amd64
pkg: gitee.com/dlpay/strcontact
cpu: 13th Gen Intel(R) Core(TM) i7-13700K
BenchmarkPlus-2 389607 182764 ns/op
BenchmarkPlus-4 236176 155908 ns/op
BenchmarkBytesBuffer-2 89882253 12.49 ns/op
BenchmarkBytesBuffer-4 100000000 12.41 ns/op
BenchmarkStringsBuilder-2 251099154 10.52 ns/op
BenchmarkStringsBuilder-4 254379927 6.826 ns/op
BenchmarkPreAlloc-2 491578856 2.957 ns/op
BenchmarkPreAlloc-4 518177994 2.692 ns/op
BenchmarkStringsBuilderPreAlloc-2 952742638 1.129 ns/op
BenchmarkStringsBuilderPreAlloc-4 982389376 1.134 ns/op
PASS
ok gitee.com/dlpay/strcontact 121.870s
上述输出中的 BenchmarkPlus-2 389607 182764 ns/op
中 389607
表示代码被测试了 389607 次,182764
表示平均每次耗时 182764 纳秒。由此可以看出 StringsBuilderPreAlloc
具有最好的性能。
微调测试参数
Benchmark 的默认时间是 1s,那么我们可以使用 -benchtime
指定为 5s。-benchtime
的值除了是时间外,还可以是具体的次数,执行 30 次可以用 -benchtime=30x
。
内存分配
-benchmem
参数可以度量内存分配的次数。内存分配次数也性能也是息息相关的,例如不合理的切片容量,将导致内存重新分配,带来不必要的开销。例如 go test -cpu=4 -benchmem -run=^$ -bench ^BenchmarkPlus$
的输出:
goos: linux
goarch: amd64
pkg: gitee.com/dlpay/strcontact
cpu: 13th Gen Intel(R) Core(TM) i7-13700K
BenchmarkPlus-4 369964 218001 ns/op 2038881 B/op 1 allocs/op
PASS
ok gitee.com/dlpay/strcontact 80.687s
1 allocs/op
表示执行了一次内存分配。
准备与清理工作
如果在 Benchmark 开始前,需要一些准备工作,如果准备工作比较耗时,则需要将这部分代码的耗时忽略掉。
func BenchmarkPay(b *testing.B) {
for n := 0; n < b.N; n++ {
time.Sleep(time.Second * 3) // 模拟支付前的一系列预处理操作
b.ResetTimer() // 重置定时器
Pay() // 执行支付操作
}
}
func BenchmarkPay(b *testing.B) {
for n := 0; n < b.N; n++ {
b.StopTimer()
playload := MixOps() // 模拟支付前的一系列预处理操作
b.StartTimer()
Pay(playload) // 执行支付操作
}
}
工具
go-global-update
更新全局安装的 go 可执行程序的工具。
安装:
go install github.com/Gelio/go-global-update@latest
使用:
go-global-update
# 模拟更新,不实际更新
go-global-update --dry-run
# 指定要更新的命令
go-global-update gofumpt
Govulncheck
GO项目漏洞检查工具,更多详情参考GO官方文章。
安装:
go install golang.org/x/vuln/cmd/govulncheck@latest
使用,在 go.mod 目录中执行:
govulncheck ./...
golangci-lint
GO语言静态代码检测工具,更多用法参考官方网站
安装
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
使用
golangci-lint run ./...
.golangci.yaml 示例
run:
concurrency: 4
timeout: 5m
skip-dirs:
- idl
skip-dirs-use-default: true
tests: true
modules-download-mode: readonly
allow-parallel-runners: true
output:
format: colored-line-number
print-issued-lines: true
print-linter-name: true
uniq-by-line: false
sort-results: true
linters-settings:
# See the dedicated "linters-settings" documentation section.
linters:
disable-all: true
enable:
- errcheck
- gosimple
- gofumpt
- govet
- ineffassign
- staticcheck
- typecheck
- unused
- whitespace
- misspell
- goimports
- unparam
- gosec
- nakedret
- lll
- goconst
- gocritic
- dupl
- gocognit
- goheader
- gocyclo
- unconvert
- gosec
- bodyclose
- containedctx
- contextcheck
- cyclop
- decorder
- dupl
- durationcheck
- errchkjson
- errname
- errorlint
- execinquery
- exhaustive
- exhaustruct
- exportloopref
- forbidigo
- forcetypeassert
- funlen
- gocheckcompilerdirectives
- gochecknoinits
- gocognit
- goconst
- gocritic
- gosec
- loggercheck
- makezero
- wsl
- wrapcheck
- whitespace
- unconvert
issues:
# See the dedicated "issues" documentation section.
severity:
# See the dedicated "severity" documentation section.
cobra-cli
快速创建GO语言命令行程序。
安装
go install github.com/spf13/cobra-cli@latest
初始化应用程序
cd myapp
go mod init gitee.com/dlpay/myapp
cobra-cli init
添加命令
cobra-cli add serve
定义生成模板
vi ~/.cobar.yaml
添加如下内容:
author: BiLuoHui <biluohui@163.com>
license: MIT
useViper: true
自定义License及文件头:
author: BiLuoHui <biluohui@163.com>
year: 2023
license:
header: This file is part of CLI application MyApp.
text: |
{{ .copyright }}
This is my license. There are many like it, but this one is mine.
My license is my best friend. It is my life. I must master it as I must
master my life.