这是我参与「第三届青训营 -后端场」笔记创作活动的第4篇笔记。
性能调优
性能优化的前提是满足正确可靠、简洁清晰等质量因素。
性能优化是综合评估,有时候时间效率和空间效率可能对立。
Benchmark
Benchmark 是 Go 提供的基准性能测试工具。
使用命令参考 c.biancheng.net/view/124.ht…
# -bench=. 表示运行 xxx_test.go 文件里的所有基准测试
# -benchmen 显示内存分配情况
go test -bench=. -benchmem
// from fib.go
func Fib(n int) int {
if n < 2 {
return n
}
return Fib(n-1) + Fib(n-2)
}
// from fib_test.go
func BenchmarkFib10(b *testing.B) {
//run the Fib function b.N times
for n := 0; n < b.N; n++ {
Fib(10)
}
}
$> go test -bench="." -benchmem bytedancecourse/src/
goos: windows
goarch: amd64
pkg: bytedancecourse/src
cpu: Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
BenchmarkFib10-8 4262046 283.1 ns/op 0 B/op 0 allocs/op
PASS
ok bytedancecourse/src 1.525s
-
BenchmarkFib10-8:BenchmarkFib10 是测试函数名,-8 表示 GOMAXPROCS 的值为 8
GOMAXPROCS 在 1.5 版本后,默认为 CPU 核数 参考 pkg.go.dev/runtime#GOM…
-
4262046:表示一共执行 4262046 次,即 b.N 的值
-
283.1 ns/op:每次执行花费 283.1 ns
-
0 B/op:每次执行申请多大内存
-
0 allocs/op:每次执行申请几次内存
Slice
使用 Slice 时,尽可能在使用 make() 初始化切片时提供容量信息,进行内存的预分配
看一个例子:
func NoPreAlloc(size int) {
data := make([]int, 0)
for k := 0; k < size; k++ {
data = append(data, k)
}
}
func PreAlloc(size int) {
data := make([]int, 0, size)
for k := 0; k < size; k++ {
data = append(data, k)
}
}
# size 800000000
$> go test -bench="." -benchmem bytedancecourse/src/
goos: windows
goarch: amd64
pkg: bytedancecourse/src
cpu: Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
BenchmarkNoPreAlloc-8 1 70511907000 ns/op 34197152912 B/op 82 allocs/op
BenchmarkPreAlloc-8 1 21504366500 ns/op 6400000024 B/op 4 allocs/op
PASS
ok bytedancecourse/src 93.967s
可以看出,预先分配内存时,每次执行花费的时间,每次申请的内存大小和次数都显著降低了。
为什么差异这么大?这与切片的底层实现有关
切片本质是一个数组片段的描述,结构如下:
type slice struct {
array unsafe.Pointer //数组指针
len int //片段的长度
cap int //片段的容量(当插入遇到不够时会进行扩容,重新分配内存)
}
扩容过程:
扩容过程时非常消耗时间的,所以在初始化切片时,进行与分配内存。
注意:
- 切片操作并不复制切片指向的元素
- 创建一个新的切片会复用原来切片的底层数组
这会导致一个新的问题:大内存未释放
由于在已有切片的基础上创建切片,不会创建新的底层数组。当原切片比较大,代码在原切片的基础新建小切片时,原切片的底层数组在内存中还存在引用,无法被 GC 回收,得不到释放。
解决这个问题,可以使用 copy 替代 re-slice。
看一个例子:
// from tmp.go
func GetLastBySlice(origin []int) []int {
return origin[len(origin)-2:]
}
func GetLastByCopy(origin []int) []int {
result := make([]int, 2)
copy(result, origin[len(origin)-2:])
return result
}
func generateWithCap(n int) []int {
rand.Seed(time.Now().UnixNano())
nums := make([]int, 0, n)
for i := 0; i < n; i++ {
nums = append(nums, rand.Int())
}
return nums
}
func printMem(t *testing.T) {
t.Helper()
var rtm runtime.MemStats
runtime.ReadMemStats(&rtm)
t.Logf("%.2f MB", float64(rtm.Alloc)/1024./1024.)
}
// from tmp_test.go
func testGetLast(t *testing.T, f func([]int) []int) {
result := make([][]int, 0)
for k := 0; k < 100; k++ {
origin := generateWithCap(128 * 1024)
result = append(result, f(origin))
}
printMem(t)
_ = result
}
func TestLastCharsBySlice(t *testing.T) { testGetLast(t, GetLastBySlice) }
func TestLastCharsByCopy(t *testing.T) { testGetLast(t, GetLastByCopy) }
$> go test -run=. -v
=== RUN TestLastCharsBySlice
tmp_test.go:11: 100.17 MB
--- PASS: TestLastCharsBySlice (0.27s)
=== RUN TestLastCharsByCopy
tmp_test.go:11: 1.18 MB
--- PASS: TestLastCharsByCopy (0.29s)
PASS
ok bytedancecourse/src 0.780s
可以看到,使用 copy 只需要 1.18 MB 的内存,而使用 re-slice 的使用了 100.17 MB 内存。
Map
Map 也有预分配的性能优化点。
// from tmp.go
func NoPreAlloc(size int) {
data := make(map[int]int)
for k := 0; k < size; k++ {
data[k] = 1
}
}
func PreAlloc(size int) {
data := make(map[int]int, size)
for k := 0; k < size; k++ {
data[k] = 1
}
}
// from tmp_test.go
func BenchmarkNoPreAlloc(b *testing.B) {
NoPreAlloc(200000000)
}
func BenchmarkPreAlloc(b *testing.B) {
PreAlloc(200000000)
}
$> go test -bench="." -benchmem bytedancecourse/src/
goos: windows
goarch: amd64
pkg: bytedancecourse/src
cpu: Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
BenchmarkNoPreAlloc-8 1 45518223300 ns/op 11916524872 B/op 7805704 allocs/op
BenchmarkPreAlloc-8 1 28873591000 ns/op 5752119240 B/op 2897363 allocs/op
PASS
ok bytedancecourse/src 75.698s
从结果可以看出,预分配内存的各项指标都优于没有预分配内存。
原因分析:
- 不断向 map 中添加元素的操作会触发 map 的扩容
- 提前分配好空间可以减少内存拷贝和 Rehash 的消耗
所以尽可能提前预估好需要的空间,进行预分配。
字符串处理
在平时使用中,字符串的拼接不建议用 + 。
// from tmp.go
func Plus(n int, str string) string {
s := ""
for i := 0; i < n; i++ {
s += str
}
return s
}
func StrBuilder(n int, str string) string {
var builder strings.Builder
for i := 0; i < n; i++ {
builder.WriteString(str)
}
return builder.String()
}
func ByteBuffer(n int, str string) string {
buf := new(bytes.Buffer)
for i := 0; i < n; i++ {
buf.WriteString(str)
}
return buf.String()
}
// from tmp_test.go
func BenchmarkPlus(b *testing.B) {
Plus(1000000, "hello")
}
func BenchmarkStrBuilder(b *testing.B) {
StrBuilder(1000000, "hello")
}
func BenchmarkByteBuffer(b *testing.B) {
ByteBuffer(1000000, "hello")
}
$> go test -bench="." -benchmem bytedancecourse/src/
goos: windows
goarch: amd64
pkg: bytedancecourse/src
cpu: Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
BenchmarkPlus-8 1 54521057300 ns/op 401620455680 B/op 416090 allocs/op
BenchmarkStrBuilder-8 1000000000 0.002991 ns/op 0 B/op 0 allocs/op
BenchmarkByteBuffer-8 1000000000 0.002990 ns/op 0 B/op 0 allocs/op
PASS
ok bytedancecourse/src 54.767s
可以看出,使用 + 拼接性能最差,strings.Builder,bytes.Buffer 相近,但 strings.Buffer 更快。
原因分析:
- 字符串在 Go 是不可变类型,占用内存大小是固定的,使用
+每次都会重新分配内存 - strings.Builder 和 bytes.Buffer 底层都是使用 []bytes 数组,在内部维护了内存扩容策略,不需要每次拼接重新分配内存
在实际应用中,更推荐使用 strings.Buffer 。因为 bytes.Buffer 转化为字符串时重新申请了一块空间,而 strings.Buffer 直接将底层 []bytes 数组转换成了字符串类型返回。
// bytes.Buffer
// To build strings more efficiently, see the strings.Buffer type
func (b *Buffer) String() string {
if b == nil {
// Special case, useful in debugging.
return "<nil>"
}
return string(b.buf[b.off:])
}
// String returns the accumulated string.
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}
如果可以预估字符串拼接后的长度,可以预分配内存进一步提高效率。
// from tmp.go
func PreStrBuilder(n int, str string) string {
var builder strings.Builder
builder.Grow(n * len(str))
for i := 0; i < n; i++ {
builder.WriteString(str)
}
return builder.String()
}
func PreByteBuffer(n int, str string) string {
buf := new(bytes.Buffer)
buf.Grow(n * len(str))
for i := 0; i < n; i++ {
buf.WriteString(str)
}
return buf.String()
}
// from tmp_test.go
const str = "hello"
const n = 400000
func BenchmarkPreStrBuilder(b *testing.B) {
PreStrBuilder(n, str)
}
func BenchmarkPreByteBuffer(b *testing.B) {
PreByteBuffer(400000, "hello")
}
$> go test -bench="." -benchmem bytedancecourse/src/
goos: windows
goarch: amd64
pkg: bytedancecourse/src
cpu: Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
BenchmarkPlus-8 1 50508202100 ns/op 401620667232 B/op 418312 allocs/op
BenchmarkStrBuilder-8 1000000000 0.001988 ns/op 0 B/op 0 allocs/op
BenchmarkByteBuffer-8 1000000000 0.003001 ns/op 0 B/op 0 allocs/op
BenchmarkPreStrBuilder-8 1000000000 0.001717 ns/op 0 B/op 0 allocs/op
BenchmarkPreByteBuffer-8 1000000000 0.002678 ns/op 0 B/op 0 allocs/op
PASS
ok bytedancecourse/src 50.752s
可以看到,无论是 strings.builder 还是 bytes.buffer ,预分配内存都由于没有预分配内存的
空结构体
空结构体 struct{} 实例不占据任何内存空间,可作为各种场景下的占位符使用。
这样可以节省资源,且空结构体本身具有很强的语义,即这里不需要任何值,仅作为占位符。
// from tmp.go
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
}
}
// from tmp_test.go
func BenchmarkEmptyStructMap(b *testing.B) {
EmptyStructMap(10000000)
}
func BenchmarkBoolMap(b *testing.B) {
BoolMap(10000000)
}
$> go test -bench="." -benchmem bytedancecourse/src/
goos: windows
goarch: amd64
pkg: bytedancecourse/src
cpu: Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
BenchmarkEmptyStructMap-8 1 1367137100 ns/op 402392336 B/op 306036 allocs/op
BenchmarkBoolMap-8 1 1347185100 ns/op 442902624 B/op 306176 allocs/op
PASS
ok bytedancecourse/src 2.974s
空结构体可以用于实现 Set,只需要把 map 的值设为空结构体即可。
Set 的开源实现:github.com/deckarep/go…
atomic 包
在一些多线程编程的场景时,使用 atomic 维护一个变量,使用 mutex 维护一段逻辑。
// from tmp.go
type AtomicCounter struct {
i int32
}
func AtomicAddOne(c *AtomicCounter) {
atomic.AddInt32(&c.i, 1)
}
type MutexCounter struct {
i int32
m sync.Mutex
}
func MutexAddOne(c *MutexCounter) {
c.m.Lock()
c.i++
c.m.Unlock()
}
// from tmp_test.go
func BenchmarkAtomicAddOne(b *testing.B) {
c := AtomicCounter{i: 0}
for i := 0; i < 1000000; i++ {
go func() {
AtomicAddOne(&c)
}()
}
}
func BenchmarkMutexAddOne(b *testing.B) {
c := MutexCounter{
i: 0,
m: sync.Mutex{},
}
for i := 0; i < 1000000; i++ {
go func() {
MutexAddOne(&c)
}()
}
}
$> go test -bench="." -benchmem bytedancecourse/src/
goos: windows
goarch: amd64
pkg: bytedancecourse/src
cpu: Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
BenchmarkAtomicAddOne-8 1000000000 0.2537 ns/op 0 B/op 0 allocs/op
BenchmarkMutexAddOne-8 1000000000 0.2628 ns/op 0 B/op 0 allocs/op
PASS
ok bytedancecourse/src 7.894s
可以看出,使用 atomic 要比 mutex 好。
因为锁的实现是通过操作系统来实现的,属于系统调用,而 atomic 操作是通过硬件实现的,效率比锁高。
对于非数值操作,可以使用 atomic.Value,能承载一个 interface{}
小结
- 避免常见的性能陷阱可以保证大部分程序的性能
- 普通应用代码,不要一味地追求性能
- 越高级的性能优化手段越容易出现问题
- 在满足正确可靠、简洁清晰的质量要求前提下提高程序性能