如何Go的更快

2021-07-27  本文已影响0人  lizyyy

先抛出几个问题:

  1. string、[]byte 各在什么场景用
  2. sync.pool 用在什么地方?
  3. map、slice 谁效率高?
  4. 反射的效率如何?
  5. 值传递还是指针传递好

一、定义"快"

很多人对操作系统中的“快”没什么概念, 那如何知道:

同机房内 RTT (Round Trip Time)大约是多少?
如果将一个应用内的函数的调用拆成两个应用 RPC 调用,将增加多少延迟?
打印日志有多快,打印日志的多少会增加多少延迟?

我们可以在这个网站看到延迟数随着硬件发展怎么变得更快。2005年之前,cpu和内存的发展很快, 之后是硬盘和网络。

如果放大这个时间,把L1缓存读取时间比如一次心跳时间:

* Minute:
L1 cache reference                  0.5 s         One heart beat (0.5 s)
Branch mispredict(分支预测错误)       5 s           Yawn
L2 cache reference                  7 s           Long yawn
Mutex lock/unlock                   25 s          Making a coffee
* Hour:
Main memory reference               100 s         Brushing your teeth
Compress 1K bytes with Zippy        50 min        One episode of a TV show (including ad breaks)
* Day:
Send 2K bytes over 1 Gbps network   5.5 hr        From lunch to end of work day
* Week
SSD random read                     1.7 days      A normal weekend
Read 1 MB sequentially from memory  2.9 days      A long weekend
同一个数据中心网络上跑一个来(RTT):
Round trip within same datacenter   5.8 days      A medium vacation
Read 1 MB sequentially from SSD    11.6 days      Waiting for almost 2 weeks for a delivery
* Year
Disk seek                           16.5 weeks    A semester in university
Read 1 MB sequentially from disk    7.8 months    Almost producing a new human being
The above 2 together                1 year
*Decade
Send packet CA->Netherlands->CA     4.8 years     Average time it takes to complete a bachelor's degree

你或许会像cpu那样感叹: “这个世界太慢了!”

二、如何“快”

USE(Utilization Saturation and Errors)是由 Brendan Gregg提出的 可以分析任何系统性能的方法论,它的思想就是根据一个 checklis快速找到系统的错误和资源(这里的资源主要包括但不限于:CPU,内存,网络,磁盘等)瓶颈。

  • Utilization 使用率:100%的使用率通常是系统性能瓶颈的标志,表示容量已经用尽或者全部时间都用于服务。
  • Saturation 饱和度:通常是一些排队队列的长度,如任务调度队列长度,网络发送队列长度。100%的使用率通常是系统性能瓶颈的标志。
  • Errors 错误:是否有错误产生,因为出现错误概率最大,其次是错误很容易被观察到。错误数越多,表明系统的问题越严重。

有了上面的方法, 我们可以重复以下的流程去找到系统的性能问题,从而达到更快:


USE

有趣的是,Brendan Gregg 在阿波罗飞船的系统设计中也看到了类似的方法论。

三、“慢”在哪

1、内存分配中的堆区和栈区

栈(Stack)是一种拥有特殊规则的线性表


栈(Stack)

栈分配是很廉价的,它只需要两个CPU指令:一个是分配入栈,另一个是栈内释放。也就是说在栈上分配内存,消耗的仅是将数据拷贝到内存的时间,而内存的 I/O 通常能够达到 30GB/s,因此在栈上分配内存效率是非常高的。
而堆分配代价是昂贵的,需要找一块足够的空间, 大多不是连续、不可预知大小的。为此付出的代价是分配速度较慢,而且会形成内存碎片。

我们现在现在说的并不是数据结构中的堆和栈,内存中的栈区处于相对较高的地址,以地址的增长方向为上的话,栈地址是向下增长的。栈中分配局部变量空间,堆区是向上增长的用于分配程序员申请的内存空间。

程序中的数据和变量都会被分配到程序所在的虚拟内存中,内存空间包含两个重要区域:栈区(Stack)和堆区(Heap)。函数调用的参数、返回值以及局部变量大都会被分配到栈上,这部分内存会由编译器进行管理;Go 以及 Java 等编程语言会由工程师和编译器共同管理,堆中的对象由内存分配器分配并由垃圾收集器回收。具体的分配原理和实现参考: 内存分配器

由于栈上的空间是自动分配自动回收的,所以栈上的数据的生存周期只是在函数的运行过程中,运行后就释放掉,不可以再访问。而堆上的数据只要程序员不释放空间,就一直可以访问到,不过缺点是一旦忘记释放会造成内存泄露。

堆:由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。也就是说堆会在申请后还要做一些后续的工作这就会引出申请效率的问题

2、逃逸分析

