Go避免使用大堆造成的高GC开销

本文探讨了Go语言在处理大量内存分配时,GC(垃圾回收)可能导致的高开销问题。通过实例展示了即使在10亿个指针的情况下,GC也需要半秒左右的时间,提出通过避免使用指针、隐藏内存分配以及字符串处理等方法来减少GC扫描,从而提高性能。建议在大内存分配时避免使用指针,尤其是字符串,可以考虑使用整型常量或特殊数据结构来优化。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

原文链接:Avoiding high GC overhead with large heaps

Go的Garbage Collector(GC)在分配的内存量相对较小时工作得非常好,但是如果堆较大,GC最终可能会占用大量的CPU,在极端情况下,它甚至可能无法跟上节奏。

What‘s the problem?

GC的工作就是确定哪些内存可以释放,它是通过扫描内存查找内存分配的指针来完成这个工作的。简而言之,如果对于一个内存分配没有一个指针指向它,则它就可以被释放了。这个工作得非常好,但是内存空间越大扫描需要花费的时间越长。

假设你在开发一个内存数据库,或者你在构建一个需要巨大的查找表的数据流水线。在这些场景下,你可能有数个G的内存分配。在这些情形下,你可能会因为GC损失很多的性能。

Is it a big problem?

让我们来看看这个问题到底有多大?下面通过一段很小的代码来演示这个问题。我们分配了10亿(1e9)个8字节的指针,总共占用了8G的内存。然后我强制执行一次GC,并统计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亿个指针耶,这有什么值得惊讶的?每个指针所分摊的时间看起来都低于纳秒,这个对于指针查找已经是一个很不错的速度了。

So what next?

这看起来是一个基本原则的问题。假如我们的应用就是需要一个很大的内存查找表,或者我们的应用基本上就是一个很大的内存查找表,那么我们就会遇到这个问题。如果GC以一个固定的时间周期扫描所有的已经分配的内存,我们将会因为GC损失巨大的CPU可用处理能力。对于这种情况我们能做什么呢?

Make our memory dull

如何让内存不被GC盯上?嗯,GC是在查找指针。如果我们分配的对象的类型不包括指针呢,GC还会扫描它们么?

让我们来试试。下面的示例中,我们分配了与前面示例完全相同的内存,但是现在我们没有包含指针类型在里面。我们分配了一个包含了10亿个int类型的数组,这同样占用了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就不会去扫描他们。如果我们能够做到我们的内存表中不包含指针类型,那我们就是赢了。

Keep our memory hidden

我们可以采用的另一种方法就是对GC隐藏我们的内存分配。如果我们执行从OS申请内存,则GC永远都不会找到它,因此也就不会扫描到它。这个相对于前面的方法稍微有点复杂。

下面的示例等效于我们第一个示例,我们分配了一个包含10亿个指向int型的指针的数组。这么我们使用了mmap系统调用直接向OS内核申请内存。

注意:这段代码只能在Unix-like系统上运行,但是在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))是什么意思?参考这篇博客获取更详细信息:unsafe.Pointer and system calls

现在这片内存对GC就是不可见的了。这会造成一个有趣的后果,存储在这片内存中的指针不能阻止GC对它们所指向的“正常”内存分配的收集。这个将是一个坏的后果,并且这个后果很难被发现。

下面我们尝试将0,1和2分配给从堆中分配的整形数中,并且将指向它们的指针存储在不是直接从堆上申请而是通过mmap-allocated系统调用申请的数组中。在内存申请和数据存储后,我们强制执行了一次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
}

下面是这段代码的输出。在我们整形后的内存已经被释放,并且在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

The nub of the problem

所以,事实证明指针就是问题的核心,不过是我们在堆上有大规模的内存分配,还是我们想通过从非堆上申请内存来绕过堆上大规模内存分配的问题。如果我们可以避免我们分配的类型中有任何的指针,则可以避开GC的开销,也不需要使用任何off-heap的技巧。如果我们实在要用off-heap技巧,那么我们需要避免在不是从堆上分配的内存中存在指向堆空间的指针,除非这些指针还在被GC可见内存中引用。

