【译】 Golang 中的垃圾回收(一)

介绍

垃圾回收器负责追踪堆内存的分配,释放掉不须要的空间,追踪那些还在使用的分配空间。不一样编程语言对这个机制的实现都很复杂,可是开发人员开发软件时候并不须要了解垃圾回收太细节的东西就能进行构建。另外,不一样发布版本编程语言的VM和runtime也老是在改变和进化。对于应用开发人员来讲,重要的是保持一个良好的work模型,了解编程语言里垃圾回收器的行为而且它们是怎么样支持这种行为的。html

对于go 1.12版原本说,go语言使用了非分代,并发的三色标记和清扫的回收器。若是想了解如何进行标记和清扫的工做,请参考这篇文章。golang的垃圾回收器的实现每一个版本都在更新和进化。所以一旦下个版本发布,讲任何细节的实现都再也不准确。git

总而言之,这篇文章不会去讲实际的实现细节。我会为你分享回收器的一些行为而且去解释怎样面对这些行为,不考虑实现细节以及将来的改变。这将会使你成为一个更好的golang开发者程序员

堆不是一个容器

我不会把堆看作是一个能够存储或者是释放值的容器。理解这件事情很重要,内存里并无明肯定义了“堆”的一个分界线。任何应用程序预留的内存空间,在堆内存分配上是可用的。给定任何堆内存分配空间,它实际在虚拟内存仍是物理内存上的存储位置和咱们的模型并无关联。理解这件事情会帮助你更好的理解垃圾回收模型的工做方式。github

回收器行为

当回收开始,回收器会完成三个阶段的工做。这其中两个阶段会产生Stop The World(STW) 延迟,而且另外一个阶段也会产生延迟,而且会致使下降应用程序的吞吐量。这三个阶段是:golang

  • Mark Setup - STW
  • Marking - Concurrent
  • Mark Termination -STW

下面看各个阶段的详细说明。算法

Mark Setup -STW

当回收开始,首先要作的确定是开启写屏障(Write Barrier)。写屏障的目的是在collector和应用程序goroutines并发时候,容许回收器去维持数据在堆中的完整性。编程

为了开启写屏障,每一个运行中的应用程序goroutine必需要停下来。这个活动一般很是快,平均在10~30微妙之间。前提是在你的应用程序goroutines行为合理的状况下。安全

注意:为了更好的理解下面的调度图解,最好先看过以前写的golang调度文章
图1.1

图1给出了4个应用程序goroutines,在开始垃圾回收以前它们都在运行中。为了进行回收,4个goroutines中每个都必须被停下来,这么作的惟一方式就是让回收器去检查并等待goroutine去作方法调用。方法调用确保了goroutines在安全的点停下来。若是其中一个goroutine没有进行方法调用可是其它的作了方法调用,会发生什么?bash

图1.2

图1.2给出了一个问题的实际案例。若是P4的goroutine不停下来的话,垃圾回收就没法启动。可是P4正在执行一个tight loop去作一些math处理,这致使回收根本没法开始。并发

L1:

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

L1给出了P4 goroutine正在执行的代码。取决于slice的大小,goroutine可能会执行很长很长的时间,致使了根本没有机会停下来。这种代码会阻止垃圾回收开始。更糟糕的是,其余的P没法为其余goroutine服务,由于collector处于等待状态。因此goroutine在合理的实际范围内进行方法调用是相当重要的。

注意:这部分是golang团队将在1.14版本中要改进的内容,经过加入调度器的抢占式调度技术

Marking -Concurrent

一旦写屏障开启,回收器就会开始进入都标记阶段。回收器第一件作的事情就是拿走25%的可用CPU给本身使用。collector使用Goroutines去进行回收工做,也就是它会从应用程序抢过来对应数量的P和M。这意味着,4个线程的go程序里,会有一个P被拿去处理回收工做。

图1.3