堆和栈各有优缺点,该怎么在编程中处理这个问题呢?C语言中,需要开发者自己学习如何进行内存分配,选用怎样的内存分配方式来适应不同的算法需求。比如,函数局部变量尽量使用栈,全局变量、结构体成员使用堆分配等。需要花费很长的时间在不同的项目中学习、记忆这些概念并加以实践和使用。庆幸的是Go语言将这个过程整合到了编译器中,命名为“变量逃逸分析”。通过编译器分析代码的特征和代码的生命周期,决定应该使用堆还是栈来进行内存分配。

编译器觉得变量应该分配在堆和栈上的原则是:
1.变量是否被取地址;
2.变量是否发生逃逸。
Go逃逸分析最基本的原则是:如果一个函数返回对一个变量的引用,那么它就会发生逃逸

来看看栈逃逸的几种情况:

在Go中通过逃逸分析日志来确定变量是否逃逸,开启逃逸分析日志:
go run -gcflags '-m -l' main.go
-m 会打印出逃逸分析的优化策略,实际上最多总共可以用 4 个 -m,但是信息量较大,一般用 1 个就可以了。
-l 会禁用函数内联,在这里禁用掉内联能更好的观察逃逸情况,减少干扰。

【举例】

a).指针逃逸
package main

type Student struct {
    Name string
    Age  int
}

func StudentRegister(name string, age int) *Student {
    s := new(Student) //逃逸

    s.Name = name
    s.Age = age

    return s
}

func main() {
    StudentRegister("Jim", 18)
}
b).interface{} 动态类型逃逸 (不确定长度大小)
type User struct {
    name interface{}
}

func main() {
    name := "WilburXu"
    MyPrintln(name)
}

func MyPrintln(one interface{}) (n int, err error) {
    var userInfo = new(User)
    userInfo.name = one // 泛型赋值 逃逸
    return
}

func main() { //动态分配不定空间
    // 在 interface 类型上调用方法
    a:=1
    fmt.Println(a)
    // 堆 动态分配不定空间 逃逸
    b := 20
    c := make([]int, 0, b)
}
c). 栈空间不足逃逸(空间开辟过大)
func Slice() {
    s := make([]int, 1, 8192) // 8k = 8192 字节

    for index, _ := range s {
        s[index] = index
    }
}

func main() { //栈空间不足逃逸(空间开辟过大)
    Slice()
}

总结:
1、指针逃逸 - 方法返回局部变量指针,就形成变量逃逸
2、栈空间不足逃逸 - 当切片长度扩大到10000时就会逃逸,实际上当栈空间不足以存放当前对象或无法判断当前切片长时会将对象分配到堆中
3、动态类型逃逸 - 编译期间很难确定其参数的具体类型,也能产生逃逸度
4、闭包引用对象逃逸 - 原本属于局部变量,由于闭包的引用,不得不放到堆上,以致产生逃逸
5、跨协程引用对象逃逸 - 原本属于A协程的变量,通过指针传递给B协程使用,产生逃逸

四、“更快” 见效

1、性能分析

回到提问,用引用还是值传递,我们使用性能分析工具看看以下这个例子:
【举例】
https://blog.crazytaxii.com/posts/golang_struct_pointer_vs_copy/ (偷懒借用下)

结论:
什么时候适合用指针

传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。 传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。

一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。

需要注意的:

2、GC

编程语言的内存管理系统除了负责堆内存的分配之外,它还需要负责回收不再使用的对象和内存空间。golang的垃圾回收采用的是 标记-清理(Mark-and-Sweep) 算法 。关于内存回收这部分可以参考: 7.2 垃圾收集器

GC过程不断演进为:(并发)标记、(并发)清理、STW三个阶段。

Go1.3-Go1.5 GC时间演进

STW(stop the world), GC的一些阶段需要停止所有的mutator(应用代码)以确定当前的引用关系. 这便是很多人对GC担心的来源, 这也是GC算法优化的重点. 对于大多数API/RPC服务, 10-20ms左右的STW完全接受的. Golang GC的STW时间从最初的秒级到百ms, 到1.15版本三色标记:10ms级别, 1.7的ms级别, 到现在的1ms以下, 已经达到了准实时的程度.

Go1.3-Go1.5 GC时间演进 Go1.4-Go1.8 STW时间 栈大小与STW时间关系

然而GC的标记过程仍然会占用比较大的系统开销(cpu时间)

cup消耗

由上可以看出, Golang GC Mark消耗的CPU时间与存活的对象数基本成正比(更准确的说是和需扫描的字节数, 每个对象第一个字节到对象的最后一个指针字段). 对于G级别以上的存活对象, 扫描一次需要花秒以上的CPU时间.

3、GC分析

拿问答服务举例:
GODEBUG=gctrace=1 go run ./vipask.go

gc 25 @0.101s 7%: 0.064+0.89+0.013 ms clock, 0.51+0.20/1.2/1.4+0.11 ms cpu, 4->4->1 MB, 5 MB goal, 8 P

