译文 Go 高性能系列教程之五:内存和垃圾回收

yudotyang · 2021年06月11日 · 617 次阅读
本帖已被设为精华帖!

原文链接 https://dave.cheney.net/high-performance-go-workshop/gophercon-2019.html#memory-and-gc

Go 是一门自动垃圾收回的语言。这是设计原则,不能改变。

作为自动垃圾回收的语言,Go 程序的性能通常取决于他们与垃圾收集器的交互。

除了算法的选择以外,内存的消耗是决定应用程序性能和可伸缩性的最重要的因素。

本节将讨论垃圾回收器的操作,如何评估程序的内存使用情况以及在垃圾回收器的性能成为瓶颈时如何降低内存使用量的策略。

5.1 垃圾回收器的目的

任何垃圾回收器的目的都是为了让程序有足够的可用内存

你可能不太同意这个观点,但是这是垃圾回收器设计者工作时最基本的假设。

就总运行时间而言,STW、标记清除 GC 是最有效的垃圾回收算法。适用于批处理、模拟等。但是,随着时间的流式,Go GC 从纯粹的 STW(stop the world)转变成并发、非压缩的方式。这是因为 Go GC 被设计成了低延迟服务和交互程序。

Go GC 的设计偏向于低延迟,而不再是高吞吐;它将一些内存分配成本转移到了 mutator 上,以减少后续的清理成本。

5.2 垃圾回收器的设计

在过去的几年里,Go GC 的设计发生了很多改变:

  • Go 1.0,高度依赖于tcmalloc的 STW、标记清除收集器
  • Go 1.3,非常精确的收集器(fully precise collector),不会将堆内存上的大数字误认为是指针了,因此降低了内存浪费
  • Go 1.5,一个新的 GC 设计,主要关注在低延迟而非高吞吐量上。
  • Go 1.6,改进 GC,用低延迟处理大的堆内存。
  • Go 1.7,一些小的改进,主要是重构。
  • Go 1.8,更进一步降低 STW 的时间,目前降低到了 100 微秒以内。
  • Go 1.10+, 摆脱了纯粹的 goroutine 协同调度以降低触发整个 GC 周期的延迟
  • Go 1.13,重写 Scavenger

5.2.1 垃圾回收调整

Go 运行时提供了一个环境变量来调整 GC,GOGC GOGC 的公式是:

goal = reachable * (1 + GOGC/100)

该公式中,reachable 是当前的内存量,goal 是 GC 运行的目标堆内存量,即当堆内存量达到该值时就需要执行 GC 了。

GOGC 是 Go 运行时很早就支持的一个环境变量。可能比 GOROOT 的支持还早。GOGC 的值能够影响 GC 执行的频率。默认值是 100.即当分配的堆内存是目前的一倍的时候,则会运行 GC。

例如,如果我们现在有一个 256M 大小的堆内存,同时,GOGC=100(默认),当堆内存增加到以下值时,GC 将会执行:

512MB = 256MB * (1 + 100 / 100)
  • GOGC 变量值大于 100 时会导致堆内存增长过快,这样会减少 GC 的压力。
  • GOGC 小于 100 时,导致堆堆内存较慢的增长,会增大 GC 的压力。

GOGC 的默认值 100 只是一个指导值。你可以根据你线上应用的实际负载情况选择合适的值。

5.2.2 VSS 和 scavenger

很多应用程序都会有不同的阶段。启动阶段、稳定运行阶段和(可选)结束阶段。每个阶段都有不同的内存分析数据。启动阶段可能会处理或汇总大量的数据。稳定运行阶段可能会消耗和客户端连接数或请求数成比例的内存。关闭阶段可能会消耗和稳定运行阶段处理的数据量成正比的内存,以将数据汇总或写入到磁盘上。

实际上,您的应用程序在启动时可能会使用比其余阶段更多的内存,然后它的堆将超过必需的内存,但大部分未使用。 如果 Go 运行时可以告诉操作系统哪部分堆内存是没有被用到的,这将很有用。

