原文地址:https://www.ardanlabs.com/blog/2019/07/garbage-collection-in-go-part3-gcpacing.html
介绍
在第二篇文章中,我向你展示了垃圾收集器的行为以及如何使用工具查看垃圾收集器对正在运行的应用程序造成的延迟。我引导你运行了一个实际的Web应用程序,并展示了如何生成GC跟踪和应用程序配置文件。然后,我向你展示了如何解释这些工具的输出,以便你找到提高应用程序性能的方法。
该文章的最终结论与第一个结论相同:如果减轻了堆的压力,则会减少延迟,从而提高应用程序的性能。优化垃圾收集器的最佳策略是减少每次执行的工作的数量或分配量。在这篇文章中,我将展示pacing算法如何能够确定一段时间内计算出工作负载的最佳速度。
并发示例代码
我将使用下面链接中的代码。
https://github.com/ardanlabs/gotraining/tree/master/topics/go/profiling/trace
这个程序可以在RSS新闻提要文档的集合中找到特定主题的出现频率。跟踪程序包含不同版本的查找算法,以测试不同的并发模式。我将重点介绍freq
,freqConcurrent
和freqNumCPU
的算法版本。
注意:我使用的是go1.12.7,在具有12个硬件线程的Intel i9处理器的Macbook Pro上运行代码。在不同的体系结构,操作系统和Go版本上会看到不同的结果。本文的核心结果会相同。
我将首先从freq
版本开始。它代表程序的非并行版本。这将为随后的并发版本提供基准。
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 }
上面显示了freq
函数。顺序遍历文件名的集合,并执行四个操作:打开,读取,解码和搜索。
在计算机上运行此版本的freq
时,会得到以下结果。
$ 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
你可以通过输出看到,该程序大约需要2.5秒来处理4000个文件。很高兴我们知道了垃圾回收所花费的时间百分比。你可以通过查看程序的追踪来做到这一点。由于这是一个启动和完成的程序,因此可以使用trace包来生成追踪信息。
03 import "runtime/trace"
04
05 func main() {
06 trace.Start(os.Stdout)
07 defer trace.Stop()
上面展示了程序生成追踪信息所需的代码。从runtime
标准库的文件夹中导入trace
软件包后,调用trace.Start
和trace.Stop
。为了简化代码,将结果输出指向os.Stdout
。
使用此代码后,现在你可以重新生成并运行该程序。不要忘记重定向stdout
到一个文件。
$ 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
运行时新增了超过100毫秒的时间,但这是可以预期的。这里捕获了每个函数的调用。现在有一个名为t.out
的文件,其中包含追踪结果数据。
要查看跟踪结果,需要通过跟踪工具运行跟踪数据。
$ go tool trace t.out
运行该命令会启动Chrome浏览器。
注意:跟踪工具正在使用Chrome浏览器中内置的工具。此工具仅适用于Chrome。
图1

图1显示了启动跟踪工具时显示的9个链接。现在的重要链接是第一个链接View trace
。选择该链接后,你将看到与以下内容相似的内容。
图2

图2显示了在我的计算机上运行程序的完整跟踪窗口。在这篇文章中,我将重点介绍与垃圾收集器相关的部分。
图3

图3详细显示了追踪的前200毫秒。将注意力集中在Heap
(绿色和橙色区域)和GC
(底部蓝线)上。Heap
向你展示了两件事。橙色区域是在任何给定的微秒内堆上当前的使用空间。绿色是将触发下一个回收的堆中正在使用的空间量。这就是为什么每次橙色区域到达绿色区域的顶部都会进行垃圾收集的原因。蓝线代表垃圾收集。
在此版本的程序中,整个程序运行期间,堆中正在使用的内存保持在〜4 meg。要查看发生的所有单个垃圾收集的统计信息,请使用选择工具并在所有蓝线周围绘制一个框。
图4

图4显示了如何使用箭头工具在蓝线周围绘制蓝框。框内的数字表示从图表中选择的项目所消耗的时间。在这个例子中接近316毫秒(ms,μs,ns)来生成此图像。选择所有蓝线后,将提供以下统计信息。
图5

图5显示图中的所有蓝线都在15.911毫秒标记到2.596秒标记之间。有232个垃圾收集使用了64.524毫秒的时间,平均收集时间为287.121微秒。知道程序运行需要2.626秒,这意味着垃圾回收仅占总运行时间的2%。从本质上讲,垃圾收集器对于运行该程序来说是微不足道的成本。
有了基线,可以使用并发算法来执行相同的工作并加快程序运行速度。
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 }
上面展示了一个freq
可能的并发版本。此版本的核心设计模式是使用扇出模式。对于docs
集合中列出的每个文件都会创建一个goroutine来处理该文件。如果要处理4000个文档,则使用4000个goroutine。该算法的优点是它是利用并发的最简单方法。每个goroutine只处理1个文件。等待每个要处理的文档的流程可以使用WaitGroup
执行,原子指令可以使计数器保持同步。
该算法的缺点是,它无法随着文档或核心的数量很好地扩展。所有的goroutine都将在程序启动时尽早运行,这意味着大量内存将被快速消耗掉。在第12行found
添加变量也存在缓存一致性问题。由于每个内核为此变量共享相同的缓存行,这将导致内存崩溃。随着文件或核心数量的增加,情况变得更糟。
使用此代码后你可以重新生成并运行该程序。
$ 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
从上面的输出中看到,现在需要951毫秒来处理相同的4000个文件。性能提高约64%。
图6

