(译) Go中的垃圾收集:第二部分 - GC痕迹

(译) Go中的垃圾收集:第二部分 - GC痕迹

这是三部分系列中的第二篇文章,它将提供对Go中垃圾收集器背后的机制和语义的理解。本文重点介绍如何生成GC跟踪并解释它们。

三部分系列的索引:
1)Go中的垃圾收集:第一部分 - 语义
2)Go中的垃圾收集:第二部分 - GC跟踪
3)Go中的垃圾收集:第三部分 - GC步伐

介绍

在第一篇文章中,我花时间描述了垃圾收集器的行为,并显示了收集器对正在运行的应用程序造成的延迟。我分享了如何生成和解释GC跟踪,显示堆上的内存如何变化,并解释了GC的不同阶段以及它们如何影响延迟成本。

该帖子的最终结论是,如果减少堆上的压力,你将减少延迟成本,从而提高应用程序的性能。我还指出,通过寻找增加任何两个系列之间的时间来减少收集开始的步伐并不是一个好的策略。一致的速度,即使它很快,也会更好地保持应用程序以最佳性能运行。

在这篇文章中,我将引导你完成运行真实Web应用程序并向你展示如何生成GC跟踪和应用程序配置文件。然后,我将向你展示如何解释这些工具的输出,以便你可以找到提高应用程序性能的方法。

运行应用程序

看看我在Go学习中使用的这个Web应用程序。


图1
https://github.com/ardanlabs/gotraining/tree/master/topics/go/profiling/project

图1显示了应用程序的外观。此应用程序从不同的新闻提供商下载三组RSS订阅源,并允许用户执行搜索。构建Web应用程序后,将启动该应用程序。

清单1

1
2
$ go build
$ GOGC=off ./project > /dev/null

清单1显示了如何使用GOGC变量设置启动应用程序为off,从而关闭垃圾回收。日志将重定向到/dev/null设备。在应用程序运行时,可以将请求发布到服务器中。

清单2

1
$ hey -m POST -c 100 -n 10000 "http://localhost:5000/search?term=topic&cnn=on&bbc=on&nyt=on"

清单2显示了如何使用该hey工具在服务器上运行使用100个连接的10k请求。一旦通过服务器发送了所有请求,就会产生以下结果。


图2

图2显示了关闭垃圾收集器处理10k请求的直观表示。处理10k请求需要4,188ms,这导致服务器每秒处理~2,387个请求。

打开垃圾收集

为此应用程序启用垃圾收集时会发生什么?

清单3

1
$ GODEBUG=gctrace=1 ./project > /dev/null

清单3显示了如何启动应用程序以查看GC跟踪GOGC变量被删除并替换为GODEBUG变量。将GODEBUG被设定在运行时会生成一个GC跟踪每一个集合发生时。现在可以再次通过服务器运行相同的10k请求。通过服务器发送所有请求后hey,可以分析工具提供的GC跟踪和信息。

清单4

1
2
3
4
5
6
$ GODEBUG=gctrace=1 ./project > /dev/null
gc 3 @3.182s 0%: 0.015+0.59+0.096 ms clock, 0.19+0.10/1.3/3.0+1.1 ms cpu, 4->4->2 MB, 5 MB goal, 12 P
.
.
.
gc 2553 @8.452s 14%: 0.004+0.33+0.051 ms clock, 0.056+0.12/0.56/0.94+0.61 ms cpu, 4->4->2 MB, 5 MB goal, 12 P

清单4显示了运行中第三个和最后一个集合的GC跟踪。自从在收集发生之后通过服务器发送加载以来,我没有显示前两个集合。最后一个集合显示它处理了10k个请求,需要2551个集合(减去前两个集合,因为它们不计算)。

以下是跟踪中每个部分的细分。

清单5

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
gc 2553 @8.452s 14%: 0.004+0.33+0.051 ms clock, 0.056+0.12/0.56/0.94+0.61 ms cpu, 4->4->2 MB, 5 MB goal, 12 P

gc 2553 : The 2553 GC runs since the program started
@8.452s : Eight seconds since the program started
14% : Fourteen percent of the available CPU so far has been spent in GC

// wall-clock
0.004ms : STW : Write-Barrier - Wait for all Ps to reach a GC safe-point.
0.33ms : Concurrent : Marking
0.051ms : STW : Mark Term - Write Barrier off and clean up.

// CPU time
0.056ms : STW : Write-Barrier
0.12ms : Concurrent : Mark - Assist Time (GC performed in line with allocation)
0.56ms : Concurrent : Mark - Background GC time
0.94ms : Concurrent : Mark - Idle GC time
0.61ms : STW : Mark Term

4MB : Heap memory in-use before the Marking started
4MB : Heap memory in-use after the Marking finished
2MB : Heap memory marked as live after the Marking finished
5MB : Collection goal for heap memory in-use after Marking finished

