高性能和Go语言的优化|青训营笔记

128 阅读6分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第4篇笔记」

为什么需要性能优化? 当然是甲方的要求,但要求的是什么呢?

提升用户体验,降低成本,提高效率

如何进行性能优化?

提升软件系统的处理能力,降低不必要的消耗,充分发挥计算机的算力,可以从哪些层面进行优化? IMG_93966F8C3562-1.jpeg 主要是从两个方面进行优化:业务层和语言运行层。

  • 业务层主要从代码的逻辑来进行优化,通过一下分析工具,来锁定最大瓶颈处也可能是代码质量问题处,因此可以获得较大的提升

  • 语言运行层则是解决更基础的问题,例如编译时的优化,其作用显然不局限于某个具体的业务,而是整个使用该编译器的公司等。

无论怎么优化,其基础均需要在实际数据下的优化,其次解决问题一般先去解决最大瓶颈,最后优化不能改变代码的正确性(即不能将1 + 1 = 2 优化成 1 + 1 = 3 即使运算效率快100倍)

一、背景

1.自动内存管理

go语言由于是一个后生代语言,因此吸收了其他语言的优势之处,初次接触这个语言,最让我意外的还是他既有c/c++中的指针,又有java的自动回收机制。这对于无论是c++ / java 转行来说都是一个易于上手的友好语法。 这里就不得不提提go的内存管理机制。

go中也像java一样有着主流的三种垃圾回收机制(也许有着借鉴前者的关系)

  • 复制对象GC :见名知义,将回收区域通过可达性标记找到所有的存活的对象,将其复制到另外的空间(java中同样也是将回收区存活的对象复制到一块叫做Surivor的区域存放存活的对象)

  • 标记清理GC:标记存活的对象,统一回收不存活的对象

  • 标记压缩GC:通过标记存活的对象,在回收时将其复制放在内存的前方,并将边界高数GC,让GC回收边界后的部分

那么我在学习时可能有以下疑问,究竟三者哪一个好呢?具体通过什么实现的呢? 没有最好的,只有更好的,对于三者回收机制,不同的时机有更适合的一种方法,下面讲解一种分代收集理论

分代GC【提到回收的内存区域,又要提到golang中的两个区域(java老版本也存在这两个区域)】:

1.新年代(Young Generation):这里通常是刚创建的对象,在长期积累的经验中,发现新年代往往不容易存活,大部分需要被回收。因此对于这块区域我们使用复制对象GC效率更高

2.老年代(Old Generation):长驻于内存的对象(经历过多次GC,一次GC算做一次年龄),由于长驻内存,因此几乎不会发生GC(趋势于一直活着),反复复制开销较大,采用mark- sweep collection(标记清理)

既然各有各的适用条件,那么如何评价GC算法呢?

  • 安全性(Safety):不能回收存活的对象(避免出错)基本要求
  • 吞吐率(Throughput):1GC时间程序执行总时间 1 - \cfrac {GC时间}{程序执行总时间} 花在GC上的时间
  • 暂停时间(Pause time):stop the world(STW) 业务是否感知
  • 内存开销(Space overhead):GC原数据开销 对于这些指标的评判决定了一个GC算法在一定环境下的好坏

二、优化

1.自动内存管理机制和Go内存管理机制

Go内存分配——分块

当对象在heap上分配内存时:

IMG_75BAADCDE932-1.jpeg

  • 提前将内存分块
    • 调用系统调用mmap()向OS申请一大块内存,例如4MB
    • 先将内存划分成大块,例如8KB,称作mspan
    • 在将大块继续划分成特定大小的小块,用于对象分配
    • noscan mspan:分配不包含指针的对象 —— GC不需要扫描
    • scan mspan:分配 包含指针的对象 —— GC扫描
  • 对象分配:根据对象的大小,选择最合适的块返回

Go内存分配——缓存

  • TCMalloc: thread caching

  • 每个p包含一个mcache 用于快速分配,用于为绑定于p上的g分配对象

  • mcache 管理一组 mspan

  • 当mcache 中的mspan 分配完毕,向mcentral申请带有未分配块的 mspan

  • 当mspan 中没有分配的对象,mspan 会被缓存在mcentral 中,而不是立刻释放井归还给 image.png

    结合上述机制我们如何优化呢?:Balanced GC

    • 每个g都绑定一大块内存(1KB),称作goroutine allocation buffer(GAB)
    • GAB用于noscan类型的小对象分配(< 128 B)
    • 适用三个指针维护GAB:base ,end,top(类似C语言读文件中的bengin end current三个指针)
    • Bump pointer(指针碰撞)风格对象分配

image.png 那如何判断g上的内存是否分配满呢? 如果top加当前所需的大小比整块内存小则可以分配

if top + size <= end {
    addr := top
    top += size 
    return addr
}

2.编译器优化

要知道如何编译器如何优化我们需要了解编译器的结构

  • 重要的系统软件
    • 识别符合语法和非法的程序
    • 生成正确且高效的代码
  • 分析部分(前段 front end)
    • 词法分析,生成词素(lexeme)
    • 语法分析,生成语法书
    • 予以分析,收集类型信息,进行语义检查
    • 中间代码生成,生成 intermediate representation(IR)
  • 综合部分(后端 bakc end)
    • 代码优化,机器无关优化,生成优化后的IR
    • 代码生成,生成目标代码

image.png

静态分析(不运行代码,推导程序的行为,分析程序的性质

    int a = 30
    int b = 9 - (a / 5)
    int c
    c = b * 4
    
    if c > 10 {
        c = c -10
    }
    return c * (60 / a)

image.png

通过分析控制流(Control flow:程序执行的流程)和数据流(Data flow:数据在控制流上的传递),我们可以知道更多关于程序的性质

为什么做编辑器优化?

  • 用户无感知,冲编译即可获得性能收益
  • 通用性优化

编译优化方向:Tradeoff(用编译时间换取更高效的机器码|适合面向后端长期执行的任务)

  • 函数内联:将被调用函数的函数体的副本替换到调用位置上,同时重写代码以反映参数的绑定
    • 优点:
      • 消除函数调用开销(由于需要压栈,需要传递参数,保存寄存器等)
      • 将过程间分析转换成过程内分析,帮组其他优化。例如逃逸分析
    • 缺点:
      • 函数体变大,instuction cache(icache)不友好
      • 编译生成的Go镜像变大 但是内联在大多数情况下是正向优化
  • 逃逸分析:分析代码中指针的动态作用域:指针在何处可以被访问
    • 大致思路
      • 从对象分配处出发,沿着控制流,观察对象的数据流
      • 若发现指针p在当前作用域 s,则已经逃逸出s:
        1. 作为参数传递给其他函数
        2. 传递给全局变量
        3. 传递给其他的goroutine
        4. 传递给已逃逸的指针指向对象
    • 优化:未逃逸的对象可以在栈上分配
      • 对象在栈上分配和回收很块:移动sp
      • 减少heap的分配,降低GC负担
  • 默认栈大小调整
  • 边界检查消除
  • 循坏展开
  • ...