大堆栈带来的高GC开销的问题

晓_7611 · · 813 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

当分配的内存量相对较小时,Go垃圾收集器(GC)工作得非常好,但是如果堆大小较大,GC最终可能会使用大量的CPU。在极端情况下,它可能无法跟上。

有什么问题?

GC的工作是确定哪些内存块可以释放,它通过扫描指向分配的内存的指针来实现这一点。简单地说,如果没有指向分配内存的指针,那么可以释放这个内存。这很有效,但是扫描内存越多,扫描时间就越长。
假设您已经编写了一个内存中的数据库,或者您正在构建一个需要一个巨大的查找表的pipeline。在这些场景中,您可能分配了千兆字节的内存。在这种情况下,GC可能会损失相当多的潜在性能。

这个是个大问题吗?

有多少问题?让我们看看!这里有一个小程序要演示。我们分配了10亿(1E9)个8字节指针,因此大约有8GB的内存。然后我们强制一个GC并计算它需要多长时间。我们这样做几次,得到一个稳定的值。我们还调用runtime.keepalive()以确保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 4.275752421s
GC took 1.465274593s
GC took 652.591348ms
GC took 648.295749ms
GC took 574.027934ms
GC took 560.615987ms
GC took 555.199337ms
GC took 1.071215002s
GC took 544.226187ms
GC took 545.682881ms

GC需要半秒钟。为什么这会令人惊讶呢?我已经分配了10亿个指针。实际上,检查每个指针不到一纳秒,这是一个很好的速度。

那么接下来呢

这似乎是一个根本问题。如果我们的应用程序需要一个大的内存查找表,或者如果我们的应用程序从根本上是一个大的内存查找表,那么我们就遇到了一个问题。如果GC坚持定期扫描我们分配的所有内存,我们将失去GC大量可用的处理能力。我们该怎么办?

让GC变得迟钝

GC怎么才能变迟钝?嗯,GC正在寻找指针。如果我们分配的对象的类型不包含指针怎么办?GC还会扫描它吗?
我们可以试试。在下面的示例中,我们分配的内存量与以前完全相同,但现在我们的分配中没有指针类型。我们分配了一个10亿个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)
}

再次运行:

GC took 350.941µs
GC took 179.517µs
GC took 169.442µs
GC took 191.353µs
GC took 126.585µs
GC took 127.504µs
GC took 111.425µs
GC took 163.378µs
GC took 145.257µs
GC took 144.757µs

由于分配的内存量完全相同,GC的速度要快1000倍多。结果表明,Go内存管理器知道每个分配的类型,并将标记不包含指针的分配,这样GC就不必扫描它们。如果我们可以安排内存表没有指针,那么我们就是赢家。

隐藏内存

我们可以做的另一件事是对GC隐藏分配的内存。如果我们直接向操作系统请求内存,GC就永远不会发现它,因此不会扫描它。这比我们前面的例子要复杂一点!

这类似于我们的第一个程序,我们分配了一个带有10亿(1E9)元素的[]*int。这次,我们使用mmap系统调用直接从操作系统内核请求内存。注意:这是在类似Unix的操作系统上工作,但是在Windows上也可以做类似的事情。

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,
    }
}

输出:

GC took 460.777µs
GC took 206.805µs
GC took 174.58µs
GC took 193.697µs
GC took 184.325µs
GC took 142.556µs
GC took 132.48µs
GC took 155.853µs
GC took 138.54µs
GC took 159.04µs

要了解a := *(*[]*int)(unsafe.Pointer(&slice))查看连接:https://blog.gopheracademy.com/advent-2017/unsafe-pointer-and-system-calls/
现在,这些内存对GC不可见。这就产生了一个有趣的结果,即存储在此内存中的指针不会停止GC收集它们指向的“正常”分配的内存。这会带来很坏的后果,很容易证明这一点。
在这里,我们尝试将数字0、1和2存储在分配给堆的int中,并将指向它们的指针存储在分配给堆外mmap的切片中。我们在使指针指向分配的每个int之后强制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
}

这是我们的输出。支持ints的内存被释放,并可能在每个gc之后重新使用。但是我们的数据并不像我们预期的那样,虽然还没有崩溃。

a[0] is C000016090
*a[0] is 0
a[1] is C00008C030
*a[1] is 1
a[2] is C00008C030
*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 C00009A000
*a[0] is 0
a[1] is C00009A040
*a[1] is 1
a[2] is C00009A050
*a[2] is 2

*a[0] is 0
*a[1] is 1
*a[2] is 2
问题的核心

所以,结果表明指针是敌人,无论是在堆上分配了大量内存时,还是在我们试图通过将数据移动到自己的堆外分配来解决这一问题时。如果我们可以避免分配的类型中的任何指针,它们不会导致GC开销,因此我们不需要使用任何堆外技巧。如果我们确实使用堆外分配,那么我们需要避免存储指向堆的指针,除非这些指针也被GC可见的内存引用。