图1.3给出了collector是怎样拿走P1的在进行回收工做的时候。如今回收器能够开始Marking过程了。标记阶段就是标记处理堆内存中的in-use的值。它会去检查栈中全部存在的goroutines,去找到指向堆内存的根指针。以后回收器必须从根指针开始,遍历堆内存的树图。当P1上进行处理Marking工做,应用程序能够在P二、P3和P4上继续并发执行。这意味着回收器减小了当前CPU容量的25%。

我但愿事情到此就结束了,可是并非。若是P1上GC的goroutine在in-use的堆内存达到上限时候没完成Marking会怎么样?若是其余3个应用程序的goroutine致使了collector没法按时完成工做会怎么样?若是发生这种状况,新的内存分配就必须慢下来,尤为是在对应的goroutine上。

若是回收器肯定它必需要减慢内存分配速度,它就会招募应用程序的goroutines去协助(Assist)进行标记工做。这叫作Mark Assist。任何应用程序goroutine被置入Mark Assist的时间和它将要在堆内存中加的数据量是成比例的。Mark Assist的一个正面功能就是它帮助提升了回收速度。

图1.4

图1.4展现了,以前P3上应用程序运行的goroutine,如今正在进行Mark Assist来帮助进行回收工做。但愿其余goroutines不要参与其中。应用程序有分配内存压力的时候会看到大部分正在运行的goroutines在垃圾回收的时候去处理小量的Mark Assist工做。

Mark Termination -STW

一旦标记工做完成,下一个过程就是Mark Termination。这个时候写屏障关闭,各类清理工做会进行,而且计算出下一次的回收目标。在标记过程当中那些发现本身处理tight loop的goroutines也会致使Mark Termination STW的延时增长。

图1.5

图1.5展现了,Mark Termination阶段完成,全部Goroutines都会中止。这个活动一般会在60~90微妙内完成。这个阶段完成能够没有STW,可是经过STW,会使得代码更加简单,而且增长的代码复杂度并不值得这点小增益。

一旦回收完成了,每一个P能够再次被应用程序goroutines去使用,程序又回到了全力运行的状态。

图1.6

图1.6展现了回收完成后,所有可用的P如今正在处理应用程序的工做。

Sweeping - Concurrent

在回收完成以后,会有另一个活动,叫作清扫(Sweeping)。Sweeping就是清理内存中有值可是没有被标记为in-use的堆内存。这个活动发生在当应用程序goroutines尝试去在堆中分配新的值的时候。Sweeping延迟增长到了堆内存分配的开销中,而且和任何垃圾回收的延迟都没有关联。

下面是在个人机器上进行trace的样本,个人机器上有12个hardware thread去执行goroutines。

图1.7

图1.7展现了trace的部分快照。你能够看到在回收中(注意上面蓝色GC行),12个P中的3个被拿去处理GC。你能够看到goroutine 2450,1978和2696在这时间正在进行Mark Assist而不是它本身的程序work。在回收的最后,只有一个P去处理GC而且最终进行STW(Mark Termination)工做.

在回收完成后,程序又回到了全力运行的状态。你能够看到在这些goroutine下面的许多玫瑰色的竖线。

图1.8

图1.8展现了那些玫瑰色的线表明了goroutine进行Sweeping工做而不是它本身的程序工做的时候。这些时刻goroutine会尝试在堆中分配新的值。

图1.9

图1.9展现了一个goroutine在Sweeping活动最后的追踪数据。 runtime.mallocgc的调用会去在堆中分配新的值。 runtime.(*mcache).nextFree调用会致使Sweeping。一旦堆中再也不有分配的内存须要回收, nextFree就不会再看见。

上面描述的回收行为仅仅发生在当回收已经启动并正在处理的过程当中。在肯定何时开始回收中,GC配置选项扮演了重要的角色。

GC percentage

runtime中有一个配置选项叫作 GC Percentage,默认值是100。这个值表明了下一次回收开始以前,有多少新的堆内存能够分配。GC Percentage设置为100意味着,基于回收完成以后被标记为生存的堆内存数量,下一次回收的开始必须在有100%以上的新内存分配到堆内存时启动。

