go优化

sync.Pool

深度解密 Go 语言之 sync.Pool

对于很多需要重复分配、回收内存的地方,sync.Pool 是一个很好的选择。频繁地分配、回收内存会给 GC 带来一定的负担,严重的时候会引起 CPU 的毛刺,而 sync.Pool 可以将暂时不用的对象缓存起来,待下次需要的时候直接使用,不用再次经过内存分配,复用对象的内存,减轻 GC 的压力,提升系统的性能。sync.Pool 的大小是可伸缩的,高负载时会动态扩容,存放在池中的对象如果不活跃了会被自动清理。

sync.Pool 是协程安全的,这对于使用者来说是极其方便的。使用前,设置好对象的 New 函数,用于在 Pool 里没有缓存的对象时,创建一个。之后,在程序的任何地方、任何时候仅通过 Get()Put() 方法就可以取、还对象了。Put() 之前最好清空对象

当程序并发度非常高的情况下,短时间内需要创建大量的临时对象。而这些对象是都是分配在堆上的,会给 GC 造成很大压力,严重影响程序的性能.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func main() {
	i := 0
	pool := &sync.Pool{
		New: func() interface{} {
			i++
			fmt.Println("Creating a new Object")
			return i
		},
	}

	p := pool.Get()
	fmt.Println("首次从 pool 里获取:", p)
	pool.Put(p)
	fmt.Println("Pool 里已有一个对象 调用 Get: ", pool.Get())
	fmt.Println("Pool 没有对象了,调用 Get: ", pool.Get())

}

Creating a new Object
首次从 pool 里获取 1
Pool 里已有一个对象 调用 Get:  1
Creating a new Object
Pool 没有对象了调用 Get:  2

  • 不要对 Get 得到的对象有任何假设,更好的做法是归还对象时,将对象清空。

  • Pool 里对象的生命周期受 GC 影响,不适合于做连接池,因为连接池需要自己管理对象的生命周期。

  • Go的并发模型是GMP模型,sync.Pool给每个P都建立了本地池,一个本地私有池,一个本地共享池,执行Get方法时,先从本地私有池取,取不到,去本地共享池,再取不到,去其他P的共享池中取,失败的话去victim cache中取,再失败就调用New方法,New生成的对象不会放到本地池中,是直接返回给调用方的。

  • Pool 不可以指定⼤⼩,⼤⼩只受制于 GC 临界值。

  • procPin 将 G 和 P 绑定,防止 G 被抢占。在绑定期间,GC 无法清理缓存的对象。

  • 在加入 victim 机制前,sync.Pool 里对象的最⼤缓存时间是一个 GC 周期,当 GC 开始时,将 local 和 victim 作交换,这样也就不致于让 GC 把所有的 Pool 都清空了,没有被引⽤的对象都会被清理掉;加入 victim 机制后,最大缓存时间为两个 GC 周期。Victim Cache 本来是计算机架构里面的一个概念,是 CPU 硬件处理缓存的一种技术,sync.Pool 引入的意图在于降低 GC 压力的同时提高命中率。

  • sync.Pool 的最底层使用切片加链表来实现双端队列,并将缓存的对象存储在切片中。

sync.Map

使用 sync.map 之后,对 map 的读写,不需要加锁。并且它通过空间换时间的方式,使用 read 和 dirty 两个 map 来进行读写分离,降低锁时间来提高效率

sync.map 适用于读多写少的场景。对于写多的场景,会导致 read map 缓存失效,需要加锁,导致冲突变多;而且由于未命中 read map 次数过多,导致 dirty map 提升为 read map,这是一个 O(N) 的操作,会进一步降低性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func main()  {
	var m sync.Map
	// 1. 写入
	m.Store("qcrao", 18)
	m.Store("stefno", 20)

	// 2. 读取
	age, _ := m.Load("qcrao")
	fmt.Println(age.(int))

	// 3. 遍历
	m.Range(func(key, value interface{}) bool {
		name := key.(string)
		age := value.(int)
		fmt.Println(name, age)
		return true
	})

	// 4. 删除
	m.Delete("qcrao")
	age, ok := m.Load("qcrao")
	fmt.Println(age, ok)

	// 5. 读取或写入
	m.LoadOrStore("stefno", 100)
	age, _ = m.Load("stefno")
	fmt.Println(age)
}

