Go 避免在堆中的高开销垃圾回收

Jan 14, 2019 17:50 · 1844 words · 4 minute read Golang

当分配的内存相对较小时,Go 的垃圾回收器(GC)工作得非常好,但是面对较大的堆时,GC 可能会占用大量 CPU,而在某些极端的情况下,甚至会掉队。

问题?

GC 的工作是确定哪些内存碎片可供释放,通过扫描内存寻找内存分配的指针。简单地说,如果没有指向已分配内存的指针,就可以这块已分配的内存就可以被释放。这样非常有效,但是扫描的内存越多,所需的时间也就越多。

假设你写了一个内存数据库,或者正在构建一个需要庞大的查找表的数据管道,在这些情景中,可能以 G 为单位的内存会被分配。在这种情况下,可能会因为 GC 损失掉相当多的性能。

很严重?

这个问题有多严重?我们分配十亿个(1e9)8字节的指针,大概 8GB 左右的内存。然后强行垃圾回收,算出花费的时间。执行十遍来获得一个稳定的值。

func main() {
    a := make([]*int, 1e9)

    for i := 0; i < 10; i++ {
        start := time.Now()
        runtime.GC()
        fmt.Printf("GC took %s\n", time.Since(start))
    }

    runtime.KeepAlive(a)
}

我的2015款 MBP 上得到的结果:

GC took 9.864259115s
GC took 3.790543642s
GC took 6.258383432s
GC took 7.149240234s
GC took 6.738356186s
GC took 6.542580595s
GC took 5.813144807s
GC took 673.113434ms
GC took 655.949867ms
GC took 684.395757ms

GC 花费了好几秒的时间。这有什么让人惊讶的?十亿个指针呢。实际上检查每个指针都不到纳秒,扫描指针的速度已经相当快了。

然后?

这看起来是一个原则问题,如果我们的程序需要一个庞大的内存查找表,或者程序就是一大块内存查找表,那么问题来了。如果 GC 坚持周期性地扫描所有已分配的内存,将会损失大量的处理能力。我们又能做些什么呢?

本质上我们有两个选择。要么把内存给藏起来,要么让 GC 视而不见。

将内存丑化

怎样才能让 GC 对内存不感兴趣。GC 会寻找指针,如果我们分配的对象不包含指针呢?GC 还会扫描它吗?

试一下。下面的例子中我们将分配和之前大小完全相同的内存,但是现在没有指针类型了。

func main() {
    a := make([]int, 1e9)

    for i := 0; i < 10; i++ {
        start := time.Now()
        runtime.GC()
        fmt.Printf("GC took %s\n", time.Since(start))
    }

    runtime.KeepAlive(a)
}

在相同的机器上再执行一遍:

GC took 417.607µs
GC took 205.877µs
GC took 171.876µs
GC took 162.338µs
GC took 141.146µs
GC took 112.821µs
GC took 122.611µs
GC took 109.086µs
GC took 133.159µs
GC took 96.555µs

GC 差不多快了1000倍,虽然同样分配了10亿个8字节的内存。事实证明,Go 内存管理器知道每个分配的内存是什么类型的数据,并且会标记处不包含指针的内存以便 GC 在扫描时忽略。如果我们可以做到内存查找表中没有指针,那就赢了。

让内存消失?

我们可以做的另一件事是隐藏内存。如果我们直接问 OS 要内存,GC 就不会发现它,也就不会扫描它了。只是有点复杂。

package main

import (
    "fmt"
    "reflect"
    "runtime"
    "syscall"
    "time"
    "unsafe"
)

func main() {
    var example *int
    slice := makeSlice(1e9, unsafe.Sizeof(example))
    a := *(*[]*int)(unsafe.Pointer(&slice))

    for i := 0; i < 10; i++ {
        start := time.Now()
        runtime.GC()
        fmt.Printf("GC took %s\n", time.Since(start))
    }

    runtime.KeepAlive(a)
}

