go 性能优化

go 性能优化

[]byte 和 string

转换:

  • 尽量避免 []bytestring 的互相转换, go 的 string 是不可变类型, 标准实现中和 []byte 的互转均为值拷贝
  • 多数场景下都可以优先选择强转换方式进行互转
//强转换
func stringToBytes(s string) []byte {
   x := (*[2]uintptr)(unsafe.Pointer(&s))
   b := [3]uintptr{x[0], x[1], x[1]}
   return *(*[]byte)(unsafe.Pointer(&b))
}

func bytesToString(b []byte) string {
   return *(*string)(unsafe.Pointer(&b))
}

仅在只读场景下使用强转换

内存申请

提前预估容量

  • slice/map 初始化尽量估计好长度, 能有效减少内存分配次数, 优化很明显
  • 尽量规避使用 append, 因为需要值拷贝, 且涉及到重新申请内存, 可能会发生逃逸 (Mac 环境下测试: 当 append 之后的 slice 长度大于 8 时会被分配到堆上)
  • 如果无法预估, 一般场景下可以考虑申请足够大的空间, 并在场景允许的情况下优先考虑复用 slice
func useCap1() {
   arr := make([]int, 0, 2048)
   for i := 0; i < 2048; i++ {
      arr = append(arr, i)
   }
}

func useCap2() {
   arr := make([]int, 2048)
   for i := 0; i < 2048; i++ {
       arr[i] = i
   }
}

func noCap() {
   var arr []int
   for i := 0; i < 2048; i++ {
      arr = append(arr, i)
   }
}

Benchmark

goos: darwin
goarch: amd64
BenchmarkUseCap1-12       966577              1212 ns/op               0 B/op          0 allocs/op
BenchmarkUseCap2-12      2398420               499 ns/op               0 B/op          0 allocs/op
BenchmarkNoCap-12         192712              6016 ns/op           58616 B/op         14 allocs/op

slice 扩容的主要代码, 常规场景下的扩容逻辑为 cap<1024 时每次翻倍, cap>1024 时每次增长 25%, 此处也可以对应上 benchmark 中 noCap() 分配在了堆上, 并经过了 14 次扩容

newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
   newcap = cap
} else {
   if old.len < 1024 {
      newcap = doublecap
   } else {
      // Check 0 < newcap to detect overflow
      // and prevent an infinite loop.
      for 0 < newcap && newcap < cap {
         newcap += newcap / 4
      }
      // Set newcap to the requested cap when
      // the newcap calculation overflowed.
      if newcap <= 0 {
         newcap = cap
      }
   }
}

优先在栈上分配

func BenchmarkHeap(b *testing.B) {
   m := make([]*string, 1000)
   for i := 0; i < b.N; i++ {
      for i := 0; i < 1000; i++ {
         s := "test"
         m[i] = &s
      }
   }
}

func BenchmarkStack(b *testing.B) {
   m := make([]string, 1000)
   for i := 0; i < b.N; i++ {
      for i := 0; i < 1000; i++ {
         s := "test"
         m[i] = s
      }
   }
}

Benchmark

goos: darwin
goarch: amd64
BenchmarkHeap-12           44640         23033 ns/op       16000 B/op       1000 allocs/op
BenchmarkStack-12        4650966           252 ns/op           0 B/op          0 allocs/op

Map/Slice

Map 中简单结构尽量不使用指针

map[int]*int

func gcTime() time.Duration {
    start := time.Now()
    runtime.GC()
    return time.Since(start)
}

func f1() {
    s := make(map[int]int, 5e7)
    for i := 0; i < 5e7; i++ {
        s[i] = i
    }
    fmt.Printf("With %T, GC took %s\n", s, gcTime())
    _ = s[0]
}

func f2() {
    s := make(map[int]*int, 5e7)
    for i := 0; i < 5e7; i++ {
        s[i] = &i
    }
    fmt.Printf("With %T, GC took %s\n", s, gcTime())
    _=s[0]
}

Output:

With map[int]int, GC took 31.956029ms
With map[int]*int, GC took 184.174966ms

不包含指针的 map 在 gc 中不需要 scanObject
另外根据 map 的实现(关键词搜索 bmap), 当元素值大于 128byte 时, 还是需要 scanObject

type BigStruct struct {
   C01 int
   C02 int
   //...
   C16 int // 128byte gc scan 临界点
   C17 int //136byte
 }
 
 func f3() {
   s := make(map[int]BigStruct, N)
   for i := 0; i < N; i++ {
      s[i] = BigStruct{}
   }
   fmt.Printf("With %T, GC took %s\n", s, timeGC())
   _ = s[0]
}

Output:

With map[int]main.BigStruct, GC took 1.628134832s
With map[int]main.NoBigStruct, GC took 44.708865ms

BigStruct 多了一个 C17, GC 时间大幅增加