// Threads
12P : Number of logical processors or threads used to run Goroutines.

清单5显示了最后一个集合的实际数字。多亏了hey,这些是运行的性能结果。

清单6

1
2
3
4
5
6
7
8
9
10
Requests            : 10,000
------------------------------------------------------
Requests/sec : 1,882 r/s - Hey
Total Duration : 5,311ms - Hey
Percent Time in GC : 14% - GC Trace
Total Collections : 2,551 - GC Trace
------------------------------------------------------
Total GC Duration : 744.54ms - (5,311ms * .14)
Average Pace of GC : ~2.08ms - (5,311ms / 2,551)
Requests/Collection : ~3.98 r/gc - (10,000 / 2,511)

清单6显示了结果。以下内容提供了更多关于所发生事件的视觉效果。


图3

图3显示了视觉上发生的事情。当收集器打开时,它必须运行〜2.5k次以处理相同的10k请求。每个集合平均以~2.0ms的速度开始,并且运行所有这些集合增加了额外的~1.1秒的延迟。


图4

图4显示了到目前为止应用程序的两次运行的比较。

减少分配

获取堆的配置文件并查看是否有任何可以删除的不必要的产生对象将会很棒。

清单7

1
go tool pprof http://localhost:5000/debug/pprof/allocs

清单7显示了使用该pprof工具调用/debug/pprof/allocs端点从正在运行的应用程序中提取内存配置文件。由于以下代码,该端点存在。

清单8

1
2
3
4
5
import _ "net/http/pprof"

go func() {
http.ListenAndServe("localhost:5000", http.DefaultServeMux)
}()

清单8显示了如何将/debug/pprof/allocs端点绑定到任何应用程序。添加导入以net/http/pprof将端点绑定到默认服务器mux。然后,使用http.ListenAndServer与http.DefaultServerMux恒使端点可用。

一旦分析器启动,该top命令可用于查看分配最多的前6个功能。

清单9

1
2
3
4
5
6
7
8
9
10
11
(pprof) top 6 -cum
Showing nodes accounting for 0.56GB, 5.84% of 9.56GB total
Dropped 80 nodes (cum <= 0.05GB)
Showing top 6 nodes out of 51
flat flat% sum% cum cum%
0 0% 0% 4.96GB 51.90% net/http.(*conn).serve
0.49GB 5.11% 5.11% 4.93GB 51.55% project/service.handler
0 0% 5.11% 4.93GB 51.55% net/http.(*ServeMux).ServeHTTP
0 0% 5.11% 4.93GB 51.55% net/http.HandlerFunc.ServeHTTP
0 0% 5.11% 4.93GB 51.55% net/http.serverHandler.ServeHTTP
0.07GB 0.73% 5.84% 4.55GB 47.63% project/search.rssSearch

清单9显示了如何在列表底部rssSearch显示该函数。此功能迄今为止分配了4.56GB的5.96GB。接下来,是时候rssSearch使用该list命令检查功能的细节。

清单10

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(pprof) list rssSearch
Total: 9.56GB
ROUTINE ======================== project/search.rssSearch in project/search/rss.go
71.53MB 4.55GB (flat, cum) 47.63% of Total


