美文网首页
Go的垃圾收集:第一部分-语义

Go的垃圾收集:第一部分-语义

作者: 豆腐匠 | 来源:发表于2020-07-26 16:09 被阅读0次

原文地址:https://www.ardanlabs.com/blog/2018/12/garbage-collection-in-go-part1-semantics.html

介绍

垃圾收集器负责跟踪堆内存分配,释放不再需要的分配,并保留仍在使用的分配。语言如何实现很复杂,但是对于应用程序开发人员来说,了解其细节并不是构建软件所必须的。另外,随着不同发行版的语言VM或运行,这些系统的实现总是在变化和发展。对于应用程序开发人员而言,重要的是要维护一个良好的工作模型,以了解其语言的垃圾收集器的行为方式而无需担心实现。

从1.12版开始,Go编程语言使用了并发三色标记清除收集器。如果你想直观地看到打标收集器的工作原理,Ken Fox撰写了这篇出色的文章并提供了动画。随着Go的每个版本的发行,Go收集器的实现都发生了变化和发展。因此,一旦发布了该语言的下一版本,任何有关实现细节的帖子将不再准确。

综上所述,我在本文中将进行的建模将不会关注实际的实现细节。建模将着重于你将要经历的行为以及未来几年你应该期望看到的行为。在这篇文章中,我将与你分享收集器的行为,并说明如何理解该行为,而不管当前的实现方式或将来的实现方式如何变化。这可以帮助你成为更好的Go开发人员。

注意:这里有更多有关垃圾收集器和Go的实际收集器的信息。

堆不是容器

我永远不会将堆称为可以存储或释放值的容器。重要的是要了解,没有线性内存定义为“堆”的。保留给进程空间中的应用程序使用的任何内存都可用于堆内存分配。虚拟或物理存储中任何给定堆内存的位置与我们的模型无关。了解这些将帮助你更好地了解垃圾收集器的工作方式。

收集器的行为

收集开始时,收集器将经历三个阶段的工作。这些阶段中的两个阶段创建了“世界停止”(STW)的延迟,而另一个阶段产生了延迟从而降低了应用程序的吞吐量。三个阶段是:

  • 标记设置-STW
  • 标记-并发
  • 标记终止-STW

下面每个阶段的详情。

标记设置-STW

收集开始阶段,执行的第一个步骤是打开写入屏障。写屏障的目的是允许收集器在收集期间保持堆上的数据完整性,因为收集器和应用程序的协程会同时运行。

为了打开写屏障,必须停止运行每个应用程序的goroutine。这项操作通常非常快,平均在10到30微秒左右。也就是说,应用程序goroutine表现的和正常运行一样。

注意:为了更好地理解这些调度程序图,请务必阅读Go Scheduler上的系列文章

图1

图1

图1显示了在开始收集之前运行的4个应用程序的goroutine。这四个goroutine中的每一个都必须停止。唯一的方法是让收集器监视并等待每个goroutine进行函数调用。函数调用可确保goroutine在安全的地方被停止。如果这些goroutine中的某一个不进行函数调用而其他进行了调用,会发生什么情况呢?

图2

图2

图2显示了一个实际问题。该集合要等到P4上运行的goroutine停止后才能启动,并且因为它处于执行某些数学运算的循环计算中,所以收集器无法开始工作。

01 func add(numbers []int) int {
02     var v int
03     for _, n := range numbers {
04         v += n
05     }
06     return v
07 }

上面的代码显示了在P4上运行的Goroutine正在执行的代码。根据切片的大小,Goroutine可能会由于运行不合理而没有机会停止。这种代码可能会阻塞收集。更糟糕的是,其他P在收集器等待时无法为其他goroutine提供服务。

注意:go语言团队希望通过在1.14的调度程序中添加抢占技术来纠正问题。

标记-并发

一旦打开写屏障,收集器将开始标记阶段。收集器要做的第一件事是为其自身占用25%的可用CPU容量。收集器使用Goroutines进行收集工作,并且需要Goroutines使用的相同P和M。这意味着对于我们的4线程Go程序,一个完整的P将专用于收集工作。