解读:
第25次运行,进程已经启动0.101s 本次执行占用的进程cpu7% 本次gc耗时:清扫时间0.064ms,并发标记时间:0.89ms,STW时间0.013ms。 本次gc占用的cpu时间 ... 。堆的大小4mb,gc堆大小4 ,存活对大小1. 整体堆大小5mb, cpu核数8.

gc 26 @0.108s 6%: 0.031+0.65+0.016 ms clock, 0.24+0.046/0.77/1.2+0.13 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 27 @0.116s 6%: 0.030+0.61+0.038 ms clock, 0.24+0.085/0.93/1.5+0.31 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 28 @0.124s 6%: 0.035+0.58+0.019 ms clock, 0.28+0.17/0.81/1.7+0.15 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 29 @0.131s 5%: 0.025+0.60+0.011 ms clock, 0.20+0.077/0.87/1.7+0.092 ms cpu, 4->4->2 MB, 5 MB goal, 8 P
gc 30 @0.137s 5%: 0.024+0.53+0.010 ms clock, 0.19+0.087/0.82/1.5+0.085 ms cpu, 4->4->2 MB, 5 MB goal, 8 P
gc 31 @0.145s 5%: 0.050+1.1+0.002 ms clock, 0.40+0.11/1.7/2.7+0.023 ms cpu, 4->5->2 MB, 5 MB goal, 8 P
gc 32 @0.156s 5%: 0.026+0.80+0.001 ms clock, 0.21+0.13/1.2/2.2+0.013 ms cpu, 5->5->2 MB, 6 MB goal, 8 P
gc 33 @0.165s 5%: 0.043+0.81+0.008 ms clock, 0.35+0.16/1.2/3.0+0.068 ms cpu, 5->5->2 MB, 6 MB goal, 8 P
gc 34 @0.174s 5%: 0.021+1.0+0.017 ms clock, 0.17+0.11/1.5/3.1+0.13 ms cpu, 5->6->3 MB, 6 MB goal, 8 P
gc 35 @0.186s 4%: 0.051+1.0+0.002 ms clock, 0.41+0.11/1.7/3.8+0.020 ms cpu, 6->6->3 MB, 7 MB goal, 8 P
gc 36 @0.197s 4%: 0.033+0.83+0.014 ms clock, 0.26+0.70/1.4/3.3+0.11 ms cpu, 6->7->3 MB, 7 MB goal, 8 P
gc 37 @0.207s 4%: 0.035+1.2+0.003 ms clock, 0.28+0.45/2.0/3.9+0.031 ms cpu, 6->7->3 MB, 7 MB goal, 8 P
gc 38 @0.221s 4%: 0.031+0.96+0.002 ms clock, 0.24+0.10/1.6/3.7+0.016 ms cpu, 7->7->4 MB, 8 MB goal, 8 P
gc 39 @0.236s 4%: 0.027+1.7+0.002 ms clock, 0.22+0/1.6/2.9+0.017 ms cpu, 8->8->4 MB, 9 MB goal, 8 P
gc 40 @0.252s 4%: 0.033+1.7+0.002 ms clock, 0.27+0.054/3.1/4.2+0.022 ms cpu, 8->9->5 MB, 9 MB goal, 8 P
gc 41 @0.272s 4%: 0.048+1.6+0.001 ms clock, 0.38+0.085/2.9/5.6+0.015 ms cpu, 9->10->5 MB, 10 MB goal, 8 P
gc 42 @0.296s 4%: 0.20+2.3+0.020 ms clock, 1.6+2.4/4.0/1.7+0.16 ms cpu, 10->12->7 MB, 11 MB goal, 8 P
.
.
.
gc 52 @120.920s 0%: 0.41+3.2+0.002 ms clock, 3.3+0/6.1/5.1+0.022 ms cpu, 10->10->6 MB, 20 MB goal, 8 P
gc 53 @241.044s 0%: 0.11+4.5+0.004 ms clock, 0.92+0/8.8/7.5+0.035 ms cpu, 6->6->5 MB, 12 MB goal, 8 P
gc 54 @361.077s 0%: 0.096+4.4+0.004 ms clock, 0.77+0/8.4/8.0+0.036 ms cpu, 5->5->5 MB, 11 MB goal, 8 P

1.两分钟自动强制一次
2.小于4mb 不会触发
3.超过上一次剩余的一倍时触发

4、结合到实际项目中

a). 资源中台的优化方案
gc条件中,把最小内存4Mb调整到40Mb,减少GC次数

b). 资源中台中为什么没用使用并发请求
go-routine消耗cpu资源

五、 不疾而速

不要过度优化,
不要提前优化
不要为了优化牺牲代码可读性
(从go的发展中也可以看出, 不论是内存分配和是gc都是不断的演进和完善中)


结合分享再回来思考这些问题:

  1. string、[]byte 各在什么场景用

  2. sync.pool 用在什么地方?

  3. map、slice 谁效率高?

  4. 反射的效率如何?

  5. range怎样用最优

上一篇下一篇

猜你喜欢

热点阅读