Go GC 深度解析

510 阅读14分钟

转载自欧长坤老师:Go GC 20 问 (qq.com),进行一些简化

GC 的认识

1. GC

GC,全称 GarbageCollection,即垃圾回收,是一种自动内存管理的机制。

当程序向OS申请的内存不再需要时,GC 将其回收,针对内存级别资源的自动回收过程。而负责垃圾回收的程序组件,即为垃圾回收器。

垃圾回收器的执行过程被划分为两个 半独立的组件:

  • 赋值器(Mutator):指用户态的代码。对垃圾回收器而言,用户态代码只是在修改对象之间的引用关系,也就是在对象图(对象之间引用关系的有向图)上进行操作。
  • 回收器(Collector):负责执行垃圾回收的代码。

2. 根对象

又叫根集合,是垃圾回收器在标记过程时最先检查的对象,包括:

  1. 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
  2. 执行栈:每个 goroutine 都有自己的执行栈,栈上包含栈上的变量及指向分配的堆内存区块的指针。
  3. 寄存器:寄存器的值可能表示一个指针,指针可能指向某些赋值器分配的堆内存区块。

3. 常见的 GC 实现方式

所有的 GC 算法其存在形式可以归结为追踪引用计数这两种形式的混合运用。

  • 追踪式 GC 从根对象出发,根据对象之间的引用信息,一步步推进直到扫描完毕整个堆并确定需要保留的对象,从而回收所有可回收的对象。Go、 Java 等均为追踪式 GC。
  • 引用计数式 GC 每个对象自身包含一个被引用的计数器,当计数器归零时自动得到回收。因为此方法缺陷较多,在追求高性能时通常不被应用。Python、Objective-C 等均为引用计数式 GC。

目前比较常见的 GC 实现方式包括:

  • 追踪式,分为不同类型:
    • 标记清扫:从根对象出发,将确定存活的对象进行标记,并清扫可以回收的对象。
    • 标记整理:为了解决内存碎片问题而提出,在标记过程中,将对象尽可能整理到一块连续的内存上。
  • 增量式:将标记与清扫的过程分批执行,每次执行很小的部分,从而增量的推进垃圾回收,达到近似实时、几乎无停顿的目的。
    • 增量整理:在增量式的基础上,增加对对象的整理过程。
  • 分代式:将对象根据存活时间的长短进行分类,存活时间小于某个值的为年轻代,存活时间大于某个值的为老年代,永远不会参与回收的对象为永久代。并根据分代假设(如果一个对象存活时间不长则倾向于被回收,如果一个对象已经存活很长时间则倾向于存活更长时间)对对象进行回收。
  • 引用计数:根据对象自身的引用计数来回收,当引用计数归零时立即回收。

Go 的 GC 目前使用的是无分代、不整理、并发的三色标记清扫算法。 原因在于:

  1. 对象整理的优势是解决内存碎片问题。但 Go 运行时的分配算法基于 tcmalloc,基本上没有碎片问题。
  2. Go 的编译器会通过逃逸分析将大部分新生对象存储在栈上(栈直接被回收),只有那些需要长期存在的对象才会被分配到堆。即分代 GC 回收的那些存活时间短的对象在 Go 中是直接被分配到栈上,当 goroutine 死亡后栈也会被直接回收,不需要 GC 的参与,进而分代假设并没有带来直接优势。

4. 三色标记法

理解三色标记法的关键是理解对象的三色抽象以及波面推进这两个概念。

  • 白色对象(可能死亡):未被回收器访问到的对象。在回收开始阶段,所有对象均为白色,当回收结束后,白色对象均不可达。
  • 灰色对象(波面):已被回收器访问到的对象,但回收器需要对其中的一个或多个指针进行扫描,因为他们可能还指向白色对象。
  • 黑色对象(确定存活):已被回收器访问到的对象,其中所有字段都已被扫描,黑色对象中任何一个指针都不可能直接指向白色对象。

回收过程其实是波面不断前进的过程,波面同时也是黑色对象和白色对象的边界。

当GC开始时只有白色对象。标记过程开始后,灰色对象出现,这时波面开始扩大。当一个对象的所有子节点均完成扫描时,会被着色为黑色。整个堆遍历完时,只剩下黑色和白色对象。这个过程可以视为以灰色对象为波面,将黑色对象和白色对象分离,使波面不断向前推进,直到所有可达的灰色对象都变为黑色对象为止的过程: 图片