对比 []*int, []int[]BigStruct

func f4() {
    s := make([]*int, N)
    for i := 0; i < N; i++ {
        s[i] = &i
    }
    fmt.Printf("With %T, GC took %s\n", s, gcTime())
    _ = s[0]
}

func f5() {
    s := make([]int, N)
    for i := 0; i < N; i++ {
        s[i] = i
    }
    fmt.Printf("With %T, GC took %s\n", s, gcTime())
    _ = s[0]
}

func f6() {
    s := make([]BigStruct, N)
    for i := 0; i < N; i++ {
        s[i] = BigStruct{}
    }
    fmt.Printf("With %T, GC took %s\n", s, gcTime())
    _ = s[0]
}

Output:

With []*int, GC took 137.308395ms
With []int, GC took 211.862µs
With []main.BigStruct, GC took 173.504µs

slice 包含指针的时候同理需要 scanObject, 但不包含指针时不受元素大小影响, 且 gc 效率要比 map 高很多
上面的优化受到很多条条框框限制, 比如 map[int]string 其实是包含指针的(见 string 定义), 无法享受高效的 gc, 看上去不实用, 但是基于此有一种应用较多的优化方式, 即把大型的 map 结构转换为 map[int]int(索引)+slice 的方式, 把 gc 压力转移到 slice 上(比 map gc 开销低), 典型例子如 bigcache

slice 预分配内存

尽可能在使用 make() 初始化切片时提供容量信息

如果在后续使用过程中切片容量不够了, 会重新用一个新的数组做底层数组, 造成性能开销。

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

使用切片表达式(类似: arr[low:high]), 在已有切片基础上创建切片, 不会创建新的底层数组而是会复用原来切片的底层数组。

会出现两个问题:

  • 原切片较大, 代码在原切片的基础上新建小切片。之后使用这个小切片实际上还是使用原先大切片底层数组, 性能不高
  • 原底层数组在内存中有引用, 得不到释放

解决方法就是可以使用 copy 函数 来代替使用切片表达式在已有切片基础上创建切片

map 预分配内存

尽可能在使用 make() 初始化 map 时提供容量信息

使用 string.Builder 对字符串进行拼接

常见的字符串拼接方式:

func Plus(n int,str string)string{
	s := ""
	for i:=0;i<n;i++{
		s += str
	}
	return s
}

使用 string.Builder 对字符串进行拼接

func StrBuilder(n int,str string)string{
var builder strings Builder
	for i:=0;i<n;i++{
		builder.WriteString(str)
	}
	return builder.String()
}

空结构体

使用空结构体节省内存, 空结构体 struct{} 实例不占用任何内存空间, 可作为各种场景下的占位符使用, 如:

func EmptyStructMap(n int){
	m := make(map[int]struct{})
    for i:=0;i<n;i++{
    	m[i] = struct{}{}
    }
}

或者

var strtChan = make(chan struct{})

func example(){
	...
	strtChan <- struct{}{}
	...
}

使用 atomic 包

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

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

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

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

sync.pool

临时对象池应该是对可读性影响最小且优化效果显著的手段。最典型的就是 fasthttp 了, 它几乎把所有的对象都用 sync.pool 维护。

但这样的复用不一定全是合理的。比如在 fasthttp 中, 传递上下文相关信息的 RequestCtx 就是用 sync.pool 维护的, 这就导致了你不能把它传递给其它的 goroutine。

如果要在 fasthttp 中实现类似接受请求->异步处理的逻辑, 必须得拷贝一份 RequestCtx 再传递。这对不熟悉 fasthttp 原理的使用者来讲, 很容易就踩坑了。

另外, 在优化前要善用 go 逃逸检查分析对象是否逃逸到堆上, 防止负优化。

string2bytes & bytes2string

这也是两个比较常规的优化手段, 核心还是复用对象, 减少内存分配。

在 go 标准库中也有类似的用法 gostringnocopy

要注意 string2bytes 后, 不能对其修改。

unsafe.Pointer 经常出现在各种优化方案中, 使用时要非常小心。这类操作引发的异常, 通常是不能 recover 的。

协程池

绝大部分应用场景, go 是不需要协程池的。当然, 协程池还是有一些自己的优势:

  • 可以限制 goroutine 数量, 避免无限制的增长。
  • 减少栈扩容的次数。
  • 频繁创建 goroutine 的场景下, 资源复用, 节省内存。(需要一定规模。一般场景下, 效果不太明显)

go 对 goroutine 有一定的复用能力。所以要根据场景选择是否使用连接池, 不恰当的场景不仅得不到收益, 反而增加系统复杂性

反射

go 里面的反射代码可读性本来就差, 常见的优化手段进一步牺牲可读性。
而且后续马上就有范型的支持, 所以若非必要, 建议不要优化反射部分的代码