图3

image.png

图3显示了收集器在收集期间如何自己获取P1。现在,收集器可以开始标记阶段。标记阶段包括标记堆内存中仍在使用的值。这项工作首先检查所有现有goroutine的堆栈,以找到指向堆内存的根指针。然后,收集器从那些根指针遍历堆内存图。在P1上进行标记工作时,可以同时在P2,P3和P4上继续进行应用程序工作。这意味着收集器的影响已最小化到当前CPU容量的25%。

我希望一切到此为止,但事实并非如此。如果在收集期间发现专用于P1上的GC的Goroutine无法在堆内存达到其使用极限之前完成标记工作,该怎么办?如果这3个Goroutine中只有一个执行应用程序工作,收集器无法及时完成怎么办?在这种情况下,必须放慢新的分配速度,特别是从Goroutine中放慢速度。

如果收集器确定需要减慢分配速度,它将收集应用程序Goroutines来协助标记工作。这称为标记辅助(Mark Assist)。任何应用程序Goroutine被放置在Mark Assist中的时间与它添加到堆内存中的数据量成正比。Mark Assist的一个积极的作用是有助于更​​快地完成收集。

图4

image.png

图4显示了在P3上运行的应用程序Goroutine现在如何执行Mark Assist并帮助进行收集工作。分配繁重的应用程序可能会看到大多数正在运行的Goroutine在收集过程中执行少量的Mark Assist。

收集器的一个目标是消除对Mark Assists的需求。如果任何给定的收集最终需要大量的Mark Assist,则收集器可以更早地开始下一次垃圾收集。这样做是为了减少下一次收集所需的“标记辅助”的量。

标记终止-STW

标记工作完成后,下一阶段是标记终止。这时会关闭“写入屏障”,执行各种清理任务,然后计算下一个收集目标的时间。在标记阶段发现自己处于紧密循环中的Goroutine也会导致“标记终止” STW等待时间延长。

图5

image.png

图5显示了在标记终止阶段完成时如何停止所有Goroutine。该活动通常平均在60到90微秒内。此阶段可以在没有STW的情况下完成,但是通过使用STW,代码更简单,而且增加的复杂性非常小。

收集完成后,应用程序Goroutines可以再次使用每个P,并且应用程序将恢复正常运行。

图6

image.png

图6显示了一旦完成收集,所有可用P现在如何再次处理应用程序工作。该应用程序已恢复到开始收集之前的全部状态。

清扫-并发

收集完成后还会进行另一项活动,称为“清扫”(Sweeping)。清扫是指回收与堆内存中未标记为使用中的值相关联的内存。当应用程序Goroutines尝试在堆内存中分配新值时将进行此活动。清扫的延迟增加了在堆内存中执行分配的成本,与垃圾收集相关的延迟无关。

以下是我机器上的跟踪示例,其中有12个可用于执行Goroutine的线程。

图7

image.png

图7显示了跟踪的部分快照。可以看到在此收集过程中(注意看在顶部的蓝色GC线内),十二个P中的三个专用于GC。可以看到Goroutine 2450、1978和2696在这段时间内正在执行Mark Assist工作,而不是其应用程序工作。在集合的最后,只有一个P专用于GC,并最终执行STW(标记终止)工作。

收集完成后,应用程序将恢复全速运行。除了在这些Goroutines下方看到许多玫瑰色的线条。

图8

image

图8显示了那些玫瑰色的线条如何代表Goroutine正在执行Sweeping工作而不是其应用程序工作的时刻。这些是Goroutine尝试在堆内存中分配新值的时刻。

图9

image

图9显示了Sweep活动中Goroutine之一的堆栈跟踪的结尾。对runtime.mallocgc的调用是在堆内存中分配新值的调用。runtime.(*mcache).nextFree的调用导致启动Sweep活动。一旦堆内存中没有更多分配要回收,对nextFree的调用就不会再出现。