做为例子,想象回收完成了并标记了2MB的in-use堆内存。

注意:图表中的堆内存不表明实际状况。go中的堆内存一般都是凌乱的碎片化的,你不会有图表中那种清晰的区分。这些图表提供了一个方便的可视化的堆内存模型来方便理解。

图1.10

图1.10展现了,在上一次的回收完成后,有2MB的in-use堆内存。因为GC Percentage设置了100%,下一次回收启动须要在堆内存增长了2MB或者更多内存时候或者以前启动。

图1.11

图1.11展现了2MB或者更多内存处于in-use。这会触发回收。一种方式去看到这些行为的方法,就是为每次GC生成一个GC trace。

L2

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
复制代码

L2展现了如何使用GODEBUG变量去生成GC trace。下面的L3展现了程序生成的gc traces。

L3

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
复制代码

L3展现了GC中的实际数值和它的含义。我最后会讲到这些值,可是如今注意1405 GC trace的内存片断。

图1.12

L4

// 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
复制代码

GC trace行给出了以下的信息:Marking Work开始以前堆内存中in-use大小是7MB。当Marking Work完成后,堆内存中in-use的大小是11MB。这意味着回收中额外增长了4MB的内存分配。Marking Work完成以后堆内存中存活空间的大小是6MB。这意味着下次回收开始以前,in-use堆内存能够增长到12MB(100%*生存堆内存大小=6MB)

你能够看到回收器超过了它设定的目标1MB,Marking Work完成以后的in-use堆内存是11MB而不是10MB。可是不要紧,由于目标是根据当前in-use的堆内存计算获得的,也就是堆内存中标记为生存的空间,当回收进行的时候会有额外随时间计算增长的内存分配。在这个案例里,应用程序作了一些事情,致使在Marking以后,须要比预期更多去使用的堆内存。

若是你看下一个GC Trace 行(1406),你开会看到在2ms内事情是如何改变的。

图1.13

L5

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
复制代码

L5展现了在以前的回收工做开始以后(6.068s vs 6.070s)这个回收工做开始了2ms的状态,尽管in-use的堆内存在容许的12MB中仅仅达到8MB。须要注意到,若是回收器决定最好要早一点开始进行回收的话,它就会那么作。这个案例下,它可能提早开始回收了,由于应用程序的分配压力很大而且collector想要下降在此次回收工做中Mark Assist的延迟。

还有两个事情要注意,回收器在它设定的目标内完成了。在Marking 完成以后in-use的堆内存空间是11MB而不是13MB,少了2MB。在Marking完成以后堆内存中标记为存活的空间一样是6MB。

另外,你能够得到更多GC的细节经过增长gcpacertrace=1的标记。这会让回收器打印concurrent pacer的内部状态。

L6

$ 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 trace能够告诉你不少应用程序的健康状态以及回收器的速度。

Pacing

回收器有一个pacing算法,它会去肯定何时回收去开始。算法依靠一个反馈循环,回收器会使用这种算法收集应用程序运行时候的信息,以及应用程序给堆形成的压力。压力能够定义为在给定的时间内应用程序在堆上的分配有多快。压力肯定了回收器运行的速度。

在回收器开始回收以前,它会计算它认为完成回收所需的时间。 一旦回收运行,会形成正在运行的应用程序上的延迟,这将减慢应用程序的工做。 每次回收都会增长应用程序的总体延迟。

有一个错误观念就是认为下降回收器的速度是一种提升性能的方式。若是你能够推迟下一次的回收,你就会推迟它产生的延时。 但其实提高性能并不在于下降回收器的速度。

你能够决定改变GC Percentage的值,设置大于100。这会增长下次回收开始以前能够分配的内存大小。这会下降回收器的速度。可是不要考虑这么作。

图1.14

图1.14展现了改变GC percentage并改变了在下次回收以前能够分配的堆内存。你能够预想到回收器是怎么被降速的,由于它要等待堆内存in-use。

