golang的GC简单梳理
切片的结构如下。其底层指向一个array,并持有len和cap两个属性,分别表示切片的长度和容量。在对切片进行append操作时,如果切片的容量不足,就会重新分配底层的array以对切片进行扩容。扩容时会涉及到内存的重新分配以及数据的拷贝。
所以在使用slice时,可以通过预分配内存的方式来减少内存的分配以及数据的拷贝。下面通过benchmark来测试预分配内存产生的优化效果。可以看到通过slice预分配内存,内存分配次数从20降为1,分配的内存从386298B降为81920B,执行时间从56809ns降为12556ns,各方面都有数量级的优化。
在实际的业务场景中,很多时候很难预测需要用到的slice的容量,这时可以分配足够大(例如2倍)的容量。实际上,在benchmark的例子中,即使是将slice的容量设为20000,也比没有预分配的性能要好。
// 没有预分配内存
func SliceTest() []int {
sl := make([]int, 0)
for i := 0; i < 10000; i++ {
sl = append(sl, i)
}
return sl
}
// 预分配内存
func SliceTest() []int {
sl := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
sl = append(sl, i)
}
return sl
}
上面讲的这些,对go稍微熟悉一点的应该都知道。接下来讲点不一样的,特定情况下
slice预分配内存可以防止将内存分配到堆上。内存分配在栈上,当函数返回时随栈一起释放,可以减少GC的压力。
demo及benchmark的结果放在下面。可以看到当预分配内存时,内存分配为0。这部分的demo和上面demo的差别主要有两点:
这里主要涉及到逃逸分析,golang会判断变量是该分配到栈上还是堆上。上面两点分布对应逃逸分析的两条:1. 指针逃逸;2. 栈空间不足逃逸。指针逃逸的判断是比较固定的,栈空间不足逃逸可能在不同机器、不同go语言版本上会不太一样,在我本地测试出来的阈值时64KB。
// 切片长度8100时未发生逃逸
func SliceTest() {
_ = make([]int, 0, 8100)
}
// 切片长度8196时发生逃逸
func SliceTest() {
_ = make([]int, 0, 8196)
}
当将返回去掉时,slice就完全作为局部变量,golang判断可以将其分配到栈上(slice中含有指针,当作为返回值时,会触发逃逸分析的条件之一,被分配到堆上)。当分配的内存太大时,也会逃逸到堆上。
当没有预分配内存时,在append函数调用时会发生逃逸分析,将其分配到堆上。
// 没有预分配内存
func SliceTest() {
sl := make([]int, 0)
for i := 0; i < 1000; i++ {
sl = append(sl, i)
}
}
// 预分配内存
func SliceTest() {
sl := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
sl = append(sl, i)
}
}
所以在有限的场景下(1. slice作为局部变量,2. slice的可预见的容量不会很大),通过预分配内存可以使其分配到栈上。当然场景确实相对有限,可以作为一个有趣的知识点来了解。
其原理和slice预分配内存相似,通过预分配内存来防止内存的多次分配、数据的拷贝以及rehash。benchmark如下。
func MapTest() {
m := make(map[int]struct{})
for i := 0; i < 1000; i++ {
m[i] = struct{}{}
}
}
func MapTest() {
m := make(map[int]struct{},1000)
for i := 0; i < 1000; i++ {
m[i] = struct{}{}
}
}
不知道在看map预分配内存时有没有人有一个疑问。MapTest()中创建了一个容量1000的map局部变量,对其的负载也没有超过容量,按照这slice中的分析,这应该分配在栈上,但是测试结果确不是这样。
为什么会这样呢?我们用下面的两个函数来探究这之间到底有什么区别。
// 没有发生逃逸,分配在栈上
func SliceTest() {
_ = make([]int, 0, 1000)
}
// 分配在堆上
func MapTest() {
_ = make(map[int]struct{},1000)
}
使用go tool compile -S xxx.go
命令来查看其汇编代码,发现SliceTest没有调用runtime.makeslice,而MapTest调用了runtime.makemap。当把SliceTest中的容量调整为10000时(大于阈值),则会调用makeslice。因此这里我推断,slice的结构比较简单,所以编译器可以做一些优化在特定条件下(参考slice预分配内存中讲到的)不调用makeslice而分配slice,但是map结构相对复杂,所以必须调用makemap。
2021-12-17更新 之前的汇编命令为go tool compile -S xxx.go
,改为go tool compile -S -N -l xxx.go
后可以禁止编译器的优化,可以观察到切片是完全分配在栈上的。汇编部分如下。可以看到这站上分配了64000字节的空间,这个demo分配的为8000容量的切片。
0x0029 00041 (./memoryOptimization.go:3) SUBQ $64008, SP
0x0030 00048 (./memoryOptimization.go:3) MOVQ BP, 64000(SP)
0x0038 00056 (./memoryOptimization.go:3) LEAQ 64000(SP), BP
涉及到字符串拼接,其实有很多的方法。先说结论,推荐使用**strings.Builder{}或者strings.join()**来进行字符串的拼接。
刚提到字符串拼接有很多方法,最简单的方法其实就是直接使用“+”操作。但是string类型是不可变的类型,所以每次对字符串进行“+”操作都需要分配内存然后拷贝数据。
func StringTest() {
var s string
for i := 0; i < 1000; i++ {
s = s + "a"
}
}
相应的benchmark如下。可以看到操作1000次进行了99次内存分配。比较明显的一个疑问是为什么1000次操作只进行了99次分配,然后还有一个疑问是这里s明明是局部变量,为什么会有堆的内存分配呢。关于这点我推断(后面有时间看下代码的汇编来确认),因为string的底层结构是一个字节数组的指针和len属性组成的,所以在底层的字节数组扩容的时候会被分配到堆上。从这点上看,string看上去有点像一个特殊的slice。
第二种方法是使用bytes.Buffer{}。demo及benchmark如下。可以看到其性能明显要好于直接使用“+”操作。其底层的实现类似示例代码,使用[]byte来承载数据防止每次都要重新分配对象。当然在其基础上还是做了一些其他的优化,比较简单,有兴趣可以直接看源代码。
func StringTest() {
var b bytes.Buffer
for i := 0; i < 1000; i++ {
b.WriteString("a")
}
_ = b.String()
}
//原理类似,当然在其基础上做了一些优化
func StringTest() {
b := make([]byte, 0)
for i := 0; i < 1000; i++ {
b = append(b, "a"...)
}
_ = string(b)
}
第三种方法是使用strings.Builder{}。demo及benchmark如下。其内存分配比使用bytes.Buffer{}稍微少了一点。节约的内存在于其直接对字节数组进行类型的强制转换,而bytes.Buffer{}中使用string([]byte)时实际是先将字节数组拷贝,然后进行强制类型转换。所以节约的内存来自于少了一次字节数组的拷贝。
func StringTest() {
b := strings.Builder{}
for i := 0; i < 1000; i++ {
b.WriteString("a")
}
_ = b.String()
}
// 原理如下
func StringTest() {
b := make([]byte, 0)
for i := 0; i < 1000; i++ {
b = append(b, "a"...)
}
_ = *(*string)(unsafe.Pointer(&b))
}
除了上面说到的几点,还有一些常见的优化技巧比如,有时间再展开来详细说:
这个优化严格来说并不是我做的,而是发现并提出的。
先说下业务场景。出于业务的需求,需要接入安全审计的功能,在调用某些接口时上报一些数据。可以简单的理解为打日志,但时效性比打日志的要求要低很多,是完全异步的行为。大概看了要接入的SDK的实现。其采用了简单的生产者消费者模型,生产者将消息投递到channel里,消费者进行消费。当消费者攒够300条消息或者间隔10s就会批量发送消息。代码实现如下。
func loop() {
var batch []*event
for {
select {
case event := <- channel :
batch = append(batch, event)
if len(batch) >= 300 {
process(batch)
}
case <- time.After(time.Second*10) :
process(batch)
}
}
}
看time.After()的源码发现其会创建一个time对象,该对象要等到duration参赛的时间以后才能被回收。回到具体的场景中,也就是每次进入for循环都会创建一个timer对象,要等到10s以后才能被回收。在qps比较高的场景,会有大量的对象创建,同时也会不断的有对象需要回收。对GC的压力还是很大的。
建议的做法是改成下面这种写法。和对方的RD交流后他也认识到了相应的问题,做了更改。
func loop() {
var batch []*event
t := time.NewTicker(time.Second*10)
defer t.Stop()
for {
select {
case event := <- channel :
batch = append(batch, event)
if len(batch) >= 300 {
process(batch)
}
case <- t.C :
process(batch)
}
}
}
这个问题的起因是有次修复了一个excel相关的数据准确性bug,在测试环境进行验证的时候发现总是导出失败。查了一下发现是OOM了,测试环境的容器实例的配置是1C2GB。然后去看下了线上实例的配置,发现是2C12GB,这说明excel导出确实占用很大的内存。
先说下具体的业务场景,excel导出用的是"github.com/tealeg/xlsx"包,内容为作答数据,包括了员工的属性,题目的答案。数据量的话300w个单元格是比较正常的水平,峰值的话应该有500~600w个单元格。后面展示的话也都是以300w单元格的数据量做的。
首先在遇到OOM的时候肯定要去pprof一下的。在这个问题里也没什么好说的,就是构建excel占用了太多的内存。我用demo跑了些benchmark,发现300w个单元格占内存在3GB左右。
func XlsxExport() {
xlsxFile := xlsx.NewFile()
defer func() {
err := xlsxFile.Save("./test_export")
if err != nil {
fmt.Printf("save file failed, err = %s", err.Error())
}
}()
sheet, err := xlsxFile.AddSheet("test sheet")
if err != nil {
fmt.Printf("add sheet failed, err = %s", err.Error())
return
}
// 尝试写10w行,30列的数据,也就是300w个cell的数据,看内存分配的情况
for i := 0; i < 100000; i ++ {
row := sheet.AddRow()
for j := 0; j < 30; j ++ {
cell := row.AddCell()
cell.Value = fmt.Sprint(i*j)
}
}
}
然后测了下每个单元格需要占600个字节的内存。这个说明大数据量的excel导出基本盘是比较固定的。尝试过为sheet的row切片和每个row的cell切片提前分配容量,但是改善确实微乎其乎。
var globalRow *xlsx.Row
func init() {
file := xlsx.NewFile()
sheet, _ := file.AddSheet("test")
globalRow = sheet.AddRow()
}
func CellTest() {
c1 := globalRow.AddCell()
c1.Value = ""
_ = globalRow.AddCell()
_ = globalRow.AddCell()
}
然后使用了xlsx的流入写入功能,将数据流式写入文件中,benchmark后占有内存将为400M左右。
func StreamFile() {
path := "./streamFile.xlsx"
steamFileBuilder, _ := xlsx.NewStreamFileBuilderForPath(path)
header := make([]string, 30)
_ = steamFileBuilder.AddSheet("test", header, []*xlsx.CellType{})
streamFile, _ := steamFileBuilder.Build()
cells := make([]string, 30, 30)
for i := 0; i < 100000; i ++ {
for j := 0; j < 30; j ++ {
cells[j] = fmt.Sprint(i*j)
}
_ = streamFile.Write(cells)
}
_ = streamFile.Close()
}
然后在具体的业务代码中,其实是将整个sheet的数据构造好放在一个二维数组后再往excel里面写入的。改造时顺便改为每构造一行的数据就写入excel。
最近在一个数据量比较大的活动中,在导出时,内存占用很高,但是导出后内存没有明显的下降,一直维持在很高的水平。经过查找,发现这是go 1.12关于内存方面做的一个优化,默认使用MADV_FREE方式,程序内存不会立刻回收,即RSS值不会立刻下降,只有当OS内存紧缺时才会回收Go程序的内存返回给OS。
解决方案是增加环境变量GODEBUG=madvdontneed=1
或者升级为go 1.16。
我们线上的版本是go 1.13,通过增加环境变量的方法解决了问题。