刚刚描述的收集行为仅在收集已启动并正在运行时发生。GC百分比配置选项在确定何时开始收集中起着重要作用。

GC百分比

运行时有一个名为GC百分比的配置选项,默认情况下设置为100。该值表示在下一个收集必须开始之前可以分配多少新堆内存的比率。将GC百分比设置为100,表示​​根据收集完成后标记为存活的堆内存量,下一个收集必须在100%的新分配添加到堆内存之时或之前开始。

例如,假设一个收集在使用2MB堆内存的情况下结束。

注意:使用Go时,本文中堆内存的图并不代表真实的配置信息。Go中的堆内存通常是碎片化且混乱的,并且无法像图像所代表的那样进行清晰的分离。这些图只是提供了一种更容易理解的可视化堆内存的方式。

图10

image

图10显示了最后一次收集完成后正在使用的2MB堆内存。由于GC百分比设置为100%,因此下一个收集需要在添加2 MB以上的堆内存时开始。

图11

image.png

图11显示了另外2 MB的堆内存正在使用中。这将触发收集。查看以上操作的一种方法是为发生的收集过程生成GC跟踪。

GC追踪

可以设置环境变量GODEBUGgctrace=1,运行时便会将GC跟踪信息写入stderr

GODEBUG=gctrace=1 ./app

gc 1405 @6.068s 11%: 0.058+1.2+0.083 ms clock, 0.70+2.5/1.5/0+0.99 ms cpu, 7->11->6 MB, 10 MB goal, 12 P

gc 1406 @6.070s 11%: 0.051+1.8+0.076 ms clock, 0.61+2.0/2.5/0+0.91 ms cpu, 8->11->6 MB, 13 MB goal, 12 P

gc 1407 @6.073s 11%: 0.052+1.8+0.20 ms clock, 0.62+1.5/2.2/0+2.4 ms cpu, 8->14->8 MB, 13 MB goal, 12 P

上面显示了如何使用GODEBUG变量生成GC跟踪。

通过查看清单中的第一条GC跟踪,可以深入了解GC跟踪中每个值的含义。

gc 1405 @6.068s 11%: 0.058+1.2+0.083 ms clock, 0.70+2.5/1.5/0+0.99 ms cpu, 7->11->6 MB, 10 MB goal, 12 P

// General
gc 1404     : The 1404 GC run since the program started
@6.068s     : Six seconds since the program started
11%         : Eleven percent of the available CPU so far has been spent in GC

// Wall-Clock
0.058ms     : STW        : Mark Start       - Write Barrier on
1.2ms       : Concurrent : Marking
0.083ms     : STW        : Mark Termination - Write Barrier off and clean up

// CPU Time
0.70ms      : STW        : Mark Start
2.5ms       : Concurrent : Mark - Assist Time (GC performed in line with allocation)
1.5ms       : Concurrent : Mark - Background GC time
0ms         : Concurrent : Mark - Idle GC time
0.99ms      : STW        : Mark Term

// Memory
7MB         : Heap memory in-use before the Marking started
11MB        : Heap memory in-use after the Marking finished
6MB         : Heap memory marked as live after the Marking finished
10MB        : Collection goal for heap memory in-use after Marking finished

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

上面显示了来自第一条GC跟踪行的实际数字,按值的含义细分了这些数字。我会讨论其中的大多数值,但现在仅关注跟踪1405的GC跟踪的内存部分。

图12

image
// Memory
7MB         : Heap memory in-use before the Marking started
11MB        : Heap memory in-use after the Marking finished
6MB         : Heap memory marked as live after the Marking finished
10MB        : Collection goal for heap memory in-use after Marking finished

在标记工作开始之前,正在使用的堆内存量为7MB。当标记工作完成时,正在使用的堆内存量达到11MB。这意味着在收集过程中还会发生另外4MB的分配。标记工作完成后被标记为活动的堆内存为6MB。这意味着应用程序可以在开始下一个收集之前将正在使用的堆内存量增加到12MB(活动堆大小的6MB的100%)。