先来看下 map 的数据结构。去掉大段的注释后:

1
2
3
4
5
6
type Map struct {
	mu Mutex
	read atomic.Value // readOnly
	dirty map[interface{}]*entry
	misses int
}

互斥量 mu 保护 read 和 dirty。

read 是 atomic.Value 类型,可以并发地读。但如果需要更新 read,则需要加锁保护。对于 read 中存储的 entry 字段,可能会被并发地 CAS 更新。但是如果要更新一个之前已被删除的 entry,则需要先将其状态从 expunged 改为 nil,再拷贝到 dirty 中,然后再更新。

dirty 是一个非线程安全的原始 map。包含新写入的 key,并且包含 read 中的所有未被删除的 key。这样,可以快速地将 dirty 提升为 read 对外提供服务。如果 dirty 为 nil,那么下一次写入时,会新建一个新的 dirty,这个初始的 dirtyread 的一个拷贝,但除掉了其中已被删除的 key。

每当从 read 中读取失败,都会将 misses 的计数值加 1,当加到一定阈值以后,需要将 dirty 提升为 read,以期减少 miss 的情形。

  • 调用 Load 或 LoadOrStore 函数时,如果在 read 中没有找到 key,则会将 misses 值原子地增加 1,当 misses 增加到和 dirty 的长度相等时,会将 dirty 提升为 read。以期减少“读 miss”。
  • 新写入的 key 会保存到 dirty 中,如果这时 dirty 为 nil,就会先新创建一个 dirty,并将 read 中未被删除的元素拷贝到 dirty。
  • 当 dirty 为 nil 的时候,read 就代表 map 所有的数据;当 dirty 不为 nil 的时候,dirty 才代表 map 所有的数据。

unsafe

可以绕过 Go 语言的类型系统,直接操作内存。例如,一般我们不能操作一个结构体的未导出成员,但是通过 unsafe 包就能做到。unsafe 包让我可以直接读写内存,还管你什么导出还是未导出。

有时,安全会导致效率低下。有了 unsafe 包,高阶的程序员就可以利用它绕过类型系统的低效。因此,它就有了存在的意义,阅读 Go 源码,会发现有大量使用 unsafe 包的例子

Sizeof 返回类型 x 所占据的字节数,但不包含 x 所指向的内容的大小。例如,对于一个指针,函数返回的大小为 8 字节(64位机上),一个 slice 的大小则为 slice header 的大小。

Offsetof 返回结构体成员在内存中的位置离结构体起始处的字节数,所传参数必须是结构体的成员。

Alignof 返回 m,m 是指当类型进行内存对齐时,它分配到的内存地址能整除 m。

注意到以上三个函数返回的结果都是 uintptr 类型,这和 unsafe.Pointer 可以相互转换。三个函数都是在编译期间执行,它们的结果可以直接赋给 const 型变量。另外,因为三个函数执行的结果和操作系统、编译器相关,所以是不可移植的。

综上所述,unsafe 包提供了 2 点重要的能力:

任何类型的指针和 unsafe.Pointer 可以相互转换。

uintptr 类型和 unsafe.Pointer 可以相互转换。

pointer 不能直接进行数学运算,但可以把它转换成 uintptr,对 uintptr 类型进行数学运算,再转换成 pointer 类型。

uintptr 并没有指针的语义,意思就是 uintptr 所指向的对象会被 gc 无情地回收。而 unsafe.Pointer 有指针语义,可以保护它所指向的对象在“有用”的时候不会被垃圾回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func main() {
	s := make([]int, 9, 20)
  // 通过 unsafe.Sizeof() 函数可以获取成员大小,进而计算出成员的地址,直接修改内存
	var Len = *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + unsafe.Sizeof(int(0))))
	fmt.Println(Len, len(s)) // 9 9
  // 通过 unsafe.Pointer 和 uintptr 进行转换,得到 slice 的字段值
	var Cap = *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + uintptr(16)))
	fmt.Println(Cap, cap(s)) // 20 20

	type Programmer struct {
		name     string
		language string
	}
	p := Programmer{"stefno", "go"}
	fmt.Println(p)
