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

当分配的内存量相对较小时,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/

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,099评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,828评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,540评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,848评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,971评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,132评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,193评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,934评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,376评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,687评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,846评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,537评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,175评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,887评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,134评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,674评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,741评论 2 351