Go 1.13 中的新特性 自从 Go 1.1 中实现了 scavenger 后,基本没有变更过。在 Go 1.13 中,scavenging 从后台周期性的操作迁移到了某些命令驱动,因此,没有从 scavenging 受益的进程不会为此付出代价,因为内存分配变化很大且长时间运行的程序应该会更有效的将内存归还给操作系统。 但是,一些与清除相关的 CL 尚未提交。 这项工作可能要到 Go 1.14 才能完成。

5.2.3 GC 监控

一种监控垃圾收集器最简单的方法是启用 GC 日志记录输出。

这些统计信息始终会收集,但通常会被禁止显示,您可以通过设置GODEBUG环境变量来启用它们的显示。

% env GODEBUG=gctrace=1 godoc -http=:8080
gc 1 @0.012s 2%: 0.026+0.39+0.10 ms clock, 0.21+0.88/0.52/0+0.84 ms cpu, 4->4->0 MB, 5 MB goal, 8 P
gc 2 @0.016s 3%: 0.038+0.41+0.042 ms clock, 0.30+1.2/0.59/0+0.33 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 3 @0.020s 4%: 0.054+0.56+0.054 ms clock, 0.43+1.0/0.59/0+0.43 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 4 @0.025s 4%: 0.043+0.52+0.058 ms clock, 0.34+1.3/0.64/0+0.46 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 5 @0.029s 5%: 0.058+0.64+0.053 ms clock, 0.46+1.3/0.89/0+0.42 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 6 @0.034s 5%: 0.062+0.42+0.050 ms clock, 0.50+1.2/0.63/0+0.40 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 7 @0.038s 6%: 0.057+0.47+0.046 ms clock, 0.46+1.2/0.67/0+0.37 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 8 @0.041s 6%: 0.049+0.42+0.057 ms clock, 0.39+1.1/0.57/0+0.46 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 9 @0.045s 6%: 0.047+0.38+0.042 ms clock, 0.37+0.94/0.61/0+0.33 ms cpu, 4->4->1 MB, 5 MB goal, 8 P

跟踪结果显示了 GC 活动的通用测量结果。gctrace=1 的输出格式是在runtime 包文档中描述的。

DEMO: Show godoc with GODEBUG=gctrace=1 enabled 示例:打开 GODEBUG=gctrace=1 以展示 godoc 结果

说明:在生产环境中使用该选项,对性能没有任何影响,

当你知道有问题的时候使用 GODEBUG=gctrace=1 是非常好的选择,但对于应用程序的常规检测,我推荐使用 net/http/pprof 接口。

import _ "net/http/pprof"

导入 net/http/pprof 包的时候,将会在/debug/pprof 中注册一个使用各种运行时指标的的处理器。包括:

  • 运行时协程列表,/debug/pprof/goroutine?debug=1
  • 静态内存分配报告,/debug/pprof/heap?debug=1

注意 net/http/pprof 将使用默认的 http.ServerMux 注册自己。如果你使用 http.ListenAndServe(address, nil) 时,要当心会暴露自己。

示例:godoc -http=:8080, 展示/debug/pprof

5.3 最小化内存分配

事实上,内存分配都是有代价开销的,无论你的语言是否是自动垃圾回收的还是手动回收的。

内存分配可能是整个代码库的开销。每一个都只占运行时的一小部分时间,但总的来说,他们代表了相当大的成本。因为这个成本贯穿在很多地方,确定最大的开销者可能很复杂,并且通常需要重新设计 API 接口。

每次分配都应按需分配 。 打个比方:如果您因为打算建立家庭而搬到更大的房子,那将是对您的资本的充分利用。 如果您因为某人要您照顾孩子一个下午而搬到更大的房子,那将浪费您的资金。

5.3.1 strings vs [] bytes

In Go string values are immutable, [] byte are mutable. 在 Go 语言中,字符串值是不可变的,[] byte 是可变的。