比较常见的优化手段有:

  • 缓存反射结果, 减少不必要的反射次数。例如 json-iterator
  • 直接使用 unsafe.Pointer 根据各个字段偏移赋值
  • 消除一般的 struct 反射内存消耗 go-reflect
  • 避免一些类型转换, 如 interface->[]byte。可以参考 zerolog

减小锁消耗

并发场景下, 对临界区加锁比较常见。带来的性能隐患也必须重视。常见的优化手段有:

  • 减小锁力度:
    go 标准库当中, math.rand 就有这么一处隐患。当我们直接使用 rand 库生成随机数时, 实际上由全局的 globalRand 对象负责生成。globalRand 加锁后生成随机数, 会导致我们在高频使用随机数的场景下效率低下。

  • atomic:
    适当场景下, 用原子操作代替互斥锁也是一种经典的 lock-free 技巧。

标准库中 sync.map 针对读操作的优化消除了 rwlock, 是一个标准的案例。对它的介绍文章也比较多, 不在赘述。

prometheus 里的组件 histograms 直方图也是一个非常巧妙的设计。

一般的开源库, 比如 go-metrics 都是直接在这里使用了互斥锁。指标上报作为一个高频操作, 在这里加锁, 对系统性能影响可想而知。

参考 sync.map 里冗余 map 的做法, prometheus 把原来 histograms 的计数器也分为两个: cold 和 hot, 还有一个 hotIdx 用来表示哪个计数器是 hot。
业务代码上报指标时, 用 atomic 原子操作对 hot 计数器累加
向 prometheus 服务上报数据时, 更改 hotIdx, 把原来的热数据变为冷数据, 作为上报的数据。然后把现在冷数据里的值, 累加到热数据里, 完成一次冷热数据的更新替换。

还有一些状态等待, 结构体内存布局的介绍, 不再赘述。具体可以参考 Lock-free Observations for Prometheus Histograms

另类手段

golink

golink 在官方的文档里有介绍, 使用格式:

//go:linkname FastRand runtime.fastrand
func FastRand() uint32

主要功能就是让编译器编译的时候, 把当前符号指向到目标符号。上面的函数 FastRand 被指向到 runtime.fastrand

runtime 包生成的也是伪随机数, 和 math 包不同的是, 它的随机数生成使用的上下文是来自当前 goroutine 的, 所以它不用加锁。正因如此, 一些开源库选择直接使用 runtime 的随机数生成函数。性能对比如下:

Benchmark_MathRand-12       84419976            13.98 ns/op
Benchmark_Runtime-12        505765551           2.158 ns/op

还有很多这样的例子, 比如我们要拿时间戳的话, 可以标准库中的 time.Now(), 这个库在会有两次系统调用 runtime.walltime1runtime.nanotime, 分别获取时间戳和程序运行时间。大部分场景下, 我们只需要时间戳, 这时候就可以直接使用 runtime.walltime1。性能对比如下:

Benchmark_Time-12       16323418            73.30 ns/op
Benchmark_Runtime-12    29912856            38.10 ns/op

同理, 如果我们需要统计某个函数的耗时, 也可以直接调用两次 runtime.nanotime 然后相减, 不用再调用两次 time.Now

//go:linkname nanotime1 runtime.nanotime1
func nanotime1() int64
func main() {
    defer func( begin int64) {
        cost := (nanotime1() - begin)/1000/1000
        fmt.Printf("cost = %dms \n" ,cost)
    }(nanotime1())
    
    time.Sleep(time.Second)
}

运行结果: cost = 1000ms

log-函数名称行号的获取

虽然很多高性能的日志库, 默认都不开启记录行号。但实际业务场景中, 我们还是觉得能打印最好。

runtime 中, 函数行号和函数名称的获取分为两步:

  1. runtime 回溯 goroutine 栈, 获取上层调用方函数的的程序计数器 (pc)。
  2. 根据 pc, 找到对应的 funcInfo, 然后返回行号名称

经过 pprof 分析。第二步性能占比最大, 约 60%。针对第一步, 我们经过多次尝试, 并没有找到有效的办法。但是第二步很明显, 我们不需要每次都调用 runtime 函数去查找 pc 和函数信息的, 我们可以把第一次的结果缓存起来, 后面直接使用。这样。第二步约 60%的消耗就可以去掉。