你会看到收集器未达到其目标1MB。标记工作完成后正在使用的堆内存量为11MB而不是10MB。因为目标是根据当前正在使用的堆内存量,标记为活动的堆内存量以及有关在收集运行时将发生的其他分配的计时计算得出的。在这种情况下,应用程序执行的操作需要在标记后使用比预期的更多的堆内存。

如果查看下一条GC跟踪线(1406),你将看到在2ms内情况是如何变化的。

图13

image
gc 1406 @6.070s 11%: 0.051+1.8+0.076 ms clock, 0.61+2.0/2.5/0+0.91 ms cpu, 8->11->6 MB, 13 MB goal, 12 P

// Memory
8MB         : Heap memory in-use before the Marking started
11MB        : Heap memory in-use after the Marking finished
6MB         : Heap memory marked as live after the Marking finished
13MB        : Collection goal for heap memory in-use after Marking finished

上面显示了该集合如何在上一个集合开始后的2ms(6.068s和6.070s)之间启动,即使正在使用的堆内存仅达到允许的12MB中的8​​MB。需要注意的重要一点是,如果收集者认为最好尽早开始收集工作。在这种情况下,它可能启动得较早,因为应用程序分配过多,并且收集器希望减少此收集期间的“Mark Assist”的延迟时间。

还有两点需要注意。这次收集者保持在其目标之内。标记完成后正在使用的堆内存量为11MB而不是13MB,减少了2MB。标记完成后标记为活动的堆内存量相同,为6MB。

你可以通过添加gcpacertrace=1标志从GC跟踪中获取更多详细信息。

$ export GODEBUG=gctrace=1,gcpacertrace=1 ./app

Sample output:
gc 5 @0.071s 0%: 0.018+0.46+0.071 ms clock, 0.14+0/0.38/0.14+0.56 ms cpu, 29->29->29 MB, 30 MB goal, 8 P

pacer: sweep done at heap size 29MB; allocated 0MB of spans; swept 3752 pages at +6.183550e-004 pages/byte

pacer: assist ratio=+1.232155e+000 (scan 1 MB in 70->71 MB) workers=2+0

pacer: H_m_prev=30488736 h_t=+2.334071e-001 H_T=37605024 h_a=+1.409842e+000 H_a=73473040 h_g=+1.000000e+000 H_g=60977472 u_a=+2.500000e-001 u_g=+2.500000e-001 W_a=308200 goalΔ=+7.665929e-001 actualΔ=+1.176435e+000 u_a/u_g=+1.000000e+000

运行GC跟踪可以告诉你很多有关应用程序运行状况和收集器运行速度的信息。

Pacing

收集器使用Pacing算法确定何时开始收集。此算法通过使用反馈循环来收集有关正在运行的应用程序以及应用程序使用堆的信息。对堆造成的压力可以定义为应用程序在给定时间内分配堆内存的速度。正是这种压力决定了收集器需要运行的速度。

在收集器开始收集之前,它会计算完成收集所需的时间。一旦开始运行,将对正在运行的应用程序产生延迟,这将减慢应用程序的工作速度。每次收集都会增加应用程序的整体延迟。

一个误解是认为放慢收集器速度是提高性能的一种方法。这个想法是如果可以延迟下一个收集的开始,那么将延迟它将造成的延迟。优化垃圾收集并不是要放慢节奏。

你可以决定将“ GC百分比”值更改为大于100的值。这将增加在必须开始下一个收集之前可以分配的堆内存量。这可能会导致收集速度变慢。不要这样做。

图14

image

图14显示了更改GC百分比将如何更改下一个收集开始之前分配的堆内存量。你可以直观地观察收集器在等待更多堆内存投入使用时如何放慢速度。

试图直接影响收集的速度与优化收集性能无关。这实际上是要在每个收集器之间或收集期间完成更多工作。你可以通过减少分配给堆内存的任何工作量或分配数量影响到这一点。