5. STW

StoptheWorld:指在垃圾回收过程中为了保证实现的正确性、防止无止境的内存增长等问题而不可避免的需要停止赋值器进一步操作对象图的一段过程。

这个过程中整个用户代码被停止或者放缓执行, STW 越长,对用户代码的影响越大。

6. 有了 GC,为什么还会发生内存泄露?

形式1:被根对象引用而没有得到迅速释放

不经意间将某个变量附着在全局对象上,且忽略的将其进行释放,则该内存永远不会得到释放。例如:

var cache = map[interface{}]interface{}{}

func keepalloc() {
  for i := 0; i < 10000; i++ {
    m := make([]byte, 1<<10)
    cache[i] = m
  }
}

形式2:goroutine 泄漏

如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象,例如:

func keepalloc2() {
  for i := 0; i < 100000; i++ {
    go func() {
      select {}
    }()
  }
}

这种形式的 goroutine 泄漏还可能由 channel 泄漏导致。而 channel 的泄漏本质上与 goroutine 泄漏存在直接联系。Channel 作为一种同步原语,会连接两个不同的 goroutine,如果一个 goroutine 尝试向一个没有接收方的无缓冲 channel 发送消息,则该 goroutine 会被永久的休眠,整个 goroutine 及其执行栈都得不到释放,例如:

var ch = make(chan struct{})

func keepalloc3() {
  for i := 0; i < 100000; i++ {
    // 没有接收方,goroutine 会一直阻塞
    go func() { ch <- struct{}{} }()
  }
}

7. 并发标记清除法的难点

在没有用户态代码并发修改 三色抽象的情况下,回收可以正常结束。但并发回收的根本问题在于,用户态代码在回收过程中会并发地更新对象图,从而造成赋值器和回收器可能对对象图的结构产生不同的认知。这时以一个固定的三色波面作为回收过程前进的边界则不再合理。

我们不妨考虑赋值器写操作的例子:

时序回收器赋值器
1shade(A, gray)
2shade(C, black)
3C.ref3 = C.ref2.ref1
4A.ref1 = nil
5shade(A.ref1, gray)
6shade(A, black)
  1. 根对象 C 指向对象 A(ref2) ,而 A 指向白色对象 B(ref1);
  2. A变灰,C变黑;
  3. C.ref3 = C.ref2.ref1:赋值器并发地将黑色对象 C 指向(ref3)了白色对象 B;
  4. A.ref1 = nil:移除灰色对象 A 对白色对象 B 的引用;
  5. 最终状态:在继续扫描的过程中,白色对象 B 永远不会被标记为黑色对象了,进而对象 B 被错误地回收。

8. 写屏障、混合写屏障

要讲清楚写屏障,就需要理解三色标记清除算法中的强弱不变性以及赋值器的颜色

写屏障是一个在并发垃圾回收器中才会出现的概念,垃圾回收器的正确性体现在:不应出现对象的丢失,也不应错误的回收还不需要回收的对象。

强三色不变性:禁止黑色对象指向白色对象。

弱三色不变性:黑色对象可以指向白色对象,但指向的白色对象,必须有能从灰色对象可达的路径。

当赋值器打破弱三色不变性,也就破坏了回收器的正确性。

这里再强调下:赋值器就是用户态代码

如果我们考虑并发的用户态代码,回收器不允许同时停止所有赋值器,即存在多个不同状态(颜色)的赋值器。

  • 黑色赋值器:已经由回收器扫描过,不会再次对其进行扫描。
  • 灰色赋值器:尚未被回收器扫描过,或尽管已经扫描过但仍需要重新扫描。

赋值器的颜色对回收周期的结束产生影响:

  • 如果某种并发回收器允许灰色赋值器的存在,则必须在回收结束之前重新扫描对象图。
  • 如果重新扫描过程中发现了新的灰色或白色对象,回收器还需要对新发现的对象进行追踪,但是在新追踪的过程中,赋值器仍然可能在其根中插入新的非黑色的引用,如此往复,直到重新扫描过程中没有发现新的白色或灰色对象。

于是,在允许灰色赋值器存在的算法,最坏的情况下,回收器只能将所有赋值器线程停止才能完成其跟对象的完整扫描,即 STW。