我们怎样才能避免指针?

在大堆栈中,指针是邪恶的,必须避免。但是你需要能够发现它们以避免它们,而且它们并不总是显而易见的。字符串、切片和时间。时间都包含指针。如果你在内存中储存了大量的这些信息,可能需要采取一些步骤。
当我遇到大堆的问题时,主要原因如下:

  • 大量的string
  • 对象中的时间是time.Time类型
  • map中含有slice的值
  • map中含有slice的key
    关于处理每一个问题的不同策略,有很多话要说。在这篇文章中,我将讨论处理字符串的一个想法。
strings

什么是string?嗯,有两部分。有一个字符串头,它告诉你它有多长,以及基础数据在哪里。然后是底层数据,它只是一个字节序列。
字符串头由reflect.string header描述,如下所示。

type StringHeader struct {
    Data uintptr
    Len  int
}

字符串头包含指针,因此我们希望避免存储字符串!

  • 如果字符串只接受几个固定值,则考虑改用整型常量。
  • 如果您将日期和时间存储为字符串,那么可以解析它们并将日期或时间存储为整数。
  • 如果你基本上需要存储大量的字符串,那么继续读下去…
    假设我们存储了一亿个字符串。为了简单起见,假设这是一个巨大的全局var mystrings[]字符串。
    我们这里有什么?myStrings的底层是一个reflect.sliceHeader,它看起来类似于我们刚才看到的reflect.stringHeader
type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

对于myStrings,len和cap分别为100000000,数据将指向一个足够大的连续内存块,以包含100000000个stringHeader。这段内存包含指针,因此将由GC扫描
strings 本身由两部分组成。包含在此切片中的StringHeaders,以及每个字符串的数据,这些字符串是单独的分配,它们都不能包含指针。从GC的角度来看,字符串头是一个问题,而不是字符串数据本身。字符串数据不包含指针,因此不进行扫描。巨大的字符串头数组确实包含指针,因此必须在每个GC循环中进行扫描。

image.png

我们该怎么办?好吧,如果所有的字符串字节都在一块内存中,那么我们可以通过偏移量跟踪字符串到内存中每个字符串的开始和结束。通过跟踪偏移量,我们的大块中不再有指针,GC也不再有问题。
image.png

我们通过这样做放弃的是为单个字符串释放内存的能力,并且我们增加了一些将字符串体复制到大字节片中的开销。
下面是一个演示这个想法的小程序。我们将创建100000000个字符串,将字符串中的字节复制到一个大字节片中,并存储偏移量。然后我们将显示gc时间仍然很短,并演示通过显示前10个字符串来检索字符串。

package main

import (
    "fmt"
    "runtime"
    "strconv"
    "time"
    "unsafe"
)

func main() {
    var stringBytes []byte
    var stringOffsets []int

    for i := 0; i < 1e8; i++ {
        val := strconv.Itoa(i)

        stringBytes = append(stringBytes, val...)
        stringOffsets = append(stringOffsets, len(stringBytes))
    }

    runtime.GC()
    start := time.Now()
    runtime.GC()
    fmt.Printf("GC took %s\n", time.Since(start))

    sStart := 0
    for i := 0; i < 10; i++ {
        sEnd := stringOffsets[i]
        bytes := stringBytes[sStart:sEnd]
        stringVal := *(*string)(unsafe.Pointer(&bytes))
        fmt.Println(stringVal)

        sStart = sEnd
    }
}
GC took 187.082µs
0
1
2
3
4
5
6
7
8
9

这里的原则是,如果不需要释放字符串,可以将其转换为索引,转换为更大的数据块,并避免有大量指针。如果你感兴趣的话,我已经建立了一个稍微复杂一点的东西,遵循这个原则。
我以前在 博客中提到过遇到由大型堆引起的垃圾收集器(GC)问题。 好几次。事实上,每次我碰到这个问题,我都会感到惊讶,我 震惊的是,我写了一篇关于它的博客。希望通过阅读到目前为止,如果它发生在您的项目中,您不会感到惊讶,或者您甚至可以预见到问题!
以下是一些处理这些问题的有用资源。

  • 我上面提到的字符串存储
  • 一个字符串interning 库,用来存储字符串到字符串银行并保证唯一性
  • 一个变量,用于转换字符串interning 库中的唯一字符串和可用于索引到数组中的序列号。

原文:https://blog.gopheracademy.com/advent-2018/avoid-gc-overhead-large-heaps/


有疑问加站长微信联系(非本文作者)

本文来自:简书

感谢作者:晓_7611

查看原文:大堆栈带来的高GC开销的问题

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

813 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传