golang性能优化

·  阅读 221

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包提供了采集指标方法,需要把这些方法注册到路由中。默认注册路径如下(也可以自己注册)

image.png

安装可视化工具 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
复制代码

分析:

huoyantu.png

  • profile: cpu使用情况的采样信息
  • allocs: 内存分配情况的抽象情况
  • block: 阻塞堆栈的采样信息
  • cmdline: 程序启动命令及其参数
  • goroutine: 当前协程的堆栈信息
  • heap: 堆内存的采样信息
  • mutex: 锁竞争的采样信息
  • threadcreate: 系统程序创建情况的采样信息
  • trace: 程序运行的跟踪信息

2 问题排查思路

cpu.drawio.png

如何保证请求的响应时间,推荐参考文章:The Tail at Scale

3. 优化方法

3.1 基本数据结构

slice

struct {
    ptr *[]T
    len int
    cap int
}
复制代码

1、append操作,当容量不够,会导致切片扩容,重新申请内存,进行值拷贝。在使用切片时,要初始化容量

slice.png

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的底层实现为哈希桶,如何实现缩容

map.drawio.png

扩容条件:

  • 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.Builderbytes.Buffer,都是基于[]byte实现的。切片 []byte 的扩容时是以倍数申请的(预分配),通常多个字符串拼接的时候性能更好一些。
  • strings.Builder 直接将底层的 []byte 通过指针转换成了字符串类型,减少了一次[]byte到string值拷贝

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
}
复制代码

并发场景下锁优化

  • 降低锁的粒度:做分片,把原来的一个数据源分成多个分片,将原来的一把锁拆成多把锁,每把锁负责一个分片 github.com/orcaman/con…

con-map.drawio.png

  • 缩小锁的持有时间:优化业务逻辑,减少锁的持有时间

减少锁的持有时间

var s sync.RWMutex
func test() {
    s.Lock()
    defer s.Unlock()
    
    // 加锁部分开始
    xxx
    xxx
    // 加锁部分结束
    
    xx
    xx
    xx
}

func test2() {
    s.Lock()  //不建议
    xxx
    xxx 
    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

适合场景:一次写入,多次读取。

sync_map.drawio.png

为何写入性能比较差? 写操作源码:

// 写
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

临时对象池

pool.drawio.png

  • 每个P挂载一个池
  • 每个池 私有 + 共享 share队列如何实现的?(无锁队列的实现

spmc.drawio.png

如何通过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)

时间轮算法

定时精度换取性能

timer.drawio.png

  • 基本概念:表盘指针、时间间隔、时间格个数
  • 单层时间轮最大定时时间 = 时间间隔 * 时间格个数
  • add任务时间复杂度 O(1)
  • add任务任务: 任务时间格 = (定时时间-当前时间)/时间间隔 + 表盘指针时间格

层级时间轮:

timer_multi.drawio.png

  • 时间轮降级:时间轮走完一轮之后会判断是否有下一层时间轮,如果有的话就会通过降级把下一层的时间轮中的任务转移到上层

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的数量

gmp.drawio.png

P的数量决定了CPU并发数,CPU分片执行内核线程M,M通过P与用户态协程绑定,执行用户态代码。P的数量由runtime的方法GOMAXPROCS()设定,通常设置为核数相等

协程池

3.7 缓存

高并发场景通常是大型互联网架构的重点和难点,不能只通过增加数据库和服务器就能完全处理所有的高并发业务。解决高并发的主要策略有读写分离策略、多级缓存策略、异步化策略等等

常见的缓存策略:

  • 客户端缓存
  • CDN缓存(content delivery network,内容分发网络),静态资源(网页文件、CSS样式文件、js脚本、图片、视频、音频等)

cdn.drawio.png

  • 中间件缓存 (常见redis缓存等)
  • 内存缓存

案例:配置的缓存实践

1、配置数据的特点为读多写少,对于同构系统,可采用轮询方式实现

aside1.drawio.png 缺点:

  • 轮询对配置服务性能影响大
  • 配置生效时间慢 2、对于异构系统或者对于配置生效时间较为严格等的场景,可采用文件监听方式更新内存缓存数据

aside2.drawio.png

3.8 网络模型

异步化策略可以提高系统的并发能力,增加系统的吞吐

1、同步流程:

tb.drawio.png

  • 链路越长,系统响应越慢 2、异步流程

对于某些场景下,可以采用异步流程设计,减小响应时间,增大系统吞吐

yb.drawio.png

actor异步通信模型

actor.drawio.png

Actor模型(Actor model)首先是由Carl Hewitt在1973定义, 由Erlang OTP 推广,其 消息传递更加符合面向对象的原始意图。Actor属于并发组件模型,通过组件方式定义并发编程范式的高级阶段,避免使用者直接接触多线程并发或线程池等基础概念。

Actor模型=数据+行为+消息。 go-actor源码实现:github.com/asynkron/pr…

分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改