为了确保强弱三色不变性的并发指针更新操作,需要通过赋值器屏障技术来保证指针的读写操作一致。因此我们所说的 Go 中的写屏障、混合写屏障,其实是指赋值器的写屏障,赋值器的写屏障用来保证赋值器在进行指针写操作时,不会破坏弱三色不变性。

两种写屏障

Dijkstra 插入屏障:(维护强三色不变性)

// slot为指向的对象, ptr为被指向的对象
func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(ptr)
    *slot = ptr
}

为了防止黑色对象指向白色对象, shade(ptr) 会先将指针 ptr 标记为灰色。但是,由于并不清楚赋值器以后会不会将这个引用删除,因此还需要重新扫描来重新确定关系图,这时需要 STW,如图所示:

图片

Dijkstra 插入屏障的好处在于可以立刻开始并发标记,但由于产生了灰色赋值器,缺陷是需要标记终止阶段 STW 时进行重新扫描。

Yuasa 删除屏障的基本思想是维护弱三色不变性:

func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(*slot)
    *slot = ptr
}

为了防止丢失从灰色对象到白色对象的路径, shade(*slot) 会先将 *slot 标记为灰色,进而该写操作总是创造了一条灰色到灰色或者灰色到白色对象的路径。

Yuasa 删除屏障的优势则在于不需要标记结束阶段的重新扫描,缺陷是依然会产生丢失的对象,需要在标记开始前对整个对象图进行快照。 图片

Go 为了简化 GC 的流程,同时减少标记终止阶段的重扫成本,将 Dijkstra 插入屏障和 Yuasa 删除屏障进行混合,形成混合写屏障。该屏障提出时的基本思想是:对正在被覆盖的对象进行着色,且如果当前栈未扫描完成,则同样对指针进行着色。

func HybridWritePointerSimple(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(*slot)
    shade(ptr)
    *slot = ptr
}

在这个实现中,如果无条件对引用双方进行着色,自然结合了 Dijkstra 和 Yuasa 写屏障的优势,但缺点也非常明显,因为着色成本是双倍的,而且编译器需要插入的代码也成倍增加,随之带来的结果就是编译后的二进制文件大小也进一步增加。为了针对写屏障的性能进行优化,Go 1.10 前后,Go 团队实现了批量写屏障机制。其基本想法是将需要着色的指针统一写入一个缓存,每当缓存满时统一对缓存中的所有 ptr 指针进行着色。

GC 的实现细节

9. Go 语言中 GC 的流程是什么?

阶段说明赋值器状态
GCMark标记准备阶段,为并发标记做准备工作,启动写屏障STW
GCMark扫描标记阶段,与赋值器并发执行,写屏障开启并发
GCMarkTermination标记终止阶段,保证一个周期内标记任务完成,停止写屏障STW
GCoff内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭并发
GCoff内存归还阶段,将过多的内存归还给操作系统,写屏障关闭并发

具体而言,各个阶段的触发函数分别为: 图片

10. 触发 GC 的时机是什么?

  1. 主动触发,通过调用 runtime.GC 来触发 GC,此调用阻塞式地等待当前 GC 运行完毕。
  2. 被动触发,两种方式:
    • 使用系统监控,当超过两分钟没有产生任何 GC 时,强制触发 GC。
    • 使用步调算法,其核心思想是控制内存增长的比例。

11. 内存分配速度 > 标记清除的速度

GC 触发后会先进入并发标记的阶段。并发标记会设置一个标志,并在 mallocgc 调用时进行检查。当存在新的内存分配时,会暂停分配内存过快的那些 goroutine,并将其转去执行一些辅助标记(Mark Assist)的工作,从而达到放缓继续分配、辅助 GC 的标记工作的目的。

编译器会分析用户代码,并在需要分配内存的位置,将申请内存的操作翻译为 mallocgc 调用,而 mallocgc 的实现决定了标记辅助的实现,其伪代码思路如下:

func mallocgc(t typ.Type, size uint64) {
  if enableMarkAssist {
    // 进行标记辅助,此时用户代码没有得到执行
    (...)
  }
  // 执行内存分配
  (...)
}

GC 的优化问题

12. GC 关注的指标有哪些

  • CPU 利用率:回收算法会在多大程度上拖慢程序?通过回收占用的 CPU 时间与其它 CPU 时间的百分比来描述的。
  • GC 停顿时间:回收器会造成多长时间的停顿?目前的 GC 中需要考虑 STW 和 Mark Assist 两个部分可能造成的停顿。
  • GC 停顿频率:回收器造成的停顿频率是怎样的?目前的 GC 中需要考虑 STW 和 Mark Assist 两个部分可能造成的停顿。

