面向高性能的 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 , uintptr (len )*unsafe.Sizeof(t), syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_ANON|syscall.MAP_PRIVATE, uintptr (fd), 0 , ) 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 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 包含以下成员:
效果 我们使用 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() 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: