【golang】性能优化

文章目录

  • GC
  • 优化技巧
      • slice预分配内存
      • map预分配内存
      • slice和map的区别
      • 字符串拼接
      • 其他
  • 内存优化的例子
      • 计时器优化
      • excel导出内存优化
      • golang 内存占用过高问题

这篇文章打算聊聊go的性能优化。性能优化其实是一个很大的话题,要是铺开来讲十篇文章也讲不完。所以我加了两个限制词:golang和内存,把主题从性能优化减小到语言层面的内存相关的性能优化。文章内容会先介绍golang的GC,同样按我以往文章的风格,GC这部分不会写得太详细,因为已经有太多优秀的文章了。然后会讲一些性能优化技巧。最后是我在最近做的和内存相关的两个优化,一个是定时器相关,一个excel导出的优化。

GC

golang的GC简单梳理

优化技巧

slice预分配内存

切片的结构如下。其底层指向一个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
}

【golang】性能优化_第1张图片

// 预分配内存
func SliceTest() []int {
   sl := make([]int, 0, 10000)
   for i := 0; i < 10000; i++ {
      sl = append(sl, i)
   }
   return sl
}

【golang】性能优化_第2张图片
上面讲的这些,对go稍微熟悉一点的应该都知道。接下来讲点不一样的,特定情况下slice预分配内存可以防止将内存分配到堆上。内存分配在栈上,当函数返回时随栈一起释放,可以减少GC的压力。

demo及benchmark的结果放在下面。可以看到当预分配内存时,内存分配为0。这部分的demo和上面demo的差别主要有两点:

  1. 没有将slice返回,slice完全作为局部变量使用;
  2. 将容量从10000改为了1000。

这里主要涉及到逃逸分析,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)
   }
}

【golang】性能优化_第3张图片

// 预分配内存
func SliceTest() {
   sl := make([]int, 0, 1000)
   for i := 0; i < 1000; i++ {
      sl = append(sl, i)
   }
}

【golang】性能优化_第4张图片
所以在有限的场景下(1. slice作为局部变量,2. slice的可预见的容量不会很大),通过预分配内存可以使其分配到栈上。当然场景确实相对有限,可以作为一个有趣的知识点来了解。

map预分配内存

其原理和slice预分配内存相似,通过预分配内存来防止内存的多次分配、数据的拷贝以及rehash。benchmark如下。

func MapTest() {
   m := make(map[int]struct{})
   for i := 0; i < 1000; i++ {
      m[i] = struct{}{}
   }
}

【golang】性能优化_第5张图片

func MapTest() {
   m := make(map[int]struct{},1000)
   for i := 0; i < 1000; i++ {
      m[i] = struct{}{}
   }
}

【golang】性能优化_第6张图片

slice和map的区别

不知道在看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"
   }
}

【golang】性能优化_第7张图片
相应的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)
}

【golang】性能优化_第8张图片
第三种方法是使用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))
}

【golang】性能优化_第9张图片

其他

除了上面说到的几点,还有一些常见的优化技巧比如,有时间再展开来详细说:

  1. 使用sync.Pool来复用对象;
  2. 使用struct{};
  3. 函数的返回值如果不是很大尽量使用值而不是指针;
  4. 计时器尽量避免使用time.After();
  5. map对象中尽量不要存指针对象;
  6. 对大数据量的slice切片时避免使用copy;

内存优化的例子

计时器优化

这个优化严格来说并不是我做的,而是发现并提出的。

先说下业务场景。出于业务的需求,需要接入安全审计的功能,在调用某些接口时上报一些数据。可以简单的理解为打日志,但时效性比打日志的要求要低很多,是完全异步的行为。大概看了要接入的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导出内存优化

这个问题的起因是有次修复了一个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()
}

【golang】性能优化_第10张图片
然后使用了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()
}

【golang】性能优化_第11张图片
然后在具体的业务代码中,其实是将整个sheet的数据构造好放在一个二维数组后再往excel里面写入的。改造时顺便改为每构造一行的数据就写入excel。

golang 内存占用过高问题

最近在一个数据量比较大的活动中,在导出时,内存占用很高,但是导出后内存没有明显的下降,一直维持在很高的水平。经过查找,发现这是go 1.12关于内存方面做的一个优化,默认使用MADV_FREE方式,程序内存不会立刻回收,即RSS值不会立刻下降,只有当OS内存紧缺时才会回收Go程序的内存返回给OS。

解决方案是增加环境变量GODEBUG=madvdontneed=1或者升级为go 1.16。

我们线上的版本是go 1.13,通过增加环境变量的方法解决了问题。

你可能感兴趣的:(web开发,golang,性能优化,性能优化,golang,开发语言,excel)