尝试直接影响回收速度并不能提高回收器性能。重要的事情是在于在每次回收的之间或者是回收的时候作更多事情,这个你能够经过减小work的堆内存的分配量来进行影响。

注意:也能够用尽小的堆来实现所须要的吞吐量。记住,在云环境中,最小化堆内存的使用很重要。

图1.15

图1.15展现了go程序内部的一些统计。蓝色版本的统计展现了没有任何优化的状况下,应用程序处理10k请求状况。绿色版本代表了相同10k请求下,4.48GB的非生产性的内存分配产生而被发现后,从应用程序中移除以后的统计状况(下降堆内存分配压力)。

看一下两个版本的平均回收速度(2.08ms vs 1.96ms)。它们几乎差不太多,大概是2ms。不一样的地方在于两个版本在每一次回收之间的work的量。应用程序每次回收之间,处理requests次数从3.98次变为 7.13次。能够看几乎相同的时间内有79.1%的工做量提高。能够看到,回收工做没有随着分配内存的减小而降速,而是保持了原来速度。成功点在于每次回收之间作了更多的事情。

调整回收器的回收速度,推迟延迟代价不是你提高应用程序的性能的方法,它只是减小了回收器须要运行的时间,这反过来会减小形成的延迟成本。回收器产生的延迟代价已经解释过了,可是这里再进行一个简单的总结说明。

Collector 延迟代价

每次回收工做,会带来两种类型的延迟。第一种是窃取CPU,在回收的时候这种窃取CPU的行为意味着你的应用程序没有以满CPU的状态运行。应用程序goroutines如今和回收器goroutine共享P,或者是进行Mark Assist。

图1.16

图1.16代表了只有75%的CPU在进行应用程序工做。由于回收器占用了一个P。

图1.17

图1.17中,只有一半的CPU在处理应用程序work。由于P3正在进行Mark Assist,P1被collector占用。

注意:Marking一般须要4 CPU-millsecondes/MB 的生存堆(举例,为了评估Marking阶段运行多少millseconds,这个值会设置为生存堆MB大小去除以0.25*CPU数目的值)。Marking实际运行大概是1MB/ms,可是只有1/4的CPU去处理。

第二种类型的延迟就是在回收中产生的STW。STW就是没有任何goroutine进行工做的状况。整个应用程序本质上是中止状态。

图1.18

图1.18展现了,STW时候全部goroutine都中止了。每次回收会发生两次STW。若是你的应用程序处于健康状态,那么回收器会让STW时间保持在100微秒之内。

下降GC延迟

减小GC延迟的方式是识别哪些是应用程序中没必要要的内存分配并移除它们,这会在几个方面帮助提升collector。

帮助回收器:

  • 维持了最小堆
  • 找到最优的一致速度
  • 每次回收保持在目标(goal)以内
  • 最小化回收的时间,STW和Mark Assist

这些事情都会帮助减小回收器产生的延迟,从而增长应用程序的吞吐量和性能。改变回收速度并无什么用。你能够经过作出正确的工程方面决策,下降堆内存的分配压力来提高性能。

理解应用程序正在运行的workload

关于性能,还须要清楚你的workload的类型。理解你的workload意味着,肯定你使用合理数量的goroutines来处理你的工做。CPU vs IO bound 的workloads是不一样的,须要作出不一样抉择。相关内容能够参考这里

结论

若是你花时间去专一于减小内存分配,你会获得性能上的提高。可是你不可能写出0分配的程序来,因此了解和确认productive(对程序有帮助的)的内存分配和not productive(损害性能)的分配是很重要的。以后你就能够信任垃圾回收器帮你维持好内存的健康和稳定,而后让你的程序持续的运行下去。

垃圾回收器是一个很好的折衷方式。花一点代价去进行垃圾回收,这样就不须要考虑内存管理的问题。Go 垃圾回收器可以让程序员更加高效和多产,可让你写出足够快的程序。

原文连接:www.ardanlabs.com/blog/2018/1…
相关文章
相关标签/搜索