// name 是结构体的第一个成员,因此可以直接将 &p 解析成 *string。
	name := (*string)(unsafe.Pointer(&p))
	*name = "qcrao"
	// 对于一个结构体,通过 offset 函数可以获取结构体成员的偏移量,进而获取成员的地址,读写该地址的内存,就可以达到改变成员值的目的。
// 这里有一个内存分配相关的事实:结构体会被分配一块连续的内存,结构体的地址也代表了第一个成员的地址。
	lang := (*string)(unsafe.Pointer(
		uintptr(unsafe.Pointer(&p)) + unsafe.Offsetof(p.language),
	))
	*lang = "Golang"
	fmt.Println(p)
}

string 和 slice 的相互转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
type StringHeader struct {
	Data uintptr
	Len  int
}

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

func string2bytes(s string) []byte {
	stringHeader := (*reflect.StringHeader)(unsafe.Pointer(&s))

	bh := reflect.SliceHeader{
		Data: stringHeader.Data,
		Len:  stringHeader.Len,
		Cap:  stringHeader.Len,
	}

	return *(*[]byte)(unsafe.Pointer(&bh))
}

func bytes2string(b []byte) string{
	sliceHeader := (*reflect.SliceHeader)(unsafe.Pointer(&b))

	sh := reflect.StringHeader{
		Data: sliceHeader.Data,
		Len:  sliceHeader.Len,
	}

	return *(*string)(unsafe.Pointer(&sh))
}

pprof

Go pprof性能调优

Go语言项目中的性能优化主要有以下几个方面:

  • CPU profile:报告程序的 CPU 使用情况,按照一定频率去采集应用程序在 CPU 和寄存器上面的数据
  • Memory Profile(Heap Profile):报告程序的内存使用情况
  • Block Profiling:报告 goroutines 不在运行状态的情况,可以用来分析和查找死锁等性能瓶颈
  • Goroutine Profiling:报告 goroutines 的使用情况,有哪些 goroutine,它们的调用关系是怎样的

Go 语言自带的 pprof 库就可以分析程序的运行情况,并且提供可视化的功能。它包含两个相关的库:

  • runtime/pprof 对于只跑一次的程序,例如每天只跑一次的离线预处理程序,调用 pprof 包提供的函数,手动开启性能数据采集。
  • net/http/pprof 对于在线服务,对于一个 HTTP Server,访问 pprof 提供的 HTTP 接口,获得性能数据。当然,实际上这里底层也是调用的 runtime/pprof 提供的函数,封装成接口对外提供网络访问。

当 CPU 性能分析启用后,Go runtime 会每 10ms 就暂停一下,记录当前运行的 goroutine 的调用堆栈及相关数据。当性能分析数据保存到硬盘后,我们就可以分析代码中的热点了。

内存性能分析则是在堆(Heap)分配的时候,记录一下调用堆栈。默认情况下,是每 1000 次分配,取样一次,这个数值可以改变。栈(Stack)分配 由于会随时释放,因此不会被内存分析所记录。由于内存分析是取样方式,并且也因为其记录的是分配内存,而不是使用内存。因此使用内存性能分析工具来准确判断程序具体的内存使用是比较困难的。

阻塞分析是一个很独特的分析,它有点儿类似于 CPU 性能分析,但是它所记录的是 goroutine 等待资源所花的时间。阻塞分析对分析程序并发瓶颈非常有帮助,阻塞性能分析可以显示出什么时候出现了大批的 goroutine 被阻塞了。阻塞性能分析是特殊的分析工具,在排除 CPU 和内存瓶颈前,不应该用它来分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import _ "net/http/pprof"