var(
    m sync.Map
)
func Caller(skip int)(pc uintptr, file string, line int, ok bool){
    rpc := [1]uintptr{}
    n := runtime.Callers(skip+1, rpc[:])
    if n < 1 {
        return
    }
    var (
        frame  runtime.Frame
        )
    pc  = rpc[0]
    if item,ok:=m.Load(pc);ok{
        frame = item.(runtime.Frame)
    }else{
        tmprpc := []uintptr{
            pc,
        }
        frame, _ = runtime.CallersFrames(tmprpc).Next()
        m.Store(pc,frame)
    }
    return frame.PC,frame.File,frame.Line,frame.PC!=0

simd

首先, go 链接器支持 simd 指令, 但 go 编译器不支持 simd 指令的生成。

所以在 go 中使用 simd 一般来说有三种方式:

  • 手写汇编
  • llvm
  • cgo(如果用 cgo 的方式来调用, 会受限于 cgo 的性能, 达不到加速的目的)

目前比较流行的做法是 llvm:

  • 用 c 来写 simd 相关的函数, 然后用 llvm 编译成 c 汇编
  • 用工具把 c 汇编转换成 go 的汇编格式, 保存为 .s 文件
  • 在 go 中调用 .s 里的方法, 最后用 go 编译器编译

以下开源库用到了 simd, 可以参考:

合理的使用 simd 可以充分发挥 cpu 特性, 但是存在以下弊端:

  • 难以维护, 要么需要懂汇编的大神, 要么需要引入第三方语言
  • 跨平台支持不够, 需要对不同平台汇编指令做适配
  • 汇编代码很难调试, 作为使用方来讲, 完全黑盒

jit

go 中使用 jit 的方式可以参考 Writing a JIT compiler in Golang

目前只有在字节跳动刚开源的 json 解析库中发现了使用场景 sonic

这种使用方式个人感觉在 go 中意义不大, 仅供参考

总结

过早的优化是万恶之源, 千万不要为了优化而优化

  • pprof 分析, 竞态分析, 逃逸分析, 这些基础的手段是必须要学会的
  • 常规的优化技巧是比较实用的, 他们往往能解决大部分的性能问题并且足够安全。
  • 在一些着重性能的基础库中, 使用一些非常规的优化手段也是可以的, 但必须要权衡利弊, 不要过早放弃可读性, 兼容性和稳定性。

Go 代码性能优化小技巧

前言

本文总结了 Go 语言日常开发中经常使用到的代码性能优化小技巧, 每个知识点都可以深挖其原理, 但本文只给出结论, 并附上 benchmark 结果, 从测试数据来直观地看性能差异, 具体的原理分析可以看每一小节给出的参考文章。

切片预分配容量

如果我们事先知道数据量大小, 则可以提前分配切片容量, 避免扩容时的元素拷贝。

before

func BenchmarkBefore(b *testing.B) {
	for i := 0; i < b.N; i++ {
		b.StopTimer()
		var s []int // 未指定切片容量
		b.StartTimer()
		for j := 0; j < 10000; j++ {
			s = append(s, j)
		}
	}
}

after

func BenchmarkAfter(b *testing.B) {
	for i := 0; i < b.N; i++ {
		b.StopTimer()
		s := make([]int, 0, 1000) // 预先指定切片容量
		b.StartTimer()
		for j := 0; j < 10000; j++ {
			s = append(s, j)
		}
	}
}

benchmark

$ go test -bench="." -benchmem
goos: windows
goarch: amd64
pkg: learnGolang
BenchmarkBefore-4          16641             76656 ns/op          386304 B/op         20 allocs/op
BenchmarkAfter-4           21256             56961 ns/op          334465 B/op          7 allocs/op
PASS
ok      learnGolang     6.305s

大量字符串拼接

Go 语言的字符串为不可变类型, 使用 + 拼接字符串会创建一个新的对象, 在大量字符串拼接的场景下, 使用 strings.Builder 可以显著提升性能。

before

func Before(str string, n int) string {
	var s string
	for i := 0; i < n; i++ {
		s += str // 直接使用 + 来拼接
	}
	return s
}

after

func After(str string, n int) string {
	var buf strings.Builder
	for i := 0; i < n; i++ {
		buf.WriteString(str) // 使用 strings.Builder 优化拼接
	}
	return buf.String()
}

benchmark

var (
	str = "hello, 世界"
	n   = 1000
)

func BenchmarkBefore(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Before(str, n)
	}
}

func BenchmarkAfter(b *testing.B) {
	for i := 0; i < b.N; i++ {
		After(str, n)
	}
}
$ go test -bench="." -benchmem
goos: windows
goarch: amd64
pkg: learnGolang
BenchmarkBefore-4            756           1391783 ns/op         6366122 B/op        999 allocs/op
BenchmarkAfter-4           82411             15351 ns/op           53232 B/op         15 allocs/op
PASS
ok      learnGolang     2.705s

正则表达式预编译

如果正则表达式是确定不变的, 则可以将其定义为全局变量并预先编译, 避免每次使用时现编译。

before

func ParseIPv4Before(ip string) bool {
	re := regexp.MustCompile(`(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)`)
	return re.MatchString(ip)
}

after

var IPv4Regex = regexp.MustCompile(`(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)`)