图6显示了此版本程序使用了我计算机上更多的CPU。图的开头密度很大。这是因为在创建所有goroutine时,它们将运行并开始尝试在堆中分配内存。一旦分配了前4兆的内存(很快),就会启动GC。在此GC期间,每个Goroutine都有时间运行,并且大多数在请求堆上的内存时会进入等待状态。到该GC完成时,至少有9个goroutine可以继续运行并将堆增长到〜26M。
图7

图7显示了第一台GC的很大一部分处于可运行状态和正在运行状态的goroutine数量,以及如何迅速重新启动它们。请注意,堆概要看起来是不规则的,并且收集没有像以前那样有规律地进行。如果仔细观察,第二个GC几乎会在第一个GC之后立即启动。
如果你选择此图中的所有集合,您将看到以下内容。
图8

图8显示图中的所有蓝线都在4.828毫秒到906.939毫秒之间。有23个垃圾回收使用了284.447毫秒的时间,平均收集时间为12.367毫秒。已知程序需要951毫秒才能运行完成,这意味着垃圾回收约占总运行时间的34%。
与顺序版本相比,这在性能和GC时间上都存在重大差异。但是,以并行方式运行更多goroutine可以使工作完成速度提高约64%。成本是需要更多的机器资源。不幸的是,在高峰时,堆上一次使用了约200兆的内存。
有了并发基准,下一个并发算法将尝试利用资源提高效率。
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 }
上面显示了freqNumCPU
的版本。此版本的核心设计模式是使用池。基于逻辑处理器的数量的goroutine
池,用于处理所有文件。如果有12个逻辑处理器可供使用,则使用12个goroutine。该算法的优势在于,它使程序的资源使用从头到尾保持一致。由于使用了固定数量的goroutine,因此仅需要在任何给定时间的那12个goroutine所需的存储器。这也解决了内存抖动导致的缓存一致性问题。这是因为在第14行上对原子指令的调用只需要发生固定的很少的次数。
该算法的缺点是复杂化。它增加了使用通道来填充goroutine池的所有工作。在使用池的任何时间,为池标识“正确”数量的goroutine都是很复杂的。通常,我给每个逻辑处理器1个goroutine来启动池。然后执行负载测试或使用生产指标,可以计算出池的最终值。
使用此代码后,我们再次编译并运行代码。
$ 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
从上面的输出我们可以看见,程序现在需要754毫秒来处理相同的4000个文件。该程序的速度要快200毫秒左右,这对于这种小负载而言非常重要。看一下追踪结果。
图9

图9显示了此版本程序如何使用计算机上的所有CPU容量。如果仔细观察,该程序具有一致的节奏。与顺序版本非常相似。
图10

图10显示了程序的前20毫秒中更仔细地查看核心指标。垃圾回收肯定比顺序版本长,但是有12个goroutine运行。在程序的整个运行过程中,堆中正在使用的内存保持在4兆左右。同样,与程序的顺序版本相同。
如果您选择此图中的所有集合,您将看到以下内容。
图11

图11显示图中的所有蓝线都在3.055毫秒到719.928毫秒之间。有467个垃圾收集使用177.709毫秒的时间,平均收集时间为380.535微秒。已知该程序需要754毫秒,这意味着垃圾回收约占总运行时间的25%。与其他并发版本相比提高了9%。
此版本的并发算法似乎可以通过更多文件和内核来更好地扩展。在我看来,复杂性成本是值得的。可以通过将列表切成每个Goroutine的工作桶来替换通道。尽管可以减少通道引起的某些等待时间成本,但这肯定会增加更多的复杂性。对于更多的文件和内核,这可能很重要,但是需要衡量复杂性成本。如果感兴趣你可以自己尝试一下。
结论
我喜欢比较算法的三个版本的原因是GC如何处理每种情况。在任何版本中,处理文件所需的内存总量不会改变。变化的是程序的分配方式。
当只有一个goroutine时,只需要一个4兆的基本堆。当程序一次在运行时放弃所有工作时,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
版本还有其他功能,例如更好地处理缓存一致性,这很有帮助。但是,每个程序的GC时间总量差异非常接近,分别为〜284.4 ms和〜177.7 ms。有时在我的计算机上运行该程序,这些数字甚至更接近。使用1.13.beta1版进行一些实验,我已经看到两种算法都在相同的时间运行。可能暗示它们可能仍有提升空间,这些改进使GC可以更好地预测运行方式。
所有这些使我有信心在运行时投入大量工作。比如一个使用50k goroutine的Web服务,这实际上是一种类似于第一个并发算法的扇出模式。GC将研究工作量,并找到使服务摆脱困境的最佳速度。
网友评论