func initPprofMonitor() error {
	pPort := global.Conf.MustInt("http_server", "pprofport", 8080)

	var err error
	addr := ":" + strconv.Itoa(pPort)

	go func() {
		err = http.ListenAndServe(addr, nil)
		if err != nil {
			logger.Error("funcRetErr=http.ListenAndServe||err=%s", err.Error())
		}
	}()

	return err
}

先使用 cpu profile 找出耗时最多的函数,进行优化。如果发现 gc 执行比较多的时候,找出内存分配最多的代码以及引发内存分配的函数,进行优化。

竞态检测

当你处理多个 routine 共享某类资源的时候,不可避免的需要考虑到这个

当设置了-race命令行标志时,编译器将使用访问内存的时间和方式的代码记录下来,用于设置所有内存访问, 而运行时库会监视对共享变量的不同步访问。 当检测到这种“racy”行为时,会打印一个警告。

由于其设计,竞态探测器只能在运行代码实际触发时才能检测到竞争条件,这意味着需要在真实的工作负载下运行启用探测器。 然而,启用竞态探测的可执行文件可能使用十倍的CPU和内存,因此始终启用探测器是不切实际的。 出于这个困境的一个办法是在启用竞态探测的情况下运行一些测试。 负载测试和集成测试是很好的候选者,因为它们往往会执行代码的并发部分。 另外的可选途径:生产工作负载环境中, 在运行的服务器池中, 部署单个启用竞态探测的实例。

1
2
# 看汇编
go build -gcflags -S trace.go
1
go run -race main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var Counter int = 0
var Wait sync.WaitGroup

func TestTrace() {

	for routine := 1; routine <= 2; routine++ {

		Wait.Add(1)
		go Routine(routine)
	}

	Wait.Wait()
	fmt.Printf("Final Counter: %d\n", Counter)
}

func Routine(id int) {

	for count := 0; count < 2; count++ {

		Counter++ // 检测到这里出问题
		time.Sleep(1 * time.Nanosecond)
	}

	Wait.Done()
}

image-20210829213724871

内存逃逸

Go 中变量分配在栈还是堆上完全由编译器决定,而原本看起来应该分配在栈上的变量,如果其生命周期获得了延长,被分配在了堆上,就说它发生了逃逸

如果工程师能够精准地为每一个变量分配合理的空间,那么整个程序的运行效率和内存使用效率一定是最高的,但是手动分配内存会导致如下的两个问题:

  1. 不需要分配到堆上的对象分配到了堆上 — 浪费内存空间;
  2. 需要分配到堆上的对象分配到了栈上 — 悬挂指针、影响内存安全

在编译器优化中,逃逸分析是用来决定指针动态作用域的方法。Go 语言的编译器使用逃逸分析决定哪些变量应该在栈上分配,哪些变量应该在堆上分配,其中包括使用 newmake 和字面量等方法隐式分配的内存,Go 语言的逃逸分析遵循以下两个不变性:

  1. 指向栈对象的指针不能存在于堆中;
  2. 指向栈对象的指针不能在栈对象回收后存活;
1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
	var a [1]int
	c := a[:]
	fmt.Println(c)
}
1
2
3
4
5
6
7
8
9
10
11
$ go tool compile -m main.go
main.go:8:13: inlining call to fmt.Println
main.go:6:6: moved to heap: a  # 第 6 行的变量 a 分配到了堆上
main.go:8:13: c escapes to heap # 变量 c 逃逸到了堆上
main.go:8:13: []interface {} literal does not escape
<autogenerated>:1: .this does not escape
<autogenerated>:1: .this does not escape

# 逃逸分析
go build -gcflags=-m main.go

如果变量具有地址,就作为堆分配的候选,但如果逃逸分析可以确定其生存周期不会超过函数返回,就会分配在栈上

变量发生逃逸的情况可以总结

  • 方法内返回局部变量指针 返回时被外部引用,因此其生命周期大于栈,则溢出
  • 发送指针或带有指针的值到 channel 中 编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据。所以编译器没法知道变量什么时候才会被释放。
  • 在一个切片上存储指针或带指针的值 导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。
  • slice append 时可能会超出其容量( cap ) 它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配
  • 在 interface 类型上调用方法。 在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。Printf Sprintf等等