注意:这个想法也是用最小的堆来实现所需的吞吐量。请记住,在云环境中运行时,最小化堆内存等资源的使用非常重要。

图15

image

图15显示了正在运行的Go应用程序的一些统计信息,该统计信息将在本系列的下一部分中使用。当通过应用程序处理10k请求时,蓝色显示了应用程序的统计信息,没有进行任何优化。绿色显示在发现4.48GB非生产性内存分配并针对相同的10k请求从应用程序中删除之后的统计信息。

查看两种版本的平均收集速度(2.08毫秒对1.96毫秒)。它们几乎相同,大约为2.0ms。这两个版本之间的根本变化是每个集合之间要完成的工作量。应用程序从每个集合处理3.98次到7.13次请求。以相同的速度完成的工作量增加了79.1%。收集并没有随着分配减少而放慢速度,而是保持不变。成功点在于每次回收之间做了更多的事情。

调整收集速度以降低延迟成本不是提高应用程序性能的方式。减少收集器需要运行的时间,这将减少造成的延迟成本。关于收集器造成的延迟成本已经解释了,但是为了让你更加清楚,这里再次对其进行总结。

收集器的延迟成本

每个集合对正在运行的应用程序造成的延迟有两种。首先是窃取CPU。CPU被盗的影响意味着你的应用程序在收集过程中并不是在全速运行。现在,应用程序Goroutines正在与收集者的Goroutines共享P或在收集过程中提供帮助(标记辅助)。

图16

image

图16显示了应用程序如何仅使用其CPU容量的75%进行应用程序工作。这是因为收集器本身单独占用了P1。

图17

image

图17显示了在此时刻(通常只有几微秒)的应用程序现在仅将其一半的CPU用于应用程序工作。这是因为P3上的goroutine正在执行“标记辅助”,并且收集器独自占用P1。

注意:标记通常每MB的堆花费4毫秒的CPU(例如,估计标记阶段将运行多少毫秒,以MB为单位的活动堆大小除以0.25 * CPU数量)。标记实际上以大约1 MB / ms的速度运行,但只有四分之一的CPU。

造成的第二个延迟是收集期间发生的STW延迟量。STW时间是没有应用程序Goroutines执行工作的时间。该应用程序实际上已停止。

图18

image.png

图18显示了STW延迟,其中所有Goroutine均已停止。每个集合两次。如果你的应用程序运行状况良好,则对于大多数收集而言,收集器应能够将整个STW时间保持在100微秒或以下。

你现在知道了收集器的不同阶段,内存的大小,pacing算法如何工作以及收集器对正在运行的应用程序造成的不同延迟。有了所有这些知识,就可以最终回答关于你如何优化的问题。

优化

对收集器的优化关键是关于减轻堆内存的压力。请记住,压力可以定义为应用程序在给定时间内分配堆内存的速度。当压力减小时,收集器造成的延迟将减小。

减少GC延迟的方法是从应用程序中识别并删除不必要的分配。可以通过下面的方法帮助收集器。

  • 保持尽可能小的堆。
  • 找到最佳的一致速度。
  • 保持每次收集的目标。
  • 最小化每次收集,STW和Mark Assist的持续时间。

所有这些都有助于减少收集器对正在运行的应用程序造成的延迟。这将提高应用程序的性能和吞吐量。

结论

如果你花时间去专注于减少内存分配,你会得到性能上的提升。但是你不可能写出0分配的程序来,所以了解和确认productive(对程序有帮助的)的内存分配和not productive(损害性能)的分配是很重要的。之后你就可以信任垃圾回收器帮你维持好内存的健康和稳定,然后让你的程序持续的运行下去。
垃圾回收器是一个很好的折衷方式。花一点代价去进行垃圾回收,这样就不需要考虑内存管理的问题。Go 垃圾回收器能够让程序员更加高效和多产,可以让你写出足够快的程序。

相关文章

网友评论

      本文标题:Go的垃圾收集:第一部分-语义

      本文链接:https://www.haomeiwen.com/subject/aobjhktx.html