Go GC 调优思路

面向高性能的 Go GC 调优思路

0

最简单的优化思路是别浪费时间优化了,转 Rust,R 门!

背景

FG 是一个纯内存计算服务,其特点是:核心数据使用 map 存储,请求到达服务后,查询 map,进行计算并返回。服务拉起时启动一个协程定时从 DB 获取数据刷新此 map。

计算逻辑已被优化到几乎最佳,当前最大的性能瓶颈是 GC,由于 map 中存储了大量 k-v 对,且 value 是个复杂结构体,会逃逸到堆上,导致堆上存在大量可达的存活对象, GC Pause 较长,平均在几 ms 到几百 ms,且出现过 GC Pause 超过 1s 的极端场景。这带来的后果是:单实例吞吐量降低,时延优化受阻,毛刺多。

特点:高并发,核心读接口QPS100w+,单实例(16C32G) QPS 5k+,低时延:要求核心读接口 P99<1ms。

目标:优化 GC,降低 GC Pause

此方案存在很多针对 FG 服务特性的特化,但是 GC 逃逸、堆栈分析等思路是共通的。

性能分析

使用 Pprof,查看火焰图以及堆使用情况,发现问题根源是堆上的大量存活对象,导致了:

  • GC 标记阶段,待标记对象多,并行标记的线程占用大量 CPU 资源。
  • GC 清理阶段,待清理对象多,STW 时间增长。

读写分离

将 map 分为读写两个 map,并在完成数据刷新后交换。这意味着任何时刻,内存中都存在一个只读的 map 和一个只写的 map,从而不再需要保障并发安全,因为写是单协程的。

这么做的另外一个好处是,方便牺牲写 map 的性能,来换取更好的堆分配和更好的读性能。

堆栈与内联分析

  • 写场景避免堆分配
    • 在写场景,case by case 的分析传值是否会产生堆逃逸,尽可能的减少逃逸到堆上的对象,最直观的方式是:尽可能使用值传递,尽管会产生复制的性能损耗,但是写 map 的性能变差是可接受的;此外,在某些场景下,可以牺牲代码可读性来减少函数调用,如明确不会内联的场景,会在传递值代价很大的情况下,取消函数调用,直接合并到调用函数内。
  • 读场景避免值复制
    • 在读场景,策略则和写场景相反,读场景会尽量使用指针传递,降低值复制的代价。不过内联策略则是一致的。
  • 内联优化
    • 我们配置了 -gcflag=’-l -l’,牺牲二进制文件的大小来换取更多的内联函数,从而减少值在栈上的传递甚至逃逸到堆上。

GC 逃逸

前述的优化只是在尽可能减少堆上的对象,但此服务的对象多数都来自 map 中的数十万的 k-v 对,因此最大的瓶颈是如何优化 map。

很显然,这两个 map 以及其中的对象大部分都是长期不变的,之前见过另外一个 Java 服务有类似场景,他们的思路是通过 UNSAFE 修改对象头,在初始化对象的时候直接将其存活代数改为 15,从而不需要经过 ygc 直接分配到老年代。

1
2
3
4
5
6
7
8
9
10
public static void updateObjAge(Object obj, int age) { 
if(!toUpdateAgeReady || obj == null) {
return;
}

Long mark = UNSAFE.getLong(obj, 0L);
long result = mark & AGE_64_MASK | ((long)(age & 0xF)) << 3;

UNSAFE.putLong(obj, 0L, result);
}

然而 Go 没有分代 GC,但一个类似的思路是:能否让这些对象对 Runtime 不可见,从而避开 GC?虽然可能会内存泄露,但是只要手动管理好这些内存,收益会是很可观的。

基于此,我们的想法便很清晰了:如何在 Go 中做到 GC 逃逸?Arena 似乎可行,但它现在还是实验特性,并且 Proposal 被无限期搁置了。但 Arena 的思想我们可以偷一下,于是便有了这个思路。

核心思路

在服务拉起时,绕开语言 API,使用内核 API sys_mmap 直接向 OS 申请内存,自行管理此内存。

优势:这一片内存是根对象不可达的,Go Runtime 检测不到这一片内存,标记和清理压力会大幅降低。

劣势:需要手动管理内存。

gcescape

https://github.com/hyphennn/gcescape

广告时间:强烈推荐,一个超快的 go collection 库,可以帮助你超大幅度的降低 gc 时间,超过 99%!

实现

弃用标准库 map,自行实现 GCEscapeMap,使用 GCEscapeMap 作为核心数据的存储。

核心数据结构:

1
2
3
4
5
6
type GCEscapeMap[T any] struct {
s []T
realCap int
realLen int
idxMap map[int]int
}

核心方法:向内核申请内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func makeSlice[T any](len int) reflect.SliceHeader {
fd := -1
var t T
data, _, errno := syscall.Syscall6(
syscall.SYS_MMAP,
0, // address
uintptr(len)*unsafe.Sizeof(t),
syscall.PROT_READ|syscall.PROT_WRITE,
syscall.MAP_ANON|syscall.MAP_PRIVATE,
uintptr(fd), // No file descriptor
0, // offset
)
if errno != 0 {
panic(errno)
}

return reflect.SliceHeader{
Data: data,
Len: len,
Cap: len,
}
}