. . 117: // Capture the data we need for our results if we find ...
. . 118: for _, item := range d.Channel.Items {
. 4.48GB 119: if strings.Contains(strings.ToLower(item.Description), strings.ToLower(term)) {
48.53MB 48.53MB 120: results = append(results, Result{
. . 121: Engine: engine,
. . 122: Title: item.Title,
. . 123: Link: item.Link,
. . 124: Content: item.Description,
. . 125: })

清单11显示了有问题的代码行。仅该行占据了迄今为止已分配的4.55GB内存的4.48GB。接下来,是时候查看这行代码,看看有什么可以做的。

清单12

1
2
3
4
5
6
7
8
9
10
11
117 // Capture the data we need for our results if we find the search term.
118 for _, item := range d.Channel.Items {
119 if strings.Contains(strings.ToLower(item.Description), strings.ToLower(term)) {
120 results = append(results, Result{
121 Engine: engine,
122 Title: item.Title,
123 Link: item.Link,
124 Content: item.Description,
125 })
126 }
127 }

清单12显示了该代码行如何处于紧密循环中。调用strings.ToLower是创建分配,因为它们创建了需要在堆上分配的新字符串。这些调用strings.ToLower是不必要的,因为这些调用可以在循环外完成。

可以更改第119行以删除所有这些分配。

清单13

1
2
3
4
5
// Before the code change.
if strings.Contains(strings.ToLower(item.Description), strings.ToLower(term)) {

// After the code change.
if strings.Contains(item.Description, term) {

注意:你没有看到的其他代码更改是在将Feed放入缓存之前调用较低的描述。新闻源每15分钟缓存一次。term降低调用是在循环外部完成的。

清单13显示了如何strings.ToLower删除被调用者。通过这些新的代码更改再次构建项目,并再次通过服务器运行10k请求。

清单14

1
2
3
4
5
6
7
$ go build
$ GODEBUG=gctrace=1 ./project > /dev/null
gc 3 @6.156s 0%: 0.011+0.72+0.068 ms clock, 0.13+0.21/1.5/3.2+0.82 ms cpu, 4->4->2 MB, 5 MB goal, 12 P
.
.
.
gc 1404 @8.808s 7%: 0.005+0.54+0.059 ms clock, 0.060+0.47/0.79/0.25+0.71 ms cpu, 4->5->2 MB, 5 MB goal, 12 P

清单14显示了在代码更改后,现在如何处理1402个集合来处理相同的10k请求。这些是两次运行的完整结果。

清单15

1
2
3
4
5
6
7
8
9
10
11
12
With Extra Allocations              Without Extra Allocations
======================================================================
Requests : 10,000 Requests : 10,000
----------------------------------------------------------------------
Requests/sec : 1,882 r/s Requests/sec : 3,631 r/s
Total Duration : 5,311ms Total Duration : 2,753 ms
Percent Time in GC : 14% Percent Time in GC : 7%
Total Collections : 2,551 Total Collections : 1,402
----------------------------------------------------------------------
Total GC Duration : 744.54ms Total GC Duration : 192.71 ms
Average Pace of GC : ~2.08ms Average Pace of GC : ~1.96ms
Requests/Collection : ~3.98 r/gc Requests/Collection : 7.13 r/gc

清单15显示了与上一结果相比的结果。以下内容提供了更多关于所发生事件的视觉效果。


图5

图5显示了视觉上发生的事情。这次收集器运行1149次(1,402对2,551)来处理相同的10k请求。这导致GC总时间的百分比从14%降低到7%。这使得应用程序运行速度提高了48%,收集时间减少了74%。


图6

图6显示了应用程序的所有不同运行的比较。我包含了一系列运行优化的代码而没有完成垃圾收集器。

我们学到了什么

正如我在上一篇文章中所说,支持收集器是为了减少堆上的压力。请记住,压力可以定义为应用程序在给定时间内分配堆上所有可用内存的速度。当压力减小时,收集器造成的延迟将会减少。这种延迟会降低你的应用程序速度。

这不是要放慢收集的速度。这真的是在每个集合之间或集合期间完成更多的工作。你可以通过减少任何工作添加到堆中的分配数量或数量来影响它。

清单16

1
2
3
4
5
6
7
8
9
10
11
12
With Extra Allocations              Without Extra Allocations
======================================================================
Requests : 10,000 Requests : 10,000
----------------------------------------------------------------------
Requests/sec : 1,882 r/s Requests/sec : 3,631 r/s
Total Duration : 5,311ms Total Duration : 2,753 ms
Percent Time in GC : 14% Percent Time in GC : 7%
Total Collections : 2,551 Total Collections : 1,402
----------------------------------------------------------------------
Total GC Duration : 744.54ms Total GC Duration : 192.71 ms
Average Pace of GC : ~2.08ms Average Pace of GC : ~1.96ms
Requests/Collection : ~3.98 r/gc Requests/Collection : 7.13 r/gc

清单16显示了带有垃圾收集的两个版本应用程序的结果。很明显,删除4.48GB的分配使应用程序运行得更快。有趣的是,每个系列(两个版本)的平均速度几乎相同,约为2.0毫秒。这两个版本之间的根本变化是每个集合之间的工作量。应用范围从3.98 r / gc到7.13 r / gc。这是完成工作量增加79.1%。

在任何两个集合的开始之间完成更多的工作有助于将所需的集合数量从2,551减少到1,402,减少了45%。该应用程序的总GC时间从745毫秒减少到193毫秒,从收集的每个相应版本的总时间的14%变为7%。当你在没有垃圾收集的情况下运行应用程序的优化版本时,性能差异仅为13%,应用程序将2,753ms降至2,398ms。

结论

如果你花时间专注于减少分配,那么你就像Go开发人员一样,对垃圾收集器表示支持。你不打算编写零分配应用程序,因此重要的是要认识到有效的分配(帮助应用程序的分配)和那些没有生产力的分配(那些损害应用程序)之间的差异。然后将你的信任和信任放在垃圾收集器中,以保持堆健康并使你的应用程序始终如一地运行。

拥有垃圾收集器是一个很好的权衡。我将花费垃圾收集的成本,所以我没有内存管理的负担。Go是关于允许你作为开发人员提高工作效率,同时仍然编写足够快的应用程序。垃圾收集器是实现这一目标的重要组成部分。在下一篇文章中,我将分享另一个程序,该程序显示收集器如何分析你的Go应用程序并找到最佳的收集方法。

评论

`
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×