大多数应用程序都喜欢使用 string,但是大多数 IO 操作都是用 [] byte 完成的。

尽可能的避免将 [] byte 转换为字符串,这通常意味着选择一种表示形式,是选择字符串,还是选择 [] byte 来保存值。 如果您从网络或磁盘读取数据,则通常为 [] byte。

Go 中的 bytes package 包含许多与 string package 相同的操作 -- Split,Compare,HasPrefix,Trim 等。

在底层实现中,字符串和 bytes 使用相同的汇编原语。

5.3.2 用 [] byte 作为 map 的 key

使用 string 作为 map 的 key 是非常常见的,但有时候你会使用字节数组([] byte)作为 map 的 key。换句话说,你可能具有 [] byte 形式的键,但是切片又没有定义等价的运算符,因此 [] bytes 不能用作 map 的 key。

编译器针对这种情况实现了特定的优化:

var m map[string]string
v, ok := m[string(bytes)]

在 map 的 key 查找中,这将避免从 byte slice 到字符串的转换。这是非常特殊的,如果你做如下操作,那么将不会工作:

key := string(bytes)
val, ok := m[key]

5.3.3 [] byte 到 string 的转换

这是 Go 1.13 版本中的新特性

就像在 map 的 key 中会自动将 [] byte 转换成字符串一样,在比较两个 [] byte 切片是否相等时也需要将字节切片 [] byte 转换成字符串后再进行比较 - 本质上是对字节切片的内容做了一个拷贝,或者是使用字节切片类型的比较函数:bytes.Equal

好消息是,在 Go 1.13 中编译器已经对字节切片转换成字符串进行了改进,下面的是对字节切片进行比较时避免了内存分配的测试:

func BenchmarkBytesEqualInline(b *testing.B) {
    x := bytes.Repeat([]byte{'a'}, 1<<20)
    y := bytes.Repeat([]byte{'a'}, 1<<20)
    b.ReportAllocs()
    b.ResetTimer()
    for n := 0; n < b.N; n++ {
        if string(x) != string(y) {
            b.Fatal("x != y")
        }
    }
}

func BenchmarkBytesEqualExplicit(b *testing.B) {
    x := bytes.Repeat([]byte{'a'}, 1<<20)
    y := bytes.Repeat([]byte{'a'}, 1<<20)
    b.ReportAllocs()
    b.ResetTimer()
    for n := 0; n < b.N; n++ {
        q := string(x)
        r := string(y)
        if q != r {
            b.Fatal("x != y")
        }
    }
}