func ParseIPv4After(ip string) bool {	
    return IPv4Regex.MatchString(ip)
}

benchmark

func BenchmarkBefore(b *testing.B) {	
    for i := 0; i < b.N; i++ {		
        ParseIPv4Before("192.168.2.255")	
    }
}

func BenchmarkAfter(b *testing.B) {	
    for i := 0; i < b.N; i++ {		
        ParseIPv4After("192.168.2.255")	
    }
}
$ go test -bench="." -benchmemgoos: windowsgoarch: amd64pkg: learnGolangBenchmarkBefore-4          
80751               12892 ns/op             11046 B/op          83 allocs/opBenchmarkAfter-4         
3840087             371 ns/op               0 B/op              0 allocs/op
PASS
ok      learnGolang         2.992s

Go 应用的性能优化

为什么要做优化

这是一个速度决定一切的年代, 只要我们的生活还在继续数字化, 线下的流程与系统就在持续向线上转移, 在这个转移过程中, 我们会碰到持续的性能问题。

互联网公司本质是将用户共通的行为流程进行了集中化管理, 通过中心化的信息交换达到效率提升的目的, 同时用规模效应降低了数据交换的成本。

用人话来讲, 公司希望的是用尽量少的机器成本来赚取尽量多的利润。利润的提升与业务逻辑本身相关, 与技术关系不大。而降低成本则是与业务无关, 纯粹的技术话题。这里面最重要的主题就是"性能优化"。

如果业务的后端服务规模足够大, 那么一个程序员通过优化帮公司节省的成本, 或许就可以负担他十年的工资了。

优化的前置知识

从资源视角出发来对一台服务器进行审视的话, CPU、内存、磁盘与网络是后端服务最需要关注的四种资源类型。

对于计算密集型的程序来说, 优化的主要精力会放在 CPU 上, 要知道 CPU 基本的流水线概念, 知道怎么样在使用少的 CPU 资源的情况下, 达到相同的计算目标。

对于 IO 密集型的程序(后端服务一般都是 IO 密集型)来说, 优化可以是降低程序的服务延迟, 也可以是提升系统整体的吞吐量。

IO 密集型应用主要与磁盘、内存、网络打交道。因此我们需要知道一些基本的与磁盘、内存、网络相关的基本数据与常见概念:

  • 要了解内存的多级存储结构: L1, L2, L3, 主存。还要知道这些不同层级的存储操作时的大致延迟: latency numbers every programmer should know
  • 要知道基本的文件系统读写 syscall, 批量 syscall, 数据同步 syscall。
  • 要熟悉项目中使用的网络协议, 至少要对 TCP, HTTP 有所了解。

优化越靠近应用层效果越好

Performance tuning is most effective when done closest to where the work is performed. For workloads driven by applications, this means within the application itself.

我们在应用层的逻辑优化能够帮助应用提升几十倍的性能, 而最底层的优化可能也就只能提升几个百分点了。

这个很好理解, 我们可以看到一个 GTA Online 的新闻: rockstar thanks gta online player who fixed poor load times

简单来说, GTA online 的游戏启动过程让玩家等待时间过于漫长, 经过各种工具分析, 发现一个 10M 的文件加载就需要几十秒, 用户 diy 进行优化之后, 将加载时间减少 70%, 并分享出来: how I cut GTA Online loading times by 70%

这就是一个非常典型的案例, GTA 在商业上取得了巨大的成功, 但不妨碍它局部的代码是一坨屎。我们只要把这里的重复逻辑干掉, 就可以完成三倍的优化效果。同样的案例, 如果我们去优化磁盘的读写速度, 则可能收效甚微。

优化是与业务场景相关的

不同的业务场景优化的侧重也是不同的。

对于大多数无状态业务模块来说, 内存一般不是瓶颈, 所以业务 API 的优化主要聚焦于延迟和吞吐。对于网关类的应用, 因为有海量的连接, 除了延迟和吞吐, 内存占用可能就会成为一个关注的重点。对于存储类应用, 内存是个逃不掉的瓶颈点。

在关注一些性能优化文章时, 我们也应特别留意作者的业务场景。场景的侧重可能会让某些人去选择使用更为 hack 的手段进行优化, 而 hack 往往也就意味着 bug。如果你选择了少有人走过的路, 那你未来要面临的也是少有人会碰到的 bug。解决起来令人头疼。

优化的工作流程

对于一个典型的 API 应用来说, 优化工作基本遵从下面的工作流:

  1. 建立评估指标, 例如固定 QPS 压力下的延迟或内存占用, 或模块在满足 SLA 前提下的极限 QPS
  2. 通过自研、开源压测工具进行压测, 直到模块无法满足预设性能要求: 如大量超时, QPS 不达预期, OOM
  3. 通过内置 profile 工具寻找性能瓶颈
  4. 本地 benchmark 证明优化效果
  5. 集成 patch 到业务模块, 回到 2

