Golang 中的垃圾回收(三)

原文链接
通过前两节的说明,我们得出这样一个结论:如果降低堆内存的分配压力就会相应的减少延迟,从而提升程序性能。这一节来讲一下,给一种类型的工作负载,GC的pacing算法是怎么来确定最佳回收速率的。
并发代码实例
本节给出的代码在这里可以找到:
github.com/ardanlabs/g…
程序是做了这样一件事情,给一个特定topic,要确定它在文档集中出现的频率。程序包含了不用版本的寻找算法,它们使用了不同的并发模式。这里我们只看freq,freqConcurrent和freqNumCPU这三种版本的算法。
首先看freq,它是非并发顺序执行的程序版本,代码如下。
L1

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
01 func freq(topic string, docs []string) int {
02 var found int
03
04 for _, doc := range docs {
05 file := fmt.Sprintf("%s.xml", doc[:8])
06 f, err := os.OpenFile(file, os.O_RDONLY, 0)
07 if err != nil {
08 log.Printf("Opening Document [%s] : ERROR : %v", doc, err)
09 return 0
10 }
11 defer f.Close()
12
13 data, err := ioutil.ReadAll(f)
14 if err != nil {
15 log.Printf("Reading Document [%s] : ERROR : %v", doc, err)
16 return 0
17 }
18
19 var d document
20 if err := xml.Unmarshal(data, &d); err != nil {
21 log.Printf("Decoding Document [%s] : ERROR : %v", doc, err)
22 return 0
23 }
24
25 for _, item := range d.Channel.Items {
26 if strings.Contains(item.Title, topic) {
27 found++
28 continue
29 }
30
31 if strings.Contains(item.Description, topic) {
32 found++
33 }
34 }
35 }
36
37 return found
38 }

非并发版本代码会去遍历文件集合,并执行以下4中操作:打开,读文件,解码和search,每次只处理一个文件。
运行freq,得到如下信息。
L2

1
2
3
$ time ./trace
2019/07/02 13:40:49 Searching 4000 files, found president 28000 times.
./trace 2.54s user 0.12s system 105% cpu 2.512 total

复制代码可以看到程序处理4000个文件花费了大约2.5s的时间。如果能够看到gc的实际情况就更好了,你可以使用trace包来生成trace信息。
L3

