简介
- 介绍golang性能优化的工具
- 总结优化思路
- 性能优化方法
1. 性能分析工具
benchmark基准测试
Go 内置的测试框架:对某一段代码进行性能测试
func BenchmarkxxxFuncName(b *testing.B) {
for i := 0; i < b.N; i++ {
doSomething()
}
}
go test -bench='xxxFuncName' -benchtime=100x(100s) -count=3 -cup=2,4 -benchmem <module name>/<package name>
//参数
-bench:基准测试
-benchtime:测试测试/测试时间
-count:循环次数
-cup:设置GOMAXPROCS
-benchmem:内存分配
//结果
BenchmarkString_Normal-8 15339685 78.41 ns/op 144 B/op 3 allocs/op
pprof
线上服务性能分析, 在项目中引入 net/http/pprof
包, pprof包提供了采集指标方法,需要把这些方法注册到路由中。默认注册路径如下(也可以自己注册)
安装可视化工具 graphviz
// 采样文件可视化
go tool pprof -http=:8000 xxx文件
线上采样:
浏览器访问 ip:port/debug/prof/ (下载)
go tool pprof -http=:8000 http://127.0.0.1:6060/debug/pprof/profile
分析:
- profile: cpu使用情况的采样信息
- allocs: 内存分配情况的抽象情况
- block: 阻塞堆栈的采样信息
- cmdline: 程序启动命令及其参数
- goroutine: 当前协程的堆栈信息
- heap: 堆内存的采样信息
- mutex: 锁竞争的采样信息
- threadcreate: 系统程序创建情况的采样信息
- trace: 程序运行的跟踪信息
2 问题排查思路
如何保证请求的响应时间,推荐参考文章:The Tail at Scale
3. 优化方法
3.1 基本数据结构
slice
struct {
ptr *[]T
len int
cap int
}
1、append操作,当容量不够,会导致切片扩容,重新申请内存,进行值拷贝。在使用切片时,要初始化容量
2、原切片由大量的元素构成,但是我们在原切片的基础上切片,虽然只使用了很小一段,但底层数组在内存中仍然占据了大量空间,得不到释放。比较推荐的做法,使用 copy
替代 re-slice
t1 := []int{1,2,3,4,5,6,7,8,9}
//re-slice
t2 := t1[:2]
// copy
t3 := make([]int, 2)
copy(t3, t1[:2])
map
golang map的底层实现为哈希桶,如何实现缩容
?
扩容条件:
- 1、存储的键值对数量过多 (负载因子>6.5)
- 2、溢出桶数量过多(哈希表里有过多的空键值对)
map伪缩容,内存不会缩容
。所以要手动对map缩容:创建一个新的 map 并从旧的 map 中复制元素
for-range
type Item struct {
id int
val [4096]byte
}
// 测试for
func BenchmarkForRangeFor(b *testing.B) {
var items [1024]Item
for i := 0; i < b.N; i++ {
length := len(items)
var tmp int
for k := 0; k < length; k++ {
tmp = items[k].id
}
_ = tmp
}
}
// 测试for k := range
func BenchmarkForRangeIndex(b *testing.B) {
var items [1024]Item
for i := 0; i < b.N; i++ {
var tmp int
for k := range items {
tmp = items[k].id
}
_ = tmp
}
}
// 测试for _, val := range
func BenchmarkForRangeValue(b *testing.B) {
var items [1024]Item
for i := 0; i < b.N; i++ {
var tmp int
for _, item := range items {
tmp = item.id
}
_ = tmp
}
}
//运行结果 go test -bench='ForRange' .
goos: darwin
goarch: arm64
pkg: tfile/example
BenchmarkForRangeFor-8 3340029 330.0 ns/op
BenchmarkForRangeIndex-8 3635565 329.6 ns/op
BenchmarkForRangeValue-8 7222 166750 ns/op
for k, v := range []slice 会对切片进行值拷贝。在大对象切片遍历时,使用 for k := range []slice方式
[]byte与string转换
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
type StringHeader struct {
Data uintptr
Len int
}
// string转[]byte, 再由[]byte转string
func BenchmarkString_Normal(b *testing.B) {
for i := 0; i < b.N; i++ {
s := fmt.Sprintf("123456789098765432101111111111111111111111111111")
bs := []byte(s)
bs[0] = '2'
s = string(bs)
}
}
// 通过unsafe 指针转换
func BenchmarkString_Direct2(b *testing.B) {
for i := 0; i < b.N; i++ {
s := fmt.Sprintf("123456789098765432101111111111111111111111111111")
bs := *(*[]byte)(unsafe.Pointer(&s))
bs[0] = '2'
s = *(*string)(unsafe.Pointer(&bs))
}
}
//运行结果 go test -bench='String' -benchmem .
goos: darwin
goarch: arm64
pkg: tfile/example
BenchmarkString_Normal-8 15339685 78.41 ns/op 144 B/op 3 allocs/op
BenchmarkString_Direct2-8 23584944 48.39 ns/op 48 B/op 1 allocs/op
扩展
:如果把s = fmt.Sprintf("abc") 替换为 s="abc"会出现什么样的结果呢?
func BenchmarkString_Direct_Const(b *testing.B) {
for i := 0; i < b.N; i++ {
// s = fmt.Sprintf() -> "xxxx"
s := "123456789098765432101111111111111111111111111111"
bs := *(*[]byte)(unsafe.Pointer(&s))
bs[0] = '2'
s = string(bs)
}
}
//运行结果
unexpected fault address 0x104662bca
fatal error: fault
[signal SIGBUS: bus error code=0x1 addr=0x104662bca pc=0x104659788]
goroutine 7 [running]:
runtime.throw({0x10465a0e2?, 0x1045a1648?})
/usr/local/go/src/runtime/panic.go:992 +0x50 fp=0x14000045e80 sp=0x14000045e50 pc=0x1045a69a0
runtime.sigpanic()
/usr/local/go/src/runtime/signal_unix.go:815 +0x124 fp=0x14000045eb0 sp=0x14000045e80 pc=0x1045bc184
tfile/example.BenchmarkString_Direct_Const(0x1400012c240)
- string字符串常量会在编译期分配到只读段,对应数据地址不可写入
- fmt.Sprintf生成的字符串分配在堆上,对应数据地址可修改。
扩展
:为什么string常量字符串
要设计为不可修改的?
func TestConstStr(t *testing.T) {
for i := 0; i < 3; i++ {
print(i)
}
}
func print(i int) {
s1 := "aaa"
s2 := fmt.Sprintf("%v", "aaa")
fmt.Printf("第%v次测试, s1: %v, s2:%v \n",
i,
*(*reflect.StringHeader)(unsafe.Pointer(&s1)),
*(*reflect.StringHeader)(unsafe.Pointer(&s2)),
)
}
// 运行结果 go test -v -gcflags='-N' const_test.go
=== RUN TestConstStr
第0次测试, s1: {4334591716 3}, s2:{1374389633320 3}
第1次测试, s1: {4334591716 3}, s2:{1374389633323 3}
第2次测试, s1: {4334591716 3}, s2:{1374389633392 3}
常量字符串只分配一次,并且不可修改,是并发安全的
字符串拼接
常见的拼接方式:
// +
s := "a"
s1 := "b"
s += s1
// fmt.Sprintf
s = fmt.Sprintf("%v%v", s, s1)
// bytes.Buffer
s := "a"
buf := bytes.NewBuffer([]byte(s))
s1 := "b"
buf.WriteString(s1)
// strings.Builder
ss := new(strings.Builder)
ss.WriteString(s)
ss.WriteString(s1)
+
拼接 2 个字符串时,申请的新空间的大小是原来两个字符串的大小之和。strings.Builder
,bytes.Buffer
,都是基于[]byte实现的。切片[]byte
的扩容时是以倍数申请的(预分配),通常多个字符串拼接的时候性能更好一些。strings.Builder
直接将底层的[]byte
通过指针转换成了字符串类型,减少了一次[]byte到string值拷贝
反射
在某项目中,存在使用反射给结构体赋值的场景,其中反射FieldByName占CPU使用率的20%左右。结合反射源码可以得出,对于每个字段,都根据name进行一次遍历获取index。对应大结构体赋值,非常消耗性能。
// FieldByName returns the struct field with the given name.
// It returns the zero Value if no field was found.
// It panics if v's Kind is not struct.
func (v Value) FieldByName(name string) Value {
v.mustBe(Struct)
if f, ok := v.typ.FieldByName(name); ok {
return v.FieldByIndex(f.Index)
}
return Value{}
}
func (t *rtype) FieldByName(name string) (StructField, bool) {
if t.Kind() != Struct {
panic("reflect: FieldByName of non-struct type " + t.String())
}
tt := (*structType)(unsafe.Pointer(t))
return tt.FieldByName(name)
}
// FieldByName returns the struct field with the given name
// and a boolean to indicate if the field was found.
func (t *structType) FieldByName(name string) (f StructField, present bool) {
// Quick check for top-level name, or struct without embedded fields.
hasEmbeds := false
if name != "" {
for i := range t.fields {
tf := &t.fields[i]
if tf.name.name() == name {
return t.Field(i), true
}
if tf.embedded() {
hasEmbeds = true
}
}
}
if !hasEmbeds {
return
}
return t.FieldByNameFunc(func(s string) bool { return s == name })
}
解决方案: 对FieldByName加缓存
func xxx(binding, value interface{}) {
bVal := reflect.ValueOf(binding).Elem() //获取reflect.Type类型
vVal := reflect.ValueOf(value).Elem() //获取reflect.Type类型
vTypeOfT := vVal.Type()
bName := reflect.TypeOf(binding).String() //获取结构体类型名
var vNameMap map[string][]int
vNameItf, ok := structIndexCache.Load(bName)
if ok {
vNameMap, ok = vNameItf.(map[string][]int)
}
// 如果未缓存,先遍历进行缓存
if !ok {
vNameMap = make(map[string][]int, bVal.NumField())
for i := 0; i < bVal.NumField(); i++ {
name := bVal.Type().Field(i).Name
_, bNameIdxs := fieldByName(bVal, name)
vNameMap[name] = bNameIdxs
}
if bName != "" {
structIndexCache.Store(bName, vNameMap)
}
}
for i := 0; i < vVal.NumField(); i++ {
name := vTypeOfT.Field(i).Name
index, ok := vNameMap[name]
if ok && len(index) > 0 {
vv := bVal.FieldByIndex(index)
if ok = vv.IsValid(); ok{
vv.Set(reflect.ValueOf(vVal.Field(i).Interface()))
}
}
}
}
func fieldByName(v reflect.Value, name string) (reflect.Value, []int) {
if f, ok := v.Type().FieldByName(name); ok {
return v.FieldByIndex(f.Index), f.Index
}
return reflect.Value{}, []int{}
}
3.2 并发与锁
互斥锁&读写锁
互斥锁
: 同一时刻一段代码只能被一个线程运行,在Lock()
和Unlock()
之间的代码段称为资源的临界区(critical section),是线程安全
的,任何一个时间点都只能有一个goroutine
执行这段区间的代码读写锁
: 某一时刻能由任意数量的reader
持有,或者被一个wrtier
持有
type RWMutex struct {
// 读写锁基于互斥锁实现
w Mutex // held if there are pending writers
writerSem uint32 // semaphore for writers to wait for completing readers
readerSem uint32 // semaphore for readers to wait for completing writers
readerCount int32 // number of pending readers
readerWait int32 // number of departing readers
}
并发场景下锁优化
降低锁的粒度
:做分片,把原来的一个数据源分成多个分片,将原来的一把锁拆成多把锁,每把锁负责一个分片。 concurrent map实现: github.com/orcaman/con…
缩小锁的持有时间
:优化业务逻辑,减少锁的持有时间
减少锁的持有时间
var s sync.RWMutex
func test() {
s.Lock() //对整个函数加锁,增加了锁的持有时间
defer s.Unlock()
aaaa // 实际需要加锁部分
bbbb // 实际需要加锁部分
xx
xx
xx
}
// 可以优化减少锁的持有时间,但通常不建议这样优化,具体看下面扩展
func test2() {
s.Lock()
aaaa // 实际需要加锁部分
bbbb // 实际需要加锁部分
s.Unlock()
xx
xx
xx
}
扩展
:为何不建议使用test2()方式? 可能出现什么问题?-锁泄露
// 测试锁泄漏
var s sync.RWMutex
func main() {
go test(1)
time.Sleep(1 * time.Second)
go test(0)
time.Sleep(1 * time.Second)
}
func test(a int) {
defer func() {
err := recover()
if err != nil {
fmt.Printf("recover: %v", err)
}
}()
s.Lock()
// 少数场景时出发panic,锁未释放,导致后续正常流程阻塞
if a == 1 {
panic("panic 1 \n")
}
s.Unlock()
fmt.Printf("test : %v \n", a)
}
// 运行结果 recover: panic 1
可以把加锁的部分单独提取函数
// 提取函数
func test() {
a := func() {
s.Lock()
defer s.Unlock()
xx
xx
}
a()
xx
xx
xx
}
sync.map
适合场景:一次写入,多次读取。
为何写入性能比较差? 写操作源码:
// 写
func (m *Map) Store(key, value any) {
// 如果读取到,通过cas方法更新
read, _ := m.read.Load().(readOnly)
if e, ok := read.m[key]; ok && e.tryStore(&value) {
return
}
m.mu.Lock()
read, _ = m.read.Load().(readOnly)
if e, ok := read.m[key]; ok { //再读一次readOnly
if e.unexpungeLocked() {
m.dirty[key] = e
}
e.storeLocked(&value)
} else if e, ok := m.dirty[key]; ok {
e.storeLocked(&value)
} else {
if !read.amended { //dirty升级为readOnly
// readOnly->dirty值拷贝
m.dirtyLocked()
m.read.Store(readOnly{m: read.m, amended: true})
}
m.dirty[key] = newEntry(value)
}
m.mu.Unlock()
}
// CAS 交换数据
func (e *entry) tryStore(i *any) bool {
for {
p := atomic.LoadPointer(&e.p)
if p == expunged {
return false
}
if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {
return true
}
}
}
- 每次写入不存在的值都尝试2次读取readOnly
- dirty - readOnly 值拷贝
3.3 内存&GC
内存对齐
为了防止处理器读取值的时候做两次内存访问,golang会自动进行内存对齐
type struct1 struct {
a int8
b int16
c int32
}
type struct2 struct {
a int8
c int32
b int16
}
// 测试结构体内存大小
func TestAlign(t *testing.T) {
fmt.Println(unsafe.Sizeof(struct1{})) //8
fmt.Println(unsafe.Sizeof(struct2{})) //12
}
//运行结果
8
12
内存碎片-小类大对象思想
生命周期相同的小对象,聚合为大对象一次性分配 参考文章:www.jianshu.com/p/6a8d72b3b…
type minObj struct {
a int
b string
}
func NewMinObj(a int, b string) *minObj {
return &minObj{
a: a,
b: b,
}
}
type maxObj struct {
m1 minObj
m2 minObj
m3 minObj
m4 minObj
}
func NewMaxObj() *maxObj {
return &maxObj{
m1: minObj{1, "1234"},
m2: minObj{1, "1234"},
m3: minObj{1, "1234"},
m4: minObj{1, "1234"},
}
}
// 小对象多次分配
func BenchmarkObjMin(b *testing.B) {
for i := 0; i < b.N; i++ {
m1 := NewMinObj(1, "1234")
m2 := NewMinObj(1, "1234")
m3 := NewMinObj(1, "1234")
m4 := NewMinObj(1, "1234")
_, _, _, _ = m1, m2, m3, m4
}
}
// 聚合为大对象一次分配
func BenchmarkObjMax(b *testing.B) {
for i := 0; i < b.N; i++ {
m := NewMaxObj()
_ = m
}
}
// 运行结果
goos: darwin
goarch: arm64
BenchmarkObjMin-8 1000000000 0.3177 ns/op 0 B/op 0 allocs/op
BenchmarkObjMax-8 1000000000 0.3131 ns/op 0 B/op 0 allocs/op
// 关闭内联优化 -gcflag='-l'
BenchmarkObjMin-8 17110922 70.10 ns/op 96 B/op 4 allocs/op
BenchmarkObjMax-8 48670435 25.75 ns/op 96 B/op 1 allocs/op
sync.pool
临时对象池
- 每个P挂载一个池
- 每个池 私有 + 共享
share队列如何实现的?(
无锁队列的实现
)
如何通过CAS保证并发安全?
// 双向链表
// type queue struct {
// head *node
// tail *node
// }
// type node struct {
// pre *node
// next *node
// }
// tail添加一个节点
tail.next = newNode
tail = newNode
// 如何通过CAS保证并发安全?
old := tail
for !CAS(old.next, nil, newNode) { //并发场景下,有一个成功了,其他的old.next != nil, 会一直自旋
old = tail //一直到tail 指向新加入的节点
}
CAS(tail, old, newNode)
3.4 定时器
timer 单次定时器,到期停止; ticker 永久定时器(周期性定时器)。 所有timer统一使用一个最小堆结构去维护,插入O(logn)
时间轮算法
设计思想:定时精度换取性能
- 基本概念:表盘指针、时间间隔、时间格个数
- 单层时间轮最大定时时间 = 时间间隔 * 时间格个数
- add任务时间复杂度 O(1)
- add任务任务: 任务时间格 = (定时时间-当前时间)/时间间隔 + 表盘指针时间格
层级时间轮:
- 时间轮降级:时间轮走完一轮之后会判断是否有下一层时间轮,如果有的话就会通过降级把下一层的时间轮中的任务转移到上层
3.5 编解码
json & protobuffer
性能测试:
- 3个结构体,一个复合数据(string+float+int),一个string,一个num
var t1 = pt.Struct1{
A1: "aaaaaabbbbbbbbbbb",
A2: "ccccccccccccccccc",
A3: 123467,
A4: 1232412.123,
A5: "dddddddd",
A6: "eeeeeeeeeee",
A7: "gggggggggggggg",
A8: 123467,
A9: 1232412.123,
A10: 12323123,
}
var t2 = pt.Struct2{
A1: "aaaaaabbbbbbbbbbb",
A2: "ccccccccccccccccc",
A3: "123467",
A4: "1232412.123",
A5: "dddddddd",
A6: "eeeeeeeeeee",
A7: "gggggggggggggg",
A8: "123467",
A9: "1232412.123",
A10: "12323123",
}
var t3 = pt.Struct3{
A1: 123231231232,
A2: 1232412.123999,
A3: 123467,
A4: 1232412.123,
A5: 1232412.123999,
A6: 123231231232,
A7: 1232412.123999,
A8: 123467,
A9: 1232412.123,
A10: 12323123,
}
// json 编码 结构体包含string+int+float
func Benchmark_Json_Marshal_Mix(b *testing.B) {
for i := 0; i < b.N; i++ {
d, _ := json.Marshal(&t1)
_ = d
}
}
// pb 编码 结构体包含string+int+float
func Benchmark_Pb_Marshal_Mix(b *testing.B) {
for i := 0; i < b.N; i++ {
d, _ := proto.Marshal(&t1)
_ = d
}
}
// json 编码 结构体包含string
func Benchmark_Json_Marshal_String(b *testing.B) {
for i := 0; i < b.N; i++ {
d, _ := json.Marshal(&t2)
_ = d
}
}
// pb 编码 结构体包含string
func Benchmark_Pb_Marshal_String(b *testing.B) {
for i := 0; i < b.N; i++ {
d, _ := proto.Marshal(&t2)
_ = d
}
}
// json 编码 结构体包含num
func Benchmark_Json_Marshal_Num(b *testing.B) {
for i := 0; i < b.N; i++ {
d, _ := json.Marshal(&t3)
_ = d
}
}
// json 编码 结构体包含num
func Benchmark_Pb_Marshal_Num(b *testing.B) {
for i := 0; i < b.N; i++ {
d, _ := proto.Marshal(&t3)
_ = d
}
}
// ****************** 编码+解码 *********************
// json 编码+解码 混合数据
func Benchmark_Json_MarshalAndUnM_Num(b *testing.B) {
var d3 pt.Struct3
for i := 0; i < b.N; i++ {
d, _ := json.Marshal(&t3)
json.Unmarshal(d, &d3)
}
}
// pb 编码+解码 混合数据
func Benchmark_Pb_MarshalAndUnM_Num(b *testing.B) {
var d3 pt.Struct3
for i := 0; i < b.N; i++ {
d, _ := proto.Marshal(&t3)
proto.Unmarshal(d, &d3)
}
}
//结果
Benchmark_Json_Marshal_Mix-8 2260450 505.6 ns/op 192 B/op 1 allocs/op
Benchmark_Pb_Marshal_Mix-8 7259154 163.7 ns/op 112 B/op 1 allocs/op
Benchmark_Json_Marshal_String-8 2795911 428.4 ns/op 192 B/op 1 allocs/op
Benchmark_Pb_Marshal_String-8 6055800 197.1 ns/op 144 B/op 1 allocs/op
Benchmark_Json_Marshal_Num-8 1949540 611.5 ns/op 176 B/op 1 allocs/op
Benchmark_Pb_Marshal_Num-8 8594131 137.9 ns/op 80 B/op 1 allocs/op
Benchmark_Json_MarshalAndUnM_Num-8 427221 2786 ns/op 528 B/op 15 allocs/op
Benchmark_Pb_MarshalAndUnM_Num-8 4809230 248.2 ns/op 80 B/op 1 allocs/op
json编解码是文本的格式,pb是二进制格式(TLV 类型-长度-值),json整数和浮点数更占空间而且更费时,从测试看,整体json编解码耗费时间和内存更大,更多详细的对比,可参考文章:www.infoq.cn/article/jso…
极致性能场景的实践优化
- pb为TLV编码,尽量减少pb编码结构体的嵌套层数
- 对于透传的string类型,可以直接定义为[]byte存储,减少编解码转换
3.6 协程
GMP-P的数量
P的数量决定了CPU并发数,CPU分片执行内核线程M,M通过P与用户态协程绑定,执行用户态代码。P的数量由runtime
的方法GOMAXPROCS()
设定,通常设置为核数相等
协程池
3.7 缓存
高并发场景通常是大型互联网架构的重点和难点,不能只通过增加数据库和服务器就能完全处理所有的高并发业务。解决高并发的主要策略有读写分离策略、多级缓存策略、异步化策略等等
常见的缓存策略:
- 客户端缓存
- CDN缓存(content delivery network,内容分发网络),静态资源(网页文件、CSS样式文件、js脚本、图片、视频、音频等)
- 中间件缓存 (常见redis缓存等)
- 内存缓存
案例:配置的缓存实践
1、配置数据的特点为读多写少,对于同构系统,可采用轮询方式实现
缺点:
- 轮询对配置服务性能影响大
- 配置生效时间慢 2、对于异构系统或者对于配置生效时间较为严格等的场景,可采用文件监听方式更新内存缓存数据
3.8 业务逻辑优化
1、减少不必要的处理逻辑
// 往往判断语句中存在可以优化的地方
if t==下游调用返回值 && b {
dosamething()
}
// 可重构,在b不为true的时候会减少下游调用
if b && t==下游调用返回值 {
dosamething()
}
3.9 网络模型(待整理)
异步化策略可以提高系统的并发能力,增加系统的吞吐
1、同步流程:
- 链路越长,系统响应越慢 2、异步流程
对于某些场景下,可以采用异步流程设计,减小响应时间,增大系统吞吐
actor异步通信模型
Actor模型(Actor model)首先是由Carl Hewitt在1973定义, 由Erlang OTP 推广,其 消息传递更加符合面向对象的原始意图。Actor属于并发组件模型,通过组件方式定义并发编程范式的高级阶段,避免使用者直接接触多线程并发或线程池等基础概念。
Actor模型=数据+行为+消息。 go-actor源码实现:github.com/asynkron/pr…