可以使用的工具

pprof

  • memory profiler

Go 内置的内存 profiler 可以让我们对线上系统进行内存使用采样, 有四个相应的指标:

  • inuse_objects: 当我们认为内存中的驻留对象过多时, 就会关注该指标
  • inuse_space: 当我们认为应用程序占据的 RSS 过大时, 会关注该指标
  • alloc_objects: 当应用曾经发生过历史上的大量内存分配行为导致 CPU 或内存使用大幅上升时, 可能关注该指标
  • alloc_space: 当应用历史上发生过内存使用大量上升时, 会关注该指标

网关类应用因为海量连接的关系, 会导致进程消耗大量内存, 所以我们经常看到相关的优化文章, 主要就是降低应用的 inuse_space。

而两个对象数指标主要是为 GC 优化提供依据, 当我们进行 GC 调优时, 会同时关注应用分配的对象数、正在使用的对象数, 以及 GC 的 CPU 占用的指标。

GC 的 CPU 占用情况可以由内置的 CPU profiler 得到。

  • cpu profiler

The builtin Go CPU profiler uses the setitimer(2) system call to ask the operating system to be sent a SIGPROF signal 100 times a second. Each signal stops the Go process and gets delivered to a random thread’s sigtrampgo() function. This function then proceeds to call sigprof() or sigprofNonGo() to record the thread’s current stack.

Go 语言内置的 CPU profiler 使用 setitimer 系统调用, 操作系统会每秒 100 次向程序发送 SIGPROF 信号。在 Go 进程中会选择随机的信号执行 sigtrampgo 函数。该函数使用 sigprof 或 sigprofNonGo 来记录线程当前的栈。

Since Go uses non-blocking I/O, Goroutines that wait on I/O are parked and not running on any threads. Therefore they end up being largely invisible to Go’s builtin CPU profiler.

Go 语言内置的 cpu profiler 是在性能领域比较常见的 On-CPU profiler, 对于瓶颈主要在 CPU 消耗的应用, 我们使用内置的 profiler 也就足够了。

如果我们碰到的问题是应用的 CPU 使用不高, 但接口的延迟却很大, 那么就需要用上 Off-CPU profiler, 遗憾的是官方的 profiler 并未提供该功能, 我们需要借助社区的 fgprof。

fgprof

fgprof is implemented as a background goroutine that wakes up 99 times per second and calls runtime.GoroutineProfile. This returns a list of all goroutines regardless of their current On/Off CPU scheduling status and their call stacks.

fgprof 是启动了一个后台的 goroutine, 每秒启动 99 次, 调用 runtime.GoroutineProfile 来采集所有 gorooutine 的栈。

虽然看起来很美好:

