代码性能优化 | 青训营

104 阅读2分钟

性能优化建议

简介

性能优化的前提是满足正确可靠、简洁清晰等质量因素

性能优化是综合评估,有时候时间效率和空间效率可能对立

针对Go语言特性,介绍Go相关的性能优化建议

性能优化建议-Benchmark工具

如何使用

性能表现需要实际数据衡量

Go语言提供了支持基准性能测试的benchmark工具

示例代码

fib.go

func Fib(n int) int {
    if n < 2 {
        return n
    }
    return Fib(n-1) + Fib(n-2)
}

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 -benchmem -run=^benchBenchmarkFib10 -bench ^BenchmarkFib10

image.png

GOMAXPROCS 1.5版本后,默认值为CPU核数pkg.go.dev/runtime#GOM…

性能优化建议-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)
    }
}

测试文件

package main
​
import (
    "testing"
)
​
func BenchmarkNoPreAlloc(b *testing.B) {
    for n := 0; n < b.N; n++ {
        NoPreAlloc(10)
    }
}
​
func BenchmarkPreAlloc(b *testing.B) {
    for n := 0; n < b.N; n++ {
        PreAlloc(10)
    }
}
​
func BenchmarkFunctions(b *testing.B) {
    b.Run("NoPreAlloc", BenchmarkNoPreAlloc)
    b.Run("PreAlloc", BenchmarkPreAlloc)
}

运行结果

运行指令:go test -benchmem -run=^benchBenchmarkFunctions -bench ^BenchmarkFunctions

image.png

结果中可以看出,使用预分配内存可以有效地提高程序性能

image.png

切片本质是一个数组片段的描述

包括数组指针

片段的长度

片段的容量(不改变内存分配情况下的最大长度)

切片操作并不复制切片指向的元素

创建一个新的切片会复用原来切片的底层数组

另一个陷阱:大内存未释放

在已有切片基础上创建切片,不会创建新的底层数组

场景

原切片较大,代码在原切片基础上新建小切片

原底层数组在内存中有引用,得不到释放

可使用copy代替re-slice

示例代码
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 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))
    }
    printMen(t)
    _ = result
}

运行结果

image.png

性能优化建议-Map

map预分配内存

示例代码

func NoPreAlloc(size int) {
    // map没有预分配内存
    data := make(map[int]int)
    for i := 0; i < size; i++ {
        data[i] = 1
    }
}
​
func PreAlloc(size int) {
    // map有预分配内存
    data := make(map[int]int, size)
    for i := 0; i < size; i++ {
        data[i] = 1
    }
}

测试文件

package main
​
import (
    "testing"
)
​
func BenchmarkNoPreAlloc(b *testing.B) {
    for n := 0; n < b.N; n++ {
        NoPreAlloc(10)
    }
}
​
func BenchmarkPreAlloc(b *testing.B) {
    for n := 0; n < b.N; n++ {
        PreAlloc(10)
    }
}
​
func BenchmarkFunctions(b *testing.B) {
    b.Run("NoPreAlloc", BenchmarkNoPreAlloc)
    b.Run("PreAlloc", BenchmarkPreAlloc)
}

运行结果

运行指令:go test -benchmem -run=^benchBenchmarkFunctions -bench ^BenchmarkFunctions

image.png

结果分析

不断向map中添加元素的操作会触发map的扩容

提前分配好空间可以减少内存拷贝和Rehash的消耗

建议根据实际需求提前预估好需要的空间

性能优化建议-字符串处理

使用strings.Builder

常见的字符串拼接方式

示例代码

// 普通拼接
func Plus(n int, str string) string {
    s := ""
    for i := 0; i < n; i++ {
        s += str
    }
    return s
}
// 使用strings.Builder实现字符串拼接
func StrBuilder(n int, str string) string {
    var builder strings.Builder
    for i := 0; i < n; i++ {
        builder.WriteString(str)
    }
    return builder.String()
}
// 使用bytes.Buffer
func ByteBuffer(n int, str string) string {
    buf := new(bytes.Buffer)
    for i := 0; i < n; i++ {
        buf.WriteString(str)
    }
    return buf.String()
}

测试文件

func BenchmarkPlus(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Plus(20, "10")
    }
}
​
func BenchmarkStrBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        StrBuilder(20, "10")
    }
}
​
func BenchmarkByteBuffer(b *testing.B) {
    for i := 0; i < b.N; i++ {
        ByteBuffer(20, "10")
    }
}
​
func BenchmarkFunctions02(b *testing.B) {
    b.Run("Plus", BenchmarkPlus)
    b.Run("StrBuilder", BenchmarkStrBuilder)
    b.Run("ByteBuffer", BenchmarkByteBuffer)
}

运行结果

运行指令:go test -benchmem -run=^benchBenchmarkFunctions02 -bench ^BenchmarkFunctions02

