【译】 Golang 中的垃圾回收(三):Go Pacing

前言

这系列的文章如下

  1. Golang 中的垃圾回收(一)
  2. Golang 中的垃圾回收(二):Go Traces
  3. Golang 中的垃圾回收(三):Go Pacing

通过前两节的说明,我们得出这样一个结论:如果降低堆内存的分配压力就会相应的减少延迟,从而提升程序性能。这一节来讲一下,给一种类型的工作负载,GC的pacing算法是怎么来确定最佳回收速率的。

并发代码实例

本节给出的代码在这里可以找到: github.com/ardanlabs/g…

程序是做了这样一件事情,给一个特定topic,要确定它在文档集中出现的频率。程序包含了不用版本的寻找算法,它们使用了不同的并发模式。这里我们只看freqfreqConcurrentfreqNumCPU这三种版本的算法。

首先看freq,它是非并发顺序执行的程序版本,代码如下。

L1

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

$ 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

扫描二维码关注公众号,回复: 12874800 查看本文章
03 import "runtime/trace"
04
05 func main() {
06     trace.Start(os.Stdout)
07     defer trace.Stop()

复制代码

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

L4

$ 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信息。

$ 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

 

图1.4格里的数字,代表了选中区域的时间量。图中大概是316ms,当选中了所有蓝线,会出现下面统计信息

图1.5

 

图5中代表了图表中所有蓝线都在标记点15.911ms到2.596s之间。一共发生了232次回收,占用了64.524ms,平均每次回收时间是287.121微秒。了解到程序运行时间是2.626s,也就是说,垃圾回收占用了总时间的2%,基本上垃圾回收的代价对程序运行来说是微不足道的。

下面来看一下使用算法的并发版,我们希望可以加速程序运行。

L6

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

$ 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

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

$ 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都会尽力让其对程序产生的影响最小

| 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…


作者:lucifer_L49715
链接:https://juejin.cn/post/6844903902924521485
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

猜你喜欢

转载自blog.csdn.net/pyf09/article/details/114825135