func GoroutineProfile(p []StackRecord) (n int, ok bool) {
    .....
stopTheWorld("profile")

for _, gp1 := range allgs {
......
}

if n <= len(p) {
// Save current goroutine.
........
systemstack(func() {
saveg(pc, sp, gp, &r[0])
})

// Save other goroutines.
for _, gp1 := range allgs {
if isOK(gp1) {
.......
saveg(^uintptr(0), ^uintptr(0), gp1, &r[0])
                .......
}

startTheWorld()

return n, ok
}

但调用 GoroutineProfile 函数的开销并不低, 如果线上系统的 goroutine 上万, 每次采集 profile 都遍历上万个 goroutine 的成本实在是太高了。所以 fgprof 只适合在测试环境中使用。

trace

一般情况下我们是不需要使用 trace 来定位性能问题的, 通过压测 + profile 就可以解决大部分问题, 除非我们的问题与 runtime 本身的问题相关。

比如 STW 时间比预想中长, 超过百毫秒, 向官方反馈问题时, 才需要出具相关的 trace 文件。比如类似 long stw 这样的 issue。

采集 trace 对系统的性能影响还是比较大的, 即使我们只是开启 gctrace, 把 gctrace 日志重定向到文件, 对系统延迟也会有一定影响, 因为 gctrace 的 print 是在 stw 期间来做的: gc trace 阻塞调度

perf

如果应用没有开启 pprof, 在线上应急时, 我们也可以临时使用 perf:

微观性能优化

在编写 library 时, 我们会关注关键的函数性能, 这时可以脱离系统去探讨性能优化, Go 语言的 test 子命令集成了相关的功能, 只要我们按照约定来写 Benchmark 前缀的测试函数, 就可以实现函数级的基准测试。我们以常见的二维数组遍历为例:

package main

import "testing"

var x = make([][]int, 100)

func init() {
for i := 0; i < 100; i++ {
x[i] = make([]int, 100)
}

func traverseVertical() {
for i := 0; i < 100; i++ {
for j := 0; j < 100; j++ {
x[j][i] = 1
}

func traverseHorizontal() {
for i := 0; i < 100; i++ {
for j := 0; j < 100; j++ {
x[i][j] = 1
}

func BenchmarkHorizontal(b *testing.B) {
for i := 0; i < b.N; i++ {
traverseHorizontal()
}

func BenchmarkVertical(b *testing.B) {
for i := 0; i < b.N; i++ {
traverseVertical()
}

执行 go test -bench=.

BenchmarkHorizontal-12      102368     10916 ns/op
BenchmarkVertical-12         66612     18197 ns/op

可见横向遍历数组要快得多, 这提醒我们在写代码时要考虑 CPU 的 cache 设计及局部性原理, 以使程序能够在相同的逻辑下获得更好的性能。

除了 CPU 优化, 我们还经常会碰到要优化内存分配的场景。只要带上 -benchmem 的 flag 就可以实现了。

举个例子, 形如下面这样的代码:

logStr := "userid :" + userID + "; orderid:" + orderID

你觉得代码写的很难看, 想要优化一下可读性, 就改成了下列代码:

logStr := fmt.Sprintf("userid: %v; orderid: %v", userID, orderID)

这样的修改方式在某公司的系统中曾经导致了 p2 事故, 上线后接口的超时俱增至 SLA 承诺以上。

我们简单验证就可以发现:

BenchmarkPrint-12      7168467       157 ns/op      64 B/op       3 allocs/op
BenchmarkPlus-12    43278558        26.7 ns/op       0 B/op       0 allocs/op

使用 + 进行字符串拼接, 不会在堆上产生额外对象。而使用 fmt 系列函数, 则会造成局部对象逃逸到堆上, 这里是高频路径上有大量逃逸, 所以导致线上服务的 GC 压力加重, 大量接口超时。

出于谨慎考虑, 修改高并发接口时, 拿不准的尽量都应进行简单的线下 benchmark 测试。

当然, 我们不能指望靠写一大堆 benchmark 帮我们发现系统的瓶颈。

实际工作中还是要使用前文提到的优化工作流来进行系统性能优化。也就是尽量从接口整体而非函数局部考虑去发现与解决瓶颈。

宏观性能优化

接口类的服务, 我们可以使用两种方式对其进行压测:

  • 固定 QPS 压测: 在每次系统有大的特性发布时, 都应进行固定 QPS 压测, 与历史版本进行对比, 需要关注的指标包括, 相同 QPS 下的系统的 CPU 使用情况, 内存占用情况(监控中的 RSS 值), goroutine 数, GC 触发频率和相关指标(是否有较长的 stw, mark 阶段是否时间较长等), 平均延迟, p99 延迟。
  • 极限 QPS 压测: 极限 QPS 压测一般只是为了 benchmark show, 没有太大意义。系统满负荷时, 基本 p99 已经超出正常用户的忍受范围了。
    压测过程中需要采集不同 QPS 下的 CPU profile, 内存 profile, 记录 goroutine 数。与历史情况进行 AB 对比。

Go 的 pprof 还提供了 --base 的 flag, 能够很直观地帮我们发现不同版本之间的指标差异: 用 pprof 比较内存使用差异

总之记住一点, 接口的性能一定是通过压测来进行优化的, 而不是通过硬啃代码找瓶颈点。关键路径的简单修改往往可以带来巨大收益。如果只是啃代码, 很有可能将 1% 优化到 0%, 优化了 100% 的局部性能, 对接口整体影响微乎其微。

寻找性能瓶颈

在压测时, 我们通过以下步骤来逐渐提升接口的整体性能:

  1. 使用固定 QPS 压测, 以阶梯形式逐渐增加压测 QPS, 如 1000 -> 每分钟增加 1000 QPS
  2. 压测过程中观察系统的延迟是否异常
  3. 观察系统的 CPU 使用情况
  4. 如果 CPU 使用率在达到一定值之后不再上升, 反而引起了延迟的剧烈波动, 这时大概率是发生了阻塞, 进入 pprof 的 web 页面, 点击 goroutine, 查看 top 的 goroutine 数, 这时应该有大量的 goroutine 阻塞在某处, 比如 Semacquire
  5. 如果 CPU 上升较快, 未达到预期吞吐就已经过了高水位, 则可以重点考察 CPU 使用是否合理, 在 CPU 高水位进行 profile 采样, 重点关注火焰图中较宽的"平顶山"

一些优化案例

gc mark 占用过多 CPU

在 Go 语言中 gc mark 占用的 CPU 主要和运行时的对象数相关, 也就是我们需要看 inuse_objects。

定时任务, 或访问流量不规律的应用, 需要关注 alloc_objects。

优化主要是下面几方面:

减少变量逃逸

尽量在栈上分配对象, 关于逃逸的规则, 可以查看 Go 编译器代码中的逃逸测试部分:

查看某个 package 内的逃逸情况, 可以使用 build + 全路径 的方式, 如:

go build -gcflags="-m -m" github.com/cch123/elasticsql

需要注意的是, 逃逸分析的结果是会随着版本变化的, 所以去背诵网上逃逸相关的文章结论是没有什么意义的。

使用 sync.Pool 复用堆上对象

sync.Pool 用出花儿的就是 fasthttp 了, 可以看看我之前写的这一篇: fasthttp 为什么快

最简单的复用就是复用各种 struct, slice, 在复用时 put 时, 需要 size 是否已经扩容过头, 小心因为 sync.Pool 中存了大量的巨型对象导致进程占用了大量内存。

调度占用过多 CPU

goroutine 频繁创建与销毁会给调度造成较大的负担, 如果我们发现 CPU 火焰图中 schedule, findrunnable 占用了大量 CPU, 那么可以考虑使用开源的 workerpool 来进行改进, 比较典型的 fasthttp worker pool

如果客户端与服务端之间使用的是短连接, 那么我们可以使用长连接。

进程占用大量内存

当前大多数的业务后端服务是不太需要关注进程消耗的内存的。

我们经常看到做 Go 内存占用优化的是在网关(包括 mesh)、存储系统这两个场景。

对于网关类系统来说, Go 的内存占用主要是因为 Go 独特的抽象模型造成的, 这个很好理解:

海量的连接加上海量的 goroutine, 使网关和 mesh 成为 Go OOM 的重灾区。所以网关侧的优化一般就是优化:

  • goroutine 占用的栈内存
  • read buffer 和 write buffer 占用的内存

很多项目都有相关的分享, 这里就不再赘述了。

对于存储类系统来说, 内存占用方面的努力也是在优化 buffer, 比如 dgraph 使用 cgo + jemalloc 来优化他们的产品内存占用

堆外内存不会在 Go 的 GC 系统里进行管辖, 所以也不会影响到 Go 的 GC Heap Goal, 所以也不会像 Go 这样内存占用难以控制。

锁冲突严重, 导致吞吐量瓶颈

我在 几个 Go 系统可能遇到的锁问题 中分享过实际的线上 case。

进行锁优化的思路无非就一个"拆"和一个"缩"字:

  • 拆: 将锁粒度进行拆分, 比如全局锁, 我能不能把锁粒度拆分为连接粒度的锁; 如果是连接粒度的锁, 那我能不能拆分为请求粒度的锁; 在 logger fd 或 net fd 上加的锁不太好拆, 那么我们增加一些客户端, 比如从 1-> 100, 降低锁的冲突是不是就可以了。
  • 缩: 缩小锁的临界区, 比如业务允许的前提下, 可以把 syscall 移到锁外面; 比如我们只是想要锁 map, 但是却不小心锁了连接读写的逻辑, 或许简单地用 sync.Map 来代替 map Lock, defer Unlock 就能简单地缩小临界区了。

timer 相关函数占用大量 CPU

同样是在某些网关应用中较常见, 优化方法手段:

  • 使用时间轮/粗粒度的时间管理, 精确到 ms 级一般就足够了
  • 升级到 Go 1.14+, 享受官方的升级红利

模拟真实工作负载

在前面的论述中, 我们对问题进行了简化。真实世界中的后端系统往往不只一个接口, 压测工具、平台往往只支持单接口压测。

公司的业务希望知道的是某个后端系统最终能支持多少业务量, 例如系统整体能承载多少发单量而不会在重点环节出现崩溃。

虽然大家都在讲微服务, 但单一服务往往也不只有单一功能, 如果一个系统有 10 个接口(已经算是很小的服务了), 那么这个服务的真实负载是很难靠人肉去模拟的。

这也就是为什么互联网公司普遍都需要做全链路压测。像样点的公司都会定期进行全链路压测演练, 以便知晓随着系统快速迭代变化, 系统整体是否出现了严重的性能衰退。

通过真实的工作负载, 我们才能发现真实的线上性能问题。讲全链路压测的文章也很多, 本文就不再赘述了。

当前性能问题定位工具的局限性

本文中几乎所有优化手段都是通过 Benchmark 和压测来进行的, 但真实世界的软件会有下列场景:

  • 做 ToB 生意, 我们的应用是部署在客户侧(比如一些数据库产品), 客户说我们的应用会 OOM, 但是我们很难拿到 OOM 的现场, 不知道到底是哪些对象分配导致了 OOM
  • 做大型平台, 平台上有各种不同类型的用户编写代码, 升级用户代码后, 线上出现各种 CPU 毛刺和 OOM 问题

这些问题在压测中是发现不了的, 需要有更为灵活的工具和更为强大的平台, 关于这些问题, 我将在 4 月 10 日的武汉 Gopher Meetup 上进行分享, 欢迎关注。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

云满笔记

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值