基于申请的内存初始化 GCEscapeMap

1
2
3
4
5
6
7
8
9
10
// 初始化 GCEscapeMap
func NewGCEscapeMap[T any](cap int) *GCEscapeMap[T] {
var t T
if reflect.TypeOf(t).Kind() == reflect.Pointer {
panic("no ptr allowed")
}
data := makeSlice[T](cap)
s := *(*[]T)(unsafe.Pointer(&data))
return &GCEscapeMap[T]{s: s, realCap: cap, realLen: 0, idxMap: make(map[int]int, cap)}
}

读&写 map

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func (e *GCEscapeMap[T]) Push(k int, v T) {
idx, ok := e.idxMap[k]
if ok {
e.s[idx] = v
} else {
e.s[e.realLen] = v
e.realLen++
e.CheckAndScale()
}
}


func (e *GCEscapeMap[T]) Get(k int) (*T, bool) {
idx, ok := e.idxMap[k]
if ok {
return &e.s[idx], true
}
return nil, false
}

e.CheckAndScale(): 此方法用于检测 realLen 和 realCap 的比值,在超过「安全阈值」时会开始告警,在超过「扩容阈值」时会开始主动扩容并告警,其原理和 Slice 一致,如果主动扩容失败,将中止读写 map 交换并告警。其中会存在大量业务和告警代码,因此不写了。

需要指出的是,主动扩容是非常危险、代价非常高的操作,因此我们需要尽可能避免其出现:关注实例内存用量;关注 GCEscapeMap 当前用量。

原理分析

GCEscapeMap 包含以下成员:

  • s 是数据真实存储的位置,由于其是直接向 OS 申请的,因此不会被 Runtime 扫描到。
  • realCap 和 realLen 用于控制 s 来实现类似 slice 的功能,因为 s 实际是定长的。
  • idxMap 是用于实现 map 能力的,其 k,v 都是 int,因此会被 Runtime 忽略(Go 1.5 开始提供此特性:https://go-review.googlesource.com/c/go/+/3288)

效果

我们使用 benchmark 来检验性能,并使用 Pprof 来观察堆情况

benchmark:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
func BenchmarkEmap(b *testing.B) {
m := emap.NewGCEscapeMap(2000000)

for i := 0; i < 1000000; i++ {
m.Push(i, TestAim{
Str: "1",
Map: map[string]string{"1": "1"},
Value: 0,
Str2: "1",
Str3: "1",
Str4: "1",
Str5: "1",
Str6: "1",
Str7: "1",
Str8: "1",
Value2: 0,
Value3: 0,
Value4: 0,
Value5: 0,
Value6: 0,
Value7: 0,
})
}
for i := 0; i < b.N; i++ {
m.Get(i)
}
}

func BenchmarkNormalMap(b *testing.B) {
m := make(map[int]emap.Aim, 1000000)

for i := 0; i < 1000000; i++ {
m[i] = TestAim{
Str: "1",
Map: map[string]string{"1": "1"},
Value: 0,
Str2: "1",
Str3: "1",
Str4: "1",
Str5: "1",
Str6: "1",
Str7: "1",
Str8: "1",
Value2: 0,
Value3: 0,
Value4: 0,
Value5: 0,
Value6: 0,
Value7: 0,
}
}

for i := 0; i < b.N; i++ {
_ = m[i]
}
}

Pprof

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import (
"net/http"
_ "net/http/pprof"
)

func main() {
go func() {
http.ListenAndServe("0.0.0.0:8080", nil)
}()
time.Sleep(time.Second)
UseGCEscapeMap()
//UseNormalMap()
time.Sleep(1000 * time.Second)
}

func UseGCEscapeMap() {
m := emap.NewGCEscapeMap[TestAim](2000000)

for i := 0; i < 1000000; i++ {
m.Push(i, TestAim{
Str: "1",
Map: map[string]string{"1": "1"},
Value: 0,
Str2: "1",
Str3: "1",
Str4: "1",
Str5: "1",
Str6: "1",
Str7: "1",
Str8: "1",
Value2: 0,
Value3: 0,
Value4: 0,
Value5: 0,
Value6: 0,
Value7: 0,
})
}

for i := 0; i < 10; i++ {
st := time.Now()
runtime.GC()
fmt.Printf("GC took %s\n", time.Since(st))
time.Sleep(time.Second)
}

runtime.KeepAlive(m)
}

func UseNormalMap() {
m := make(map[int]TestAim, 1000000)

for i := 0; i < 1000000; i++ {
m[i] = TestAim{
Str: "1",
Map: map[string]string{"1": "1"},
Value: 0,
Str2: "1",
Str3: "1",
Str4: "1",
Str5: "1",
Str6: "1",
Str7: "1",
Str8: "1",
Value2: 0,
Value3: 0,
Value4: 0,
Value5: 0,
Value6: 0,
Value7: 0,
}
}

for i := 0; i < 10; i++ {
st := time.Now()
runtime.GC()
fmt.Printf("GC took %s\n", time.Since(st))
time.Sleep(time.Second)
}

runtime.KeepAlive(m)
}

UseGCEscapeMap:

UseNormalMap:


Go GC 调优思路
https://hyphennn.com/2024/03/07/Go-GC-调优/
Author
hyphennn
Posted on
March 7, 2024
Licensed under