13. Go 的 GC 如何调优

严格意义上来讲,Go 可供用户调整的参数只有 GOGC 环境变量。

当我们谈论 GC 调优时,通常是指减少用户代码对 GC 产生的压力,这一方面包含了减少用户代码分配内存的数量(即对程序的代码行为进行调优),另一方面包含了最小化 Go 的 GC 对 CPU 的使用率(即调整 GOGC)。

并非所有程序都需要针对 GC 进行调优。 只有那些对执行延迟非常敏感、当 GC 的开销成为程序性能瓶颈的程序,才需要针对 GC 进行性能调优,几乎不存在于实际开发中 99% 的情况。

总的来说,我们可以在开发中处理的有以下几种情况:

  1. 对停顿敏感:GC 过程中产生的长时间停顿、或由于需要执行 GC 而没有执行用户代码,导致需要立即执行的用户代码执行滞后。
  2. 对资源消耗敏感:对于频繁分配内存的应用而言,频繁分配内存增加 GC 的工作量,原本可以充分利用 CPU 的应用不得不频繁地执行垃圾回收,影响用户代码对 CPU 的利用率,进而影响用户代码的执行效率。

优化内存的申请速度,尽可能的少申请内存,复用已申请的内存。或者简单来说:控制、减少、复用

例1:合理化内存分配的速度、提高赋值器的 CPU 利用率

在这个例子中, concat 函数负责拼接一些长度不确定的字符串。并且为了快速完成任务,出于某种原因,在两个嵌套的 for 循环中一口气创建了 800 个 goroutine。在 main 函数中,启动了一个 goroutine 并在程序结束前不断的触发 GC,并尝试输出 GC 的平均执行时间:

var (
  stop  int32
  count int64
  sum   time.Duration
)

func concat() {
  for n := 0; n < 100; n++ {
    for i := 0; i < 8; i++ {
      go func() {
        s := "Go GC"
        s += " " + "Hello"
        s += " " + "World"
        _ = s
      }()
    }
  }
}

func main() {
  f, _ := os.Create("trace.out")
  defer f.Close()
  trace.Start(f)
  defer trace.Stop()

  go func() {
    var t time.Time
    for atomic.LoadInt32(&stop) == 0 {
      t = time.Now()
      runtime.GC()
      sum += time.Since(t)
      count++
    }
    fmt.Printf("GC spend avg: %v\n", time.Duration(int64(sum)/count))
  }()

  concat()
  atomic.StoreInt32(&stop, 1)
}

执行结果:

$ go build -o main
$ ./main
GC spend avg: 2.583421ms

GC 平均执行一次需要长达 2ms 的时间,我们再进一步观察 trace 的结果:

程序的整个执行过程中仅执行了一次 GC,而且仅 Sweep STW 就耗费了超过 1 ms,非常反常。甚至查看赋值器 mutator 的 CPU 利用率,在整个 trace 尺度下连 40% 都不到:

主要原因是什么呢?我们不妨查看 goroutine 的分析:

图片

在这个榜单中我们不难发现,goroutine 的执行时间占其生命周期总时间非常短的一部分,但大部分时间都花费在调度器的等待上了(蓝色的部分),说明同时创建大量 goroutine 对调度器产生的压力确实不小,我们不妨将这一产生速率减慢,一批一批地创建 goroutine:

func concat() {
  wg := sync.WaitGroup{}
  for n := 0; n < 100; n++ {
    wg.Add(8)
    for i := 0; i < 8; i++ {
      go func() {
        s := "Go GC"
        s += " " + "Hello"
        s += " " + "World"
        _ = s
        wg.Done()
      }()
    }
    wg.Wait()
  }
}

这时候我们再来看:

$ go build -o main
$ ./main
GC spend avg: 328.54µs

GC 的平均时间就降到 300 微秒了。这时的赋值器 CPU 使用率也提高到了 60%,相对来说就很可观了:

当然,这个程序仍然有优化空间,例如我们其实没有必要等待很多 goroutine 同时执行完毕才去执行下一组 goroutine。而可以当一个 goroutine 执行完毕时,直接启动一个新的 goroutine,也就是 goroutine 池的使用。

例2:降低并复用已经申请的内存