func makeSlice(len int, eltsize uintptr) reflect.SliceHeader {
    fd := -1
    data, _, errno := syscall.Syscall6(
        syscall.SYS_MMAP,
        0, // address
        uintptr(len)*eltsize,
        syscall.PROT_READ|syscall.PROT_WRITE,
        syscall.MAP_ANON|syscall.MAP_PRIVATE,
        uintptr(fd), // No file descriptor
        0,           // offset
    )
    if errno != 0 {
        panic(errno)
    }

    return reflect.SliceHeader{
        Data: data,
        Len:  len,
        Cap:  len,
    }
}

这个例子等同于分配十亿个 *int 指针,这次,我们用了 syscall 来从 OS 内核直接申请内存。注意:只在类 UNIX 系统上有效。

下面是输出:

GC took 412.22µs
GC took 137.717µs
GC took 167.307µs
GC took 193.459µs
GC took 167.506µs
GC took 128.462µs
GC took 136.087µs
GC took 148.207µs
GC took 136.819µs
GC took 123.508µs

现在内存对 GC 是不可见的。这种非常规手段当然会带来不良后果。

我们尝试在堆中存储整数0,1和2,在堆外分配的切片中存储它们的指针,强行 GC。

func main() {
    var example *int
    slice := makeSlice(3, unsafe.Sizeof(example))
    a := *(*[]*int)(unsafe.Pointer(&slice))

    for j := range a {
        a[j] = getMeAnInt(j)

        fmt.Printf("a[%d] is %X\n", j, a[j])
        fmt.Printf("*a[%d] is %d\n", j, *a[j])

        runtime.GC()
    }

    fmt.Println()
    for j := range a {
        fmt.Printf("*a[%d] is %d\n", j, *a[j])
    }
}

func getMeAnInt(i int) *int {
    b := i
    return &b
}

func makeSlice(len int, eltsize uintptr) reflect.SliceHeader {
    fd := -1
    data, _, errno := syscall.Syscall6(
        syscall.SYS_MMAP,
        0, // address
        uintptr(len)*eltsize,
        syscall.PROT_READ|syscall.PROT_WRITE,
        syscall.MAP_ANON|syscall.MAP_PRIVATE,
        uintptr(fd), // No file descriptor
        0,           // offset
    )
    if errno != 0 {
        panic(errno)
    }

    return reflect.SliceHeader{
        Data: data,
        Len:  len,
        Cap:  len,
    }
}

下面是输出。每次 GC 后,分配给整形数的内存被释放并可以被重新使用。数据和设想的不太一样,幸好程序没崩。。。

a[0] is C000018080
*a[0] is 0
a[1] is C00008C040
*a[1] is 1
a[2] is C00008C040
*a[2] is 2

*a[0] is 0
*a[1] is 811295018
*a[2] is 811295018

如果改成正常的 []*int 就能得到想要的结果了:

func main() {
    a := make([]*int, 3)

    for j := range a {
        a[j] = getMeAnInt(j)

        fmt.Printf("a[%d] is %X\n", j, a[j])
        fmt.Printf("*a[%d] is %d\n", j, *a[j])

        runtime.GC()
    }

    fmt.Println()
    for j := range a {
        fmt.Printf("*a[%d] is %d\n", j, *a[j])
    }
}
a[0] is C000018080
*a[0] is 0
a[1] is C00008A040
*a[1] is 1
a[2] is C00008A050
*a[2] is 2

*a[0] is 0
*a[1] is 1
*a[2] is 2

问题的本质

看起来指针都是问题的关键,不论我们在堆上分配大量内存,又或是试图通过将数据挪到自己分配的堆外内存中。**如果我们可以避免分配内存给指针类型,就不会引发 GC 开销,而且也不需要搞些花里胡哨的堆外内存技巧。如果非要分配堆外内存,我们要避免存储指向堆内存的指针,除非这些指针对 GC 可见。

避开指针

在体量庞大的堆中,指针不是什么好东西,必须避开。你要带上火眼金睛,因为它们并不总是显式的。string,slice,time 全都包含了指针。大堆有问题时,可能是下面原因:

  • 很多 string
  • 使用 time.Time 的对象
  • 值包含切片的 map
  • 键包含字符串的 map