image.png

使用“+”号拼接性能最差,strings.Builder,bytes.Buffer相近,strings.Buffer更快

分析

字符串在Go语言中是不可变类型,占用内存大小是固定的

使用+每次都会重新分配内存

strings.Builder,bytes.Buffer底层都是[]byte数组

内存扩容策略,不需要每次拼接重新分配内存

bytes.Buffer转化为字符串时重新申请了一块空间

strings.Builder直接将底层的[]byte转换成了字符串类型返回

性能优化建议-空结构体

使用空结构体节省内存

空结构体struct{}实例不占据任何的内存空间

可作为各种场景下的占位符使用

节省资源

空结构体本身具备很强的语义,即这里不需要任何值,仅作为占位符

示例代码

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
    }
}

运行结果

运行指令:go test -benchmem -run=^benchBenchmarkFunctions03 -bench ^BenchmarkFunctions03

image.png

实现Set,可以考虑用map来代替

对于这个场景,只需要用到map的键,而不需要值

即使是将map的值设置为bool类型,也会多占据1个字节空间

性能优化建议-atomic包

如何使用atomic包

// 使用atomic实现加1
type atomicCounter struct {
    i int32
}
​
func AtomicAddOne(c *atomicCounter) {
    atomic.AddInt32(&c.i, 1)
}
//使用锁实现加1
type mutexCounter struct {
    i int32
    m sync.Mutex
}
​
func MutexAddOne(c *mutexCounter) {
    c.m.Lock()
    c.i++
    c.m.Unlock()
}

测试文件

func BenchmarkAtomicAddOne(b *testing.B) {
    counter := &atomicCounter{}
    b.ResetTimer() // 重置计时器// 并发运行加1操作
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            AtomicAddOne(counter)
        }
    })
}
​
func BenchmarkMutexAddOne(b *testing.B) {
    counter := &mutexCounter{}
    b.ResetTimer() // 重置计时器// 并发运行加1操作
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            MutexAddOne(counter)
        }
    })
}
​
func BenchmarkFunctions04(b *testing.B) {
    b.Run("AtomicAddOne", BenchmarkAtomicAddOne)
    b.Run("MutexAddOne", BenchmarkMutexAddOne)
}

运行结果

运行命令:go test -benchmem -run=^benchBenchmarkFunctions04 -bench ^BenchmarkFunctions04

image.png

使用atomic包

锁的实现是通过操作系统来实现,属于系统调用

atomic操作是通过硬件实现,效率比锁高

sync.Mutex应该用来保护一段逻辑,不仅仅用于保护一个变量

对于非数值操作,可以使用atomic.Value,能承载一个interface{}

性能调优实战

性能调优原则

要依靠数据不是猜测

要定位最大瓶颈而不是细枝末节

不要过早优化

不要过度优化

性能分析工具pprof

说明

希望知道应用在什么地方耗费了多少CPU、Memory

pprof是用于可视化和分析性能分析数据的工具

功能简介

image.png

排查实战

搭建pprof实践项目

pprof实战项目源码:github.com/wolfogre/go…

项目提前埋入了一些炸弹代码,产生可观测的性能问题

下载源代码

image.png

编译项目

编译指令:go build

image.png

浏览器查看指标

./go-pprof-practice.exe运行程序

浏览器输入http://localhost:6060/debug/pprof/查看指标

image.png

image.png

CPU

image.png

命令行输入命令:go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"可以进入pprof对于这个项目的工具

image.png

查看占用资源最多的函数,命令top

image.png

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

什么情况下flat==cum? 什么情况下flat==0

image.png

flat == cum, 函数中没有调用其他函数
flat == 0, 函数中只有其他函数的调用

根据指定的正则表达式查找代码行,命令list Eat

image.png

调用关系可视化,命令web

image.png

Heap-堆内存

解决完CPUbug后CPU和内存占用率

image.png

查看堆内存命令:go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/heap"

如果浏览器出现下面的内容,则需要安装graphviz软件,下载地址:graphviz.gitlab.io/

image.png

image.png

Top视图

image.png

Source视图

image.png

解决完bug后的内存占用

image.png

image.png

alloc_objects: 程序累计申请的对象数
alloc_space: 程序累计申请的内存大小
inuse_objects: 程序当前持有的对象数
inuse_space: 程序当前占用的内存大小
goroutine-协程

查看协程命令:go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/goroutine"

火焰图

由上到下表示调用顺序

每一块代表一个函数,越长代表占用CPU的时间越长

火焰图是动态的,支持点击快进行分析

image.png

支持搜索,在Source视图下搜索wolf

image.png

注释问题代码后的结果

image.png

mutex-锁

查看锁命令:go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/mutex"

image.png

image.png

block-阻塞

查看block命令:go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/block"

image.png

image.png