我们通过一个非常简单的 Web 程序来说明复用内存的重要性。在这个程序中,每当产生一个 /example2的请求时,都会创建一段内存,并用于进行一些后续的工作。

func newBuf() []byte {
  return make([]byte, 10<<20)
}

func main() {
  go func() {
    http.ListenAndServe("localhost:6060", nil)
  }()
  
  http.HandleFunc("/example2", func(w http.ResponseWriter, r *http.Request) {
    b := newBuf()

    // 模拟执行一些工作
    for idx := range b {
      b[idx] = 1
    }

    fmt.Fprintf(w, "done, %v", r.URL.Path[1:])
  })
  http.ListenAndServe(":8080", nil)
}

为了进行性能分析,我们还额外创建了一个监听 6060 端口的 goroutine,用于使用 pprof 进行分析。我们先让服务器跑起来:

$ go build -o main$ ./main

我们这次使用 pprof 的 trace 来查看 GC 在此服务器中面对大量请求时候的状态,要使用 trace 可以通过访问 /debug/pprof/trace 路由来进行,其中 seconds 参数设置为 20s,并将 trace 的结果保存为 trace.out

$ wget http://127.0.0.1:6060/debug/pprof/trace\?seconds\=20 -O trace.out
--2020-01-01 22:13:34--  http://127.0.0.1:6060/debug/pprof/trace?seconds=20
Connecting to 127.0.0.1:6060... connected.
HTTP request sent, awaiting response...

这时候我们使用一个压测工具 ab,来同时产生 500 个请求( -n 一共 500 个请求, -c 一个时刻执行请求的数量,每次 100 个并发请求):