1
2
3
4
5
03 import "runtime/trace"
04
05 func main() {
06 trace.Start(os.Stdout)
07 defer trace.Stop()

L3中引入了runtime/trace包。
重新编译运行代码,不要忘记把标准输出重定向到文件里。
L4

1
2
3
4
$ go build
$ time ./trace > t.out
Searching 4000 files, found president 28000 times.
./trace > t.out 2.67s user 0.13s system 106% cpu 2.626 total

复制代码正如我们预料的,运行时间增加了大概100ms。trace获取了每一次的方法调用的执行时间,达到微秒的级别。t.out包含了trace的数据。
我们可以使用下面的命令来查看trace信息。

1
$ go tool trace t.out

运行命令后会自动开一个浏览器窗口,如下图所示
图1.1

图1.1给了9个链接,现在我们只关心View trace。点击链接,看到如下图示:
图1.2

图2是程序运行的trace图。这部分我会主要关注垃圾回收器相关的东西,图中的Heap和GC部分。
图1.3

图1.3更近一些去看前200ms的trace信息。注意Heap信息(绿色和橘黄色区域),以及GC(底下蓝色的竖线)。Heap信息告诉了你两件事情。橘色区域是当前时间正在使用的堆空间大小。绿色部分代表,触发下次回收,in-used堆内存空间。也就是说,每次橘色区域达到顶峰的时候,便开始进行垃圾回收。蓝色竖线代表了垃圾回收。
这个版本的程序运行中,堆中in-use的内存保持在大约4MB。要看一次单独垃圾回收的统计信息,可以使用选择工具框住蓝线。
图1.4
!()[https://user-gold-cdn.xitu.io/2019/7/30/16c42dd4681fa5d2?imageView2/0/w/1280/h/960/format/webp/ignore-error/1]
图1.4格里的数字,代表了选中区域的时间量。图中大概是316ms,当选中了所有蓝线,会出现下面统计信息
图1.5

图5中代表了图表中所有蓝线都在标记点15.911ms到2.596s之间。一共发生了232次回收,占用了64.524ms,平均每次回收时间是287.121微秒。了解到程序运行时间是2.626s,也就是说,垃圾回收占用了总时间的2%,基本上垃圾回收的代价对程序运行来说是微不足道的。
下面来看一下使用算法的并发版,我们希望可以加速程序运行。
L6

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
01 func freqConcurrent(topic string, docs []string) int {
02 var found int32
03
04 g := len(docs)
05 var wg sync.WaitGroup
06 wg.Add(g)
07
08 for _, doc := range docs {
09 go func(doc string) {
10 var lFound int32
11 defer func() {
12 atomic.AddInt32(&found, lFound)
13 wg.Done()
14 }()
15
16 file := fmt.Sprintf("%s.xml", doc[:8])
17 f, err := os.OpenFile(file, os.O_RDONLY, 0)
18 if err != nil {
19 log.Printf("Opening Document [%s] : ERROR : %v", doc, err)
20 return
21 }
22 defer f.Close()
23
24 data, err := ioutil.ReadAll(f)
25 if err != nil {
26 log.Printf("Reading Document [%s] : ERROR : %v", doc, err)
27 return
28 }
29
30 var d document
31 if err := xml.Unmarshal(data, &d); err != nil {
32 log.Printf("Decoding Document [%s] : ERROR : %v", doc, err)
33 return
34 }
35
36 for _, item := range d.Channel.Items {
37 if strings.Contains(item.Title, topic) {
38 lFound++
39 continue
40 }
41
42 if strings.Contains(item.Description, topic) {
43 lFound++
44 }
45 }
46 }(doc)
47 }
48
49 wg.Wait()
50 return int(found)
51 }

L6给出了freq算法的一个并发版。这个程序版本使用了扇出模式。每个一个docs列出的文件,都去创建一个goroutine去出里。如果有4000个文档,那么就要去创建4000个goroutine。这个版本的好处是,最简单的方式使用了并发。每个goroutine处理一个文件。我们可以使用WaitGroup和原子操作来保持计数器的同步处理。
在程序运行早期,所有goroutines都会分配时间去运行,也就是说程序会快速消耗大量的内存。在12行found增加这里,也会产生cache一致性的问题。由于每个虚拟内核共享这个变量的相同cache行,这会导致程序内存抖动,如果文件数或者内核数增加话,这种问题会更加严重。
我们重新编译代码然后再次运行
L7

1
2
3
4
$ go build
$ time ./trace > t.out
Searching 4000 files, found president 28000 times.
./trace > t.out 6.49s user 2.46s system 941% cpu 0.951 total

L7中可以看到,程序现在处理4000个文件的时间是951ms,大概是64%的性能提升,来看一下trace信息。
图1.6

程序开始的时候,图中会有很多密集的线。这是因为所有goroutines都被创建了,他们运行并且尝试进行堆内存分配。只要第一个4MB内存分配了之后,就会开始GC。在GC期间,每个Goroutine都会去运行,并且当它们在堆上请求内存时,大多数都会进入等待状态。GC结束时,至少有9个goroutines还在继续运行,并且堆内存涨到了大约26MB。
图1.7

图1.7中你可以看到,第一次GC的时候,有一大堆处于Running或者是Runnable状态的goroutines。第一次GC结束之后,后面的GC时间就很快了,而且GC不再像上一个版本代码那样有规律。
如果你选择了图表上所有的回收,你会得到如下统计信息
图1.8

图1.8给出了,从4.828ms到906.939ms内。一共有23次垃圾回收,占用时间284.447ms,平均收集时间为12.367ms。程序运行时间是951ms,这意味着垃圾回收占用了总时间的34%。
可以对比出程序性能和GC时间的不同。运行更多goroutines确实让程序运行速度提升了64%。但是代价就是需要更多的机器资源,如果在同一时间,一下子达到了200MB in-use堆内存这个峰值的话就会很糟糕。
在并发版本的基础上,下面的并发版本,我们来更有效率更合理的来利用资源。
L8

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
01 func freqNumCPU(topic string, docs []string) int {
02 var found int32
03
04 g := runtime.NumCPU()
05 var wg sync.WaitGroup
06 wg.Add(g)
07
08 ch := make(chan string, g)
09
10 for i := 0; i < g; i++ {
11 go func() {
12 var lFound int32
13 defer func() {
14 atomic.AddInt32(&found, lFound)
15 wg.Done()
16 }()
17
18 for doc := range ch {
19 file := fmt.Sprintf("%s.xml", doc[:8])
20 f, err := os.OpenFile(file, os.O_RDONLY, 0)
21 if err != nil {
22 log.Printf("Opening Document [%s] : ERROR : %v", doc, err)
23 return
24 }
25
26 data, err := ioutil.ReadAll(f)
27 if err != nil {
28 f.Close()
29 log.Printf("Reading Document [%s] : ERROR : %v", doc, err)
23 return
24 }
25 f.Close()
26
27 var d document
28 if err := xml.Unmarshal(data, &d); err != nil {
29 log.Printf("Decoding Document [%s] : ERROR : %v", doc, err)
30 return
31 }
32
33 for _, item := range d.Channel.Items {
34 if strings.Contains(item.Title, topic) {
35 lFound++
36 continue
37 }
38
39 if strings.Contains(item.Description, topic) {
40 lFound++
41 }
42 }
43 }
44 }()
45 }
46
47 for _, doc := range docs {
48 ch <- doc
49 }
50 close(ch)
51
52 wg.Wait()
53 return int(found)
54 }

L8中给出了freqNumCPU版本的代码。这个设计模式的核心就是使用了池化模式。我们使用基于逻辑处理器数的goroutines池子,去处理所有的文件。如果是12个逻辑处理器,那么就是12个goroutines。这种方式的好处是,它会从开始到结束持续地让资源被使用。在任何时间,只有12个goroutines去申请内存。这也解决了由于cache一致性导致的内存抖动。
重新编译程序,再次运行。
L9

1
2
3
4
$ go build
$ time ./trace > t.out
Searching 4000 files, found president 28000 times.
./trace > t.out 6.22s user 0.64s system 909% cpu 0.754 total

可以看到L9中,同样4000个文件,花费了754ms。相比上个程序块了200ms,下面看下trace
图1.9

图1.9里,可以看到全部CPU容量都被使用,仔细观察可以看到,这个版本代码和顺序版本类似,GC比较有规律。
图1.10

图10给出了前20ms的程序trace图。12个goroutines运行,垃圾回收相对顺序版本时间更长一些。内存使用量维持在4MB之内。
框住所有垃圾回收,可以看到下面信息。
图1.11

图1.11,给出了3.055ms到719.928ms之间。产生了467次垃圾回收,占用177.709ms,平均回收时间380.535微秒。程序运行时间754ms,也就是说垃圾回收占了25%的全部时间。相比上一个版本有9%的提升。
如果文件数或者是虚拟核数更多的话,池化版本似乎会有更好的扩展,代码复杂性的代价是值得的。
结论
我们主要关注的就是不同版本代码的GC情况。总的内存分配其实每个版本都是一样的,不同只是如何去分配。
当只有一个goroutine,4MB的内存基本够用了。当程序一次性把所有work都扔过来,GC会让触发GC的in-used堆内存增加,减少GC次数但同时增加了GC时间。当程序控制每次并发处理file的数量的时候,GC会采取使用保持小堆的方式,增加了GC次数但是减少了每次GC时间。每种方式GC都会尽力让其对程序产生的影响最小。

1
2
3
4
5
| Algorithm  | Program | GC Time  | % Of GC | # of GC’s | Avg GC   | Max Heap |
|------------|---------|----------|---------|-----------|----------|----------|
| freq | 2626 ms | 64.5 ms | ~2% | 232 | 278 μs | 4 meg |
| concurrent | 951 ms | 284.4 ms | ~34% | 23 | 12.3 ms | 200 meg |
| numCPU | 754 ms | 177.7 ms | ~25% | 467 | 380.5 μs | 4 meg |

复制代码freqNumCPU 版本会更好的处理cache一致性上的问题,这很有用。
综上,程序运行时候我们还是可以把一堆work扔过去处理。例如一个web服务使用50k的goroutines,本质上就是类似于第一种并发算法的扇出模式。GC会考虑workload然后找到最优的步调去进行。但是一些情况下也需要考虑相应的代价。
原文链接:www.ardanlabs.com/blog/2019/0…

-------------本文结束 感谢阅读-------------