更进一步阅读 [https://go-review.googlesource.com/c/go/+/173323]

5.3.4 避免字符串连接

Go 语言中的字符串是不可变的,连接两个字符串将会产生第三个字符串。下面的程序哪个会更快?

s := request.ID
s += " " + client.Addr().String()
s += " " + time.Now().String()
r = s
var b bytes.Buffer
fmt.Fprintf(&b, "%s %v %v", request.ID, client.Addr(), time.Now())
r = b.String()
r = fmt.Sprintf("%s %v %v", request.ID, client.Addr(), time.Now())
b := make([]byte, 0, 40)
b = append(b, request.ID...)
b = append(b, ' ')
b = append(b, client.Addr().String()...)
b = append(b, ' ')
b = time.Now().AppendFormat(b, "2006-01-02 15:04:05.999999999 -0700 MST")
r = string(b)
var b strings.Builder
b.WriteString(request.ID)
b.WriteString(" ")
b.WriteString(client.Addr().String())
b.WriteString(" ")
b.WriteString(time.Now().String())
r = b.String()

DEMO: go test -bench=. ./examples/concat

5.3.5 不要在你的 API 调用方上强制分配内存

确保你的 API 调用方减少内存垃圾生成的数量

考虑以下两种 Read 方法:

func (r *Reader) Read() ([]byte, error)
func (r *Reader) Read(buf []byte) (int, error)

第一个方法是不带任何参数的,并且返回一个 [] byte。第二个函数时带一个 [] byte 的 buf 参数,并且返回的是读取到的字节数量。

第一个 Read 方法调用方总是会分配一个字节切片来接收返回的值,这在 GC 上带来压力。第二个方法是将读到的数据填充到已经给的 buffer 中。 译者注:第一个方法是因为在 Read 函数中分配的字节切片指向的内存逃逸到了堆上,而堆内存是需要进行 GC 回收的,所以会增加 GC 的压力。但第二个方法是只有调用者分配了一次内存,被调用者共享了传入进来的内存

5.3.6 如果切片长度可知则可预先分配

Append 函数非常方便,但也比较浪费资源,

Slice 的空间扩容首先会按成倍的方式直至扩容到 1024 个元素,然后每次扩容会按原容量 25% 的增速扩容。那么,以下代码中,我们使用 append 往 b 中添加 1 个或多个元素后其容量是多少?

func main() {
   b := make([]int, 1024)
   b = append(b, 99)
   fmt.Println("len:", len(b), "cap:", cap(b))
}

如果你使用 append 模式,那么你可能会拷贝大量的数据,同时也会产生大量的垃圾。

如果能提前知道切片的长度,然后预分配目标大小的切片容量,就可以避免数据的拷贝,并能依然能够确保容量的正确性

之前:

var s []string
for _, v := range fn() {
        s = append(s, v)
}
return s

之后:

vals := fn()
s := make([]string, len(vals))
for i, v := range vals {
        s[i] = v
}
return s

5.4 使用 sync.Pool

注意:sync.Pool 不是缓存。它在任何时刻都可能被清空。不要把任何重要的数据放在 sync.Pool 中,他们有可能会被删除。

sync 包中的 sync.Pool 被用于对象的重用。 sync.Pool 没有固定的大小或最大容量。你可以增加一个对象到 sync.Pool 中,也可以从 sync.Pool 中获取一个对象,直到有 GC 发生,sync.Pool 将会被无条件的被清空。这是就这样设计的。

sync.Pool 实践:

var pool = sync.Pool{New: func() interface{} { return make([]byte, 4096) }}

func fn() {
    buf := pool.Get().([]byte) // takes from pool or calls New
    // do work
    pool.Put(buf) // returns buf to the pool
}

5.5 重排结构体的字段顺序以便更好的压缩

考虑以下结构体的定义

type S struct {
    a bool
    b float64
    c int32
}

那么,一个该类型的变量值将占用多少内存呢?

var s S
fmt.Println(unsafe.Sizeof(s)) 

结果是在 64 位系统上是 24 字节,在 32 位系统上是 16 字节。

这是为什么呢?这不得不从内存对齐说起(padding and alignment)

float64 类型的值在所有的平台上都是 8 字节的,因此它们必须始终位于 8 的倍数的地址处。这叫做自然对齐。因为 CPU 自然希望长度为 4 字节的字段在 4 字节边界上对齐,8 字节的字段在 8 字节边界上对齐,依此类推。这是因为某些平台(尤其不是 Intel)不允许您对未正确对齐的值进行操作。 即使在确实支持所谓的不对齐访问的平台上,访问这些字段通常也会产生成本。

了解了内存对齐之后,我们就能知道编译器是如何将这些字段排列在内存上的了:

type S struct {
    a bool
    _ [7]byte // padding 注释1
    b float64
    c int32
    _ [4]byte // padding 
}
  • 在 a bool 字段上,bool 值原本占 1 个字节,所以需要额外填充 7 个字节,以确保 b float64 是从 8 字节的边界上开始的。
  • 在 c int32 字段删个,int32 值原本占 4 个字节,所以需要额外填充 4 个字节以确保 S 数组或切片在内存上整体排列的正确性。

深入阅读:Padding is hard

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
astaxie 将本帖设为了精华贴 06月12日 02:38
bugme GoCN 每日新闻 (2021-06-12) 中提及了此贴 06月12日 08:49
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册