$ ab -n 500 -c 100 http://127.0.0.1:8080/example2
This is ApacheBench, Version 2.3 <$Revision: 1843412 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 127.0.0.1 (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Finished 500 requests

Server Software:        
Server Hostname:        127.0.0.1
Server Port:            8080

Document Path:          /example2
Document Length:        14 bytes

Concurrency Level:      100
Time taken for tests:   0.987 seconds
Complete requests:      500
Failed requests:        0
Total transferred:      65500 bytes
HTML transferred:       7000 bytes
Requests per second:    506.63 [#/sec] (mean)
Time per request:       197.382 [ms] (mean)
Time per request:       1.974 [ms] (mean, across all concurrent requests)
Transfer rate:          64.81 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    1   1.1      0       7
Processing:    13  179  77.5    170     456
Waiting:       10  168  78.8    162     455
Total:         14  180  77.3    171     458

Percentage of the requests served within a certain time (ms)
  50%    171
  66%    203
  75%    222
  80%    239
  90%    281
  95%    335
  98%    365
  99%    400
 100%    458 (longest request)

图片GC 反复被触发,一个显而易见的原因就是内存分配过多。我们可以通过 go tool pprof 来查看究竟是谁分配了大量内存(使用 web 指令来使用浏览器打开统计信息的可视化图形):

$ go tool pprof http://127.0.0.1:6060/debug/pprof/heap
Fetching profile over HTTP from http://localhost:6060/debug/pprof/heap
Saved profile in /Users/changkun/pprof/pprof.alloc_objects.alloc_space.inuse_o
bjects.inuse_space.003.pb.gz
Type: inuse_space
Time: Jan 1, 2020 at 11:15pm (CET)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) web
(pprof)

图片

可见 newBuf 产生的申请的内存过多,现在我们使用 sync.Pool 来复用 newBuf 所产生的对象:

// 使用 sync.Pool 复用需要的 buf
var bufPool = sync.Pool{
  New: func() interface{} {
    return make([]byte, 10<<20)
  },
}

func main() {
  go func() {
    http.ListenAndServe("localhost:6060", nil)
  }()
  http.HandleFunc("/example2", func(w http.ResponseWriter, r *http.Request) {
    b := bufPool.Get().([]byte)
    for idx := range b {
      b[idx] = 0
    }
    fmt.Fprintf(w, "done, %v", r.URL.Path[1:])
    bufPool.Put(b)
  })
  http.ListenAndServe(":8080", nil)
}

其中 ab 输出的统计结果为:

$ ab -n 500 -c 100 http://127.0.0.1:8080/example2
This is ApacheBench, Version 2.3 <$Revision: 1843412 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 127.0.0.1 (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Finished 500 requests

Server Software:        
Server Hostname:        127.0.0.1
Server Port:            8080

Document Path:          /example2
Document Length:        14 bytes

Concurrency Level:      100
Time taken for tests:   0.427 seconds
Complete requests:      500
Failed requests:        0
Total transferred:      65500 bytes
HTML transferred:       7000 bytes
Requests per second:    1171.32 [#/sec] (mean)
Time per request:       85.374 [ms] (mean)
Time per request:       0.854 [ms] (mean, across all concurrent requests)
Transfer rate:          149.85 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    1   1.4      1       9
Processing:     5   75  48.2     66     211
Waiting:        5   72  46.8     63     207
Total:          5   77  48.2     67     211

Percentage of the requests served within a certain time (ms)
  50%     67
  66%     89
  75%    107
  80%    122
  90%    148
  95%    167
  98%    196
  99%    204
 100%    211 (longest request)

Requestsper second 每秒请求数来看,从原来的 506.63 变为 1171.32 得到了近乎一倍的提升。从 trace 的结果来看,GC 也没有频繁的被触发从而长期消耗 CPU 使用率:

图片

sync.Pool 是内存复用的一个最为显著的例子,从语言层面上还有很多类似的例子,例如在例 1 中, concat 函数可以预先分配一定长度的缓存,而后再通过 append 的方式将字符串存储到缓存中:

func concat() {
  wg := sync.WaitGroup{}
  for n := 0; n < 100; n++ {
    wg.Add(8)
    for i := 0; i < 8; i++ {
      go func() {
        s := make([]byte, 0, 20)
        s = append(s, "Go GC"...)
        s = append(s, ' ')
        s = append(s, "Hello"...)
        s = append(s, ' ')
        s = append(s, "World"...)
        _ = string(s)
        wg.Done()
      }()
    }
    wg.Wait()
  }
}

原因在于 + 运算符会随着字符串长度的增加而申请更多的内存,并将内容从原来的内存位置拷贝到新的内存位置,造成大量不必要的内存分配,先提前分配好足够的内存,再慢慢地填充,也是一种减少内存分配、复用内存形式的一种表现。

例3:调整 GOGC

我们已经知道了 GC 的触发原则是由步调算法来控制的,其关键在于估计下一次需要触发 GC 时,堆的大小。可想而知,如果我们在遇到海量请求的时,为了避免 GC 频繁触发,是否可以通过将 GOGC 的值设置得更大,让 GC 触发的时间变得更晚,从而减少其触发频率,进而增加用户代码对机器的使用率呢?答案是肯定的。

我们可以非常简单粗暴的将 GOGC 调整为 1000,来执行上一个例子中未复用对象之前的程序:

$ GOGC=1000 ./main

这时我们再重新执行压测:

$ ab -n 500 -c 100 http://127.0.0.1:8080/example2
This is ApacheBench, Version 2.3 <$Revision: 1843412 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 127.0.0.1 (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Finished 500 requests

Server Software:        
Server Hostname:        127.0.0.1
Server Port:            8080

Document Path:          /example2
Document Length:        14 bytes

Concurrency Level:      100
Time taken for tests:   0.923 seconds
Complete requests:      500
Failed requests:        0
Total transferred:      65500 bytes
HTML transferred:       7000 bytes
Requests per second:    541.61 [#/sec] (mean)
Time per request:       184.636 [ms] (mean)
Time per request:       1.846 [ms] (mean, across all concurrent requests)
Transfer rate:          69.29 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    1   1.8      0      20
Processing:     9  171 210.4     66     859
Waiting:        5  158 199.6     62     813
Total:          9  173 210.6     68     860

Percentage of the requests served within a certain time (ms)
  50%     68
  66%    133
  75%    198
  80%    292
  90%    566
  95%    696
  98%    723
  99%    743
 100%    860 (longest request)

可以看到,压测的结果得到了一定幅度的改善( Requestsper second 从原来的 506.63 提高为了 541.61),

并且 GC 的执行频率明显降低:

在实际实践中可表现为需要紧急处理一些由 GC 带来的瓶颈时,人为将 GOGC 调大,加钱加内存,扛过这一段峰值流量时期。

当然,这种做法其实是治标不治本,没有从根本上解决内存分配过于频繁的问题,极端情况下,反而会由于 GOGC 太大而导致回收不及时而耗费更多的时间来清理产生的垃圾,对实时性要求较高的程序来说就是致命的打击了。

小结

现在我们来总结一下前面三个例子中的优化情况:

  1. 控制内存分配的速度,限制 goroutine 的数量,从而提高赋值器对 CPU 的利用率。
  2. 减少并复用内存,例如使用 sync.Pool 来复用需要频繁创建临时对象,例如提前分配足够的内存来降低多余的拷贝。
  3. 需要时,增大 GOGC 的值,降低 GC 的运行频率。

这三种情况几乎涵盖了 GC 调优中的核心思路。

当然,我们还应该谨记 过早优化是万恶之源这一警语,在没有遇到应用的真正瓶颈时,将宝贵的时间分配在开发中其他优先级更高的任务上。

14. Go 垃圾回收器相关 API 和其作用

  • runtime.GC:手动触发 GC
  • runtime.ReadMemStats:读取内存相关的统计信息,其中包含部分 GC 相关的统计信息
  • debug.FreeOSMemory:手动将内存归还给操作系统
  • debug.ReadGCStats:读取关于 GC 的相关统计信息
  • debug.SetGCPercent:设置 GOGC 调步变量
  • debug.SetMaxHeap:设置 Go 程序堆的上限值

不同语言的GC

15. 目前提供 GC 的语言以及不提供 GC 的语言有哪些?GC 和 No GC 各自的优缺点是什么?

从原理上而言,所有的语言都能够自行实现 GC。从语言诞生之初就提供 GC 的语言,例如:

  • Python
  • JavaScript
  • Java
  • Objective-C
  • Swift

而不以 GC 为目标,被直接设计为手动管理内存、但可以自行实现 GC 的语言有:

  • C
  • C++

垃圾回收使程序员无需手动处理内存释放,从而能够消除一些需要手动管理内存才会出现的运行时错误:

  1. 在仍然有指向内存区块的指针的情况下释放这块内存时,会产生悬挂指针,从而后续可能错误的访问已经用于他用的内存区域。
  2. 多重释放同一块申请的内存区域可能导致不可知的内存损坏。

当然,垃圾回收也会伴随一些缺陷,这也就造就了没有 GC 的一些优势:

  1. 没有额外的性能开销
  2. 精准的手动内存管理,极致的利用机器的性能

16. Go 对比 Java 和 JavaScript 的 GC 性能如何?

Java的 GC 为分代式 GC。其核心假设就是分代假说:将对象依据存活时间分配到不同的区域,每次回收只回收其中的一个区域。

Java 的 GC

Java 的 GC 称之为 G1,并将整个堆分为年轻代、老年代和永久代。包括四种不同的收集操作,从上往下的这几个阶段会选择性地执行,触发条件是用户的配置和实际代码行为的预测。

  1. 年轻代收集周期:只对年轻代对象进行收集与清理
  2. 老年代收集周期:只对老年代对象进行收集与清理
  3. 混合式收集周期:同时对年轻代和老年代进行收集与清理
  4. 完整 GC 周期:完整的对整个堆进行收集与清理

在回收过程中,G1 会对停顿时间进行预测,竭尽所能地调整 GC 的策略从而达到用户代码对停顿时间的要求。

这四个周期的执行成本逐渐上升,优化得当的程序可以完全避免完整 GC 周期。

性能比较

在 Go、Java之间比较 GC 的性能本质上是一个不切实际的问题。如前面所说,垃圾回收器的设计权衡了很多方面的因素,同时还受语言自身设计的影响,因为语言的设计也直接影响了程序员编写代码的形式,也就自然影响了产生垃圾的方式。

但总的来说,他们对垃圾回收的实现都需要 STW,并均已达到了用户代码几乎无法感知到的状态。当然,随着 STW 的减少,垃圾回收器会增加 CPU 的使用率,这也是程序员在编写代码时需要手动进行优化的部分,即充分考虑内存分配的必要性,减少过多申请内存带给垃圾回收器的压力。

17. 目前 Go 的 GC 存在的问题

尽管 Go 团队宣称 STW 停顿时间得以优化到 100 微秒级别,但这本质上是一种取舍。原本的 STW 某种意义上来说其实转移到了可能导致用户代码停顿的几个位置。

目前 Go 中的 GC 仍然存在以下问题:

  1. Mark Assist 停顿时间过长
  2. Sweep 停顿时间过长
  3. 创建大量 Goroutine 后导致 GC 消耗更多的 CPU