go性能优化

本文探讨了Go程序中常见的性能优化手段,如sync.Pool减少内存分配、string2bytes优化、协程池控制并发、反射优化、锁消耗减小及非标准技术如golink和simd的应用。强调了早期优化的危害,提倡在理解基础分析后再进行有针对性的优化。
摘要由CSDN通过智能技术生成

常规手段

1.sync.Pool

临时对象池应该是对可读性影响最小且优化效果显著的手段。最典型的就是fasthttp了,它几乎把所有的对象都用sync.Pool维护。
但这样的复用不一定全是合理的。比如在fasthttp中,传递上下文相关信息的RequestCtx就是用sync.Pool维护的,这就导致了你不能把它传递给其他的goroutine
如果要在fasthttp中实现类似接受请求->异步处理的逻辑,必须得拷贝一份RequestCtx再传递。这对不熟悉fasthttp原理的使用者来讲,很容易就踩坑了。

另外,在优化前要善用go逃逸检查分析对象是否逃逸到堆上,防止负优化。

2.string2bytes & bytes2string

这也是两个比较常规的优化手段,核心还是复用对象,减少内存分配。

在go标准库中也有类似的用法gostringnocopy

要注意string2bytes后,不能对其修改。

unsafe.Pointer经常出现在各种优化方案中,使用时要非常小心。这类操作引发的异常,通常是不能recover的。

3.协程池

绝大部分应用场景,go是不需要协程池的。当然,协程池还是有一些自己的优势:

  1. 可以限制goroutine数量,避免无限制的增长。
  2. 减少栈扩容的次数。
  3. 频繁创建goroutine的场景下,资源复用,节省内存。(需要一定规模。一般场景下,效果不太明显)

go对goroutine有一定的复用能力。所以要根据场景选择是否使用连接池,不恰当的场景不仅得不到收益,反而增加系统复杂性

4.反射

go里面的反射代码可读性本来就差,常见的优化手段进一步牺牲可读性。
而且后续马上就有范型的支持,所以若非必要,建议不要优化反射部分的代码

比较常见的优化手段有:

  1. 缓存反射结果,减少不必要的反射次数。例如json-iterator
  2. 直接使用unsafe.Pointer根据各个字段偏移赋值
  3. 消除一般的struct反射内存消耗go-reflect
  4. 避免一些类型转换,如interface->[]byte。可以参考zerolog

5.减小锁消耗

并发场景下,对临界区加锁比较常见。带来的性能隐患也必须重视。常见的优化手段有:

  1. 减小锁力度:
    go标准库当中,math.rand就有这么一处隐患。当我们直接使用rand库生成随机数时,实际上由全局的globalRand对象负责生成。globalRand加锁后生成随机数,会导致我们在高频使用随机数的场景下效率低下。
  2. atomic:
    适当场景下,用原子操作代替互斥锁也是一种经典的lock-free技巧。

    标准库中sync.map针对读操作的优化消除了rwlock,是一个标准的案例。对它的介绍文章也比较多,不在赘述。

    prometheus里的组件histograms直方图也是一个非常巧妙的设计。

    一般的开源库,比如go-metrics都是直接在这里使用了互斥锁。指标上报作为一个高频操作,在这里加锁,对系统性能影响可想而知。

    参考sync.map里冗余map的做法,prometheus把原来histograms的计数器也分为两个:coldhot,还有一个hotIdx用来表示哪个计数器是hot
    业务代码上报指标时,用atomic原子操作对hot计数器累加
    prometheus服务上报数据时,更改hotIdx,把原来的热数据变为冷数据,作为上报的数据。然后把现在冷数据里的值,累加到热数据里,完成一次冷热数据的更新替换。

    还有一些状态等待,结构体内存布局的介绍,不再赘述。具体可以参考Lock-free Observations for Prometheus Histograms

另类手段

golink在官方的文档里有介绍,使用格式:

//go:linkname FastRand runtime.fastrand
func FastRand() uint32

主要功能就是让编译器编译的时候,把当前符号指向到目标符号。上面的函数FastRand被指向到runtime.fastrand