How can we avoid pointers?

在很大的堆中,指针是邪恶的,必须避免。要避免它们你首先需要发现它们,但是有时候它们不总是很明显。string, slicetime.Time都包含指针。如果你在内存中存储了很多这些类型,可能你需要采取一些措施。

当我在大堆中遇到问题时,多半都是以下原因造成的:

  • 很多string
  • 对象的时间戳使用time.Time
  • Maps里面存储了slice
  • Maps的key是string

关于处理这些问题的不同策略有很多可以说的。这篇文章中我只介绍处理string的一个想法。

String

什么是string?它由两部分组成。string header:告诉你它有多长,以及底层数据在哪里。然后就是underlying data:它就是一个字节序列。

当你传递一个string给一个函数,实际上是string header被写到了函数的栈上。如果一个string数组,数组中存的也实际是string header

string headerreflect.StringHeader描述,看起来就有点像下面这个样子:

type StringHeader struct {
	Data uintptr
	Len  int
}

string header包含指针,所以我们想避免存储strings!

  • 如果你的string只是几个固定的值,就考虑用整型常量代替;
  • 如果你是用strings存储日期和时间,也许可以解析它们然后用整数存储日期和时间;
  • 如果你实在是需要存储很多字符串,那就继续往下读… …

假设我们需要存储上亿个字符串。简单起见,我假设这是一个巨大的全局字符串数组:var mystrings []string

在这里我们有些什么?首先,从底层数据结构看,变量mystring是一个reflect.SliceHeader,它看起来跟reflect.StringHeader有点类似。

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

对于mystrings来说,LenCap的值都是100,000,000,Data将指向一个连续的足够包含100,000,000个StringHeaders的内存块。那段内存包含指针,因此将会被GC扫描。

string本身就包含两个部分。StringHeader被包含在数组中,然后对于每个string的数据将被单独分配,它们不再包含指针。从GC角度来说,string的头才是问题,而不是string本身。string的数据不会包含指针,所以不会被扫描。这个包含StringHeader的巨大数组才包含指针,它在每一个GC周期都必须被扫描。
String header pointer

对于这种情况我们能做什么?如果所有的string字节都在一个单一内存片段中,我们就可以通过偏移来追踪某个字符串在这段内存中的开始和结束位置。通过追踪偏移,我们不在需要在我们大数组中存储指针,GC也不在会被困扰。
String header optimize

这样做我们需要放弃的是指向单个string的灵活性,并且我们将所有字符串数据拷贝到一个大的连续内存片段中也增加了额外的开销。

下面是一段小的代码演示这个想法。我们将创建100,000,000个字符串。将字符串中的字节数据拷贝到一个连续的字节数组中,并且存储字符串在数组中的偏移。我们将证明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

原则就是:如果你永远都不需要释放一个字符串,你可以将它转换成一个大数据块中的一个索引来避免拥有大量的指针。如果你有兴趣,我其实还基于这个原则构建了一个稍微复杂的实例,你可以在GitHub中找到它:https://github.com/philpearl/stringbank.

我之前写过关于大堆引起的GC问题的博客。有好几次,事实上是每次遇到这个问题我都很惊讶,而且在我震惊的时候我会又发表一遍这个博客。希望通过阅读这篇文章,你在你的项目中遇到这个问题时不会惊讶,甚至你可以提前预料到这个问题。

以下是一些你可以参考有助于解决这些问题的资源:

  • StringBank:支持将大量的字符串存储在一个大的内存块中来避免过多的指针分配。
  • string interning library: 保证重复的字符串只会被存储一份,降低字符串存储的GC的负载。
  • string symbol table:将字符串ID转换成递增的整型ID,整型ID将用于索引存储在大内存块中字符串。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值