20. 目前 Go 语言的 GC 还存在哪些问题?

尽管 Go 团队宣称 STW 停顿时间得以优化到 100 微秒级别,但这本质上是一种取舍。原本的 STW 某种意义上来说其实转移到了可能导致用户代码停顿的几个位置;除此之外,由于运行时调度器的实现方式,同样对 GC 存在一定程度的影响。

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

1. Mark Assist 停顿时间过长

  1. package main
  2. import (
  3. "fmt"
  4. "os"
  5. "runtime"
  6. "runtime/trace"
  7. "time"
  8. )
  9. const (
  10. windowSize = 200000
  11. msgCount = 1000000
  12. )
  13. var (
  14. best time.Duration = time.Second
  15. bestAt time.Time
  16. worst time.Duration
  17. worstAt time.Time
  18. start = time.Now()
  19. )
  20. func main() {
  21. f, _ := os.Create("trace.out")
  22. defer f.Close()
  23. trace.Start(f)
  24. defer trace.Stop()
  25. for i := 0; i < 5; i++ {
  26. measure()
  27. worst = 0
  28. best = time.Second
  29. runtime.GC()
  30. }
  31. }
  32. func measure() {
  33. var c channel
  34. for i := 0; i < msgCount; i++ {
  35. c.sendMsg(i)
  36. }
  37. fmt.Printf("Best send delay %v at %v, worst send delay: %v at %v. Wall clock: %v \n", best, bestAt.Sub(start), worst, worstAt.Sub(start), time.Since(start))
  38. }
  39. type channel [windowSize][]byte
  40. func (c *channel) sendMsg(id int) {
  41. start := time.Now()
  42. // 模拟发送
  43. (*c)[id%windowSize] = newMsg(id)
  44. end := time.Now()
  45. elapsed := end.Sub(start)
  46. if elapsed > worst {
  47. worst = elapsed
  48. worstAt = end
  49. }
  50. if elapsed < best {
  51. best = elapsed
  52. bestAt = end
  53. }
  54. }
  55. func newMsg(n int) []byte {
  56. m := make([]byte, 1024)
  57. for i := range m {
  58. m[i] = byte(n)
  59. }
  60. return m
  61. }

运行此程序我们可以得到类似下面的结果:

  1. $ go run main.go
  2. Best send delay 330ns at 773.037956ms, worst send delay: 7.127915ms at 579.835487ms. Wall clock: 831.066632ms
  3. Best send delay 331ns at 873.672966ms, worst send delay: 6.731947ms at 1.023969626s. Wall clock: 1.515295559s
  4. Best send delay 330ns at 1.812141567s, worst send delay: 5.34028ms at 2.193858359s. Wall clock: 2.199921749s
  5. Best send delay 338ns at 2.722161771s, worst send delay: 7.479482ms at 2.665355216s. Wall clock: 2.920174197s
  6. Best send delay 337ns at 3.173649445s, worst send delay: 6.989577ms at 3.361716121s. Wall clock: 3.615079348s

总结 - 图1

在这个结果中,第一次的最坏延迟时间高达 7.12 毫秒,发生在程序运行 578 毫秒左右。通过 go tool trace 可以发现,这个时间段中,Mark Assist 执行了 7112312ns,约为 7.127915ms;可见,此时最坏情况下,标记辅助拖慢了用户代码的执行,是造成 7 毫秒延迟的原因。

2. Sweep 停顿时间过长

同样还是刚才的例子,如果我们仔细观察 Mark Assist 后发生的 Sweep 阶段,竟然对用户代码的影响长达约 30ms,根据调用栈信息可以看到,该 Sweep 过程发生在内存分配阶段:

总结 - 图2

3. 由于 GC 算法的不正确性导致 GC 周期被迫重新执行

此问题很难复现,但是一个已知的问题,根据 Go 团队的描述,能够在 1334 次构建中发生一次 [15],我们可以计算出其触发概率约为 0.0007496251874。虽然发生概率很低,但一旦发生,GC 需要被重新执行,非常不幸。

4. 创建大量 Goroutine 后导致 GC 消耗更多的 CPU

这个问题可以通过以下程序进行验证:

  1. func BenchmarkGCLargeGs(b *testing.B) {
  2. wg := sync.WaitGroup{}
  3. for ng := 100; ng <= 1000000; ng *= 10 {
  4. b.Run(fmt.Sprintf("#g-%d", ng), func(b *testing.B) {
  5. // 创建大量 goroutine,由于每次创建的 goroutine 会休眠
  6. // 从而运行时不会复用正在休眠的 goroutine,进而不断创建新的 g
  7. wg.Add(ng)
  8. for i := 0; i < ng; i++ {
  9. go func() {
  10. time.Sleep(100 * time.Millisecond)
  11. wg.Done()
  12. }()
  13. }
  14. wg.Wait()
  15. // 现运行一次 GC 来提供一致的内存环境
  16. runtime.GC()
  17. // 记录运行 b.N 次 GC 需要的时间
  18. b.ResetTimer()
  19. for i := 0; i < b.N; i++ {
  20. runtime.GC()
  21. }
  22. })
  23. }
  24. }

其结果可以通过如下指令来获得:

  1. $ go test -bench=BenchmarkGCLargeGs -run=^$ -count=5 -v . | tee 4.txt
  2. $ benchstat 4.txt
  3. name time/op
  4. GCLargeGs/#g-100-12 192µs ± 5%
  5. GCLargeGs/#g-1000-12 331µs ± 1%
  6. GCLargeGs/#g-10000-12 1.22ms ± 1%
  7. GCLargeGs/#g-100000-12 10.9ms ± 3%
  8. GCLargeGs/#g-1000000-12 32.5ms ± 4%

这种情况通常发生于峰值流量后,大量 goroutine 由于任务等待被休眠,从而运行时不断创建新的 goroutine,旧的 goroutine 由于休眠未被销毁且得不到复用,导致 GC 需要扫描的执行栈越来越多,进而完成 GC 所需的时间越来越长。一个解决办法是使用 goroutine 池来限制创建的 goroutine 数量。

总结

GC 是一个复杂的系统工程,本文讨论的二十个问题尽管已经展现了一个相对全面的 Go GC。但它们仍然只是 GC 这一宏观问题的一小部分较为重要的内容,还有非常多的细枝末节、研究进展无法在有限的篇幅内完整讨论。

从 Go 诞生之初,Go 团队就一直在对 GC 的表现进行实验与优化,但仍然有诸多未解决的公开问题,我们不妨对 GC 未来的改进拭目以待。

进一步阅读的主要参考文献

其他参考文献