runtime包生成的也是伪随机数,和math包不同的是,它的随机数生成使用的上下文是来自当前goroutine的,所以它不用加锁。正因如此,一些开源库选择直接使用runtime的随机数生成函数。性能对比如下:

Benchmark_MathRand-12       84419976            13.98 ns/op
Benchmark_Runtime-12        505765551           2.158 ns/op

还有很多这样的例子,比如我们要拿时间戳的话,可以标准库中的time.Now(),这个库在会有两次系统调用runtime.walltime1runtime.nanotime,分别获取时间戳和程序运行时间。大部分场景下,我们只需要时间戳,这时候就可以直接使用runtime.walltime1。性能对比如下:

Benchmark_Time-12       16323418            73.30 ns/op
Benchmark_Runtime-12    29912856            38.10 ns/op

同理,如果我们需要统计某个函数的耗时,也可以直接调用两次runtime.nanotime然后相减,不用再调用两次time.Now

//go:linkname nanotime1 runtime.nanotime1
func nanotime1() int64
func main() {
    defer func( begin int64) {
        cost := (nanotime1() - begin)/1000/1000
        fmt.Printf("cost = %dms \n" ,cost)
    }(nanotime1())
    
    time.Sleep(time.Second)
}

运行结果:cost = 1000ms 

2. log-函数名称行号的获取

虽然很多高性能的日志库,默认都不开启记录行号。但实际业务场景中,我们还是觉得能打印最好。

runtime中,函数行号和函数名称的获取分为两步:

  1. runtime回溯goroutine栈,获取上层调用方函数的的程序计数器(pc)。
  2. 根据pc,找到对应的funcInfo,然后返回行号名称

经过pprof分析。第二步性能占比最大,约60%。针对第一步,我们经过多次尝试,并没有找到有效的办法。但是第二步很明显,我们不需要每次都调用runtime函数去查找pc和函数信息的,我们可以把第一次的结果缓存起来,后面直接使用。这样。第二步约60%的消耗就可以去掉。

var(
    m sync.Map
)
func Caller(skip int)(pc uintptr, file string, line int, ok bool){
    rpc := [1]uintptr{}
    n := runtime.Callers(skip+1, rpc[:])
    if n < 1 {
        return
    }
    var (
        frame  runtime.Frame
        )
    pc  = rpc[0]
    if item,ok:=m.Load(pc);ok{
        frame = item.(runtime.Frame)
    }else{
        tmprpc := []uintptr{
            pc,
        }
        frame, _ = runtime.CallersFrames(tmprpc).Next()
        m.Store(pc,frame)
    }
    return frame.PC,frame.File,frame.Line,frame.PC!=0

6.simd

首先,go链接器支持simd指令,但go编译器不支持simd指令的生成。
所以在go中使用simd一般来说有三种方式:

  1. 手写汇编
  2. llvm
  3. cgo(如果用cgo的方式来调用,会受限于cgo的性能,达不到加速的目的)

目前比较流行的做法是llvm

  1. c来写simd相关的函数,然后用llvm编译成c汇编
  2. 用工具把c汇编转换成go的汇编格式,保存为.s文件
  3. 在go中调用.s里的方法,最后用go编译器编译

以下开源库用到了simd,可以参考:

  1. simdjson-go
  2. sonic
  3. sha256-simd

合理的使用simd可以充分发挥cpu特性,但是存在以下弊端:

  1. 难以维护,要么需要懂汇编的大神,要么需要引入第三方语言
  2. 跨平台支持不够,需要对不同平台汇编指令做适配
  3. 汇编代码很难调试,作为使用方来讲,完全黑盒

7.jit

go中使用jit的方式可以参考Writing a JIT compiler in Golang

目前只有在字节跳动刚开源的json解析库中发现了使用场景sonic

这种使用方式个人感觉在go中意义不大,仅供参考

总结

过早的优化是万恶之源,千万不要为了优化而优化

  1. pprof分析,竞态分析,逃逸分析,这些基础的手段是必须要学会的
  2. 常规的优化技巧是比较实用的,他们往往能解决大部分的性能问题并且足够安全。
  3. 在一些着重性能的基础库中,使用一些非常规的优化手段也是可以的,但必须要权衡利弊,不要过早放弃可读性,兼容性和稳定性。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

anssummer

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值