百度程序员开发避坑指南(Go语言篇)

原创
2022/04/13 17:22
阅读数 662

本期我们根据一线开发的同学在开发过程中遇到的实际问题,提炼出来五个关于Go语言的小技巧,供大家参考:Golang性能优化之Go Ballast、Golang性能分析之benchmark+pprof、Golang单测技巧之打桩、一次由锁引发的在线服务OOM、Go并发编程时的内存同步问题。希望能为你的技术提升助力~

01Golang性能优化之Go Ballast

关于Go GC 优化的手段比较常见的手段就是通过调整GC的步调,以调整GC的触发频率,主要通过设置GOGC、设置 debug.SetGCPercent()的方式实现。
这里简单说下设置 GOGC 的弊端:

1. GOGC设置比率不精确,很难精确的控制我们想要触发的垃圾回收阈值;

2. GOGC设置过小,频繁触发GC就会导致无效的CPU浪费;

3. 程序本身占用内存比较低时,每次GC之后本身占用内存也比较低,如果按照上次GC后的heap的一倍的GC步调来设置GOGC的话,这个阈值很容易就能够触发,于是就很容易出现程序因为GC的触发导致额外的消耗;

4. GOGC设置的过大,假设这些接口突然接受到一大波流量,由于长时间无法触发GC可能导致OOM;

由此,GOGC 对于某些场景并不是很友好,那有没有能够精确控制内存,让其在10G的倍数时准确控制GC呢?

这就需要 Go ballast 出场了。什么是 Go ballast,其实很简单就是初始化一个生命周期贯穿整个 Go 应用生命周期的超大 slice。

func main() {
ballast := make([]byte, 1010241024*1024) // 10G
// do something
runtime.KeepAlive(ballast)
}

上面的代码就初始化了一个ballast,runtime.KeepAlive可以保证ballast不会被 GC 给回收掉。

利用这个特性,就能保证GC在10G的一倍时才被触发,这样就能够比较精准控制GOGC的触发时机。

02Golang性能分析之benchmark+pprof

在编写Golang代码时,可能由于编码不当,或者引入了一些耗时操作没注意,使得编写出来的代码性能比较差。这个时候,就面临着性能优化问题,需要快速找出“性能消耗大户”,发现性能瓶颈,快速进行针对性的优化。

Golang是一门优秀的语言,在性能分析上,也为我们提供了很好的工具。

通过Golang的benchmark + pprof能帮我们快速对代码进行性能分析,对代码的CPU和内存消耗进行分析。通过对CPU消耗的分析,快速找出CPU耗时较长的函数,针对性进行优化,提高程序运行性能。通过对内存消耗的分析,可找出代码中内存消耗较大的对象,也能进行内存泄露的排查。

benchmark是go testing测试框架提供的基准测试功能,对需要分析的代码进行基准测试,同时产出cpu profile和mem profile数据到文件,对这两个profile文件进行分析,可进行性能问题定位。

pprof是Golang自带的cpu和内存分析器,包含在go tool工具中,可对benchmark中产出的cpu profile和mem profile文件进行分析,定位性能瓶颈。

pprof可以在命令行中以交互式的方式进行性能分析,同时也提供了可视化的图形展示,在浏览器中进行分析,在使用可视化分析之前需要先安装graphviz。

pprof可视化分析页面展示的数据比较直观,也很贴心,对于CPU消耗大和内存消耗高的函数,标记的颜色会比较深,对应的图形也比较大,能让你一眼就找到他们。

分析中用到的benchmark test命令示例:

go test -bench BenchmarkFuncA -run none -benchmem -cpuprofile cpuprofile.o -memprofile memprofile.o

分析中用到的pprof可视化查看命令示例:

go tool pprof -http=":8080" cpuprofile.o

执行命令后,浏览器会自动打开分析页面页面,或者手动打开:

http://localhost:8080/ui/。

03Golang单测技巧之打桩

3.1 简介

在编写单测过程中,有的时候需要让指定的函数或实例方法返回特定的值,那么这时就需要进行打桩。它在运行时通过汇编语言重写可执行文件,将目标函数或方法的实现跳转到桩实现,其原理类似于热补丁。这里简要介绍下在Go语言中使用monkey进行打桩。

3.2 使用

3.2.1 安装

go get bou.ke/monkey

3.2.2 函数打桩

对你需要进行打桩的函数使用monkey.Patch进行重写,以返回在单测中所需的条件依赖参数:

// func.go

func GetCurrentTimeNotice() string {
    hour := time.Now().Hour()
    if hour >= 5 && hour < 9 {
        return "一日之计在于晨,今天也要加油鸭!"
    } else if hour >= 9 && hour < 22 {
        return "好好搬砖..."
    } else {
        return "夜深了,早点休息"
    }
}

当我们需要控制time.Now()返回值时,可以按照如下方式进行打桩:

// func_test.go

func TestGetCurrentTimeNotice(t *testing.T) {
    monkey.Patch(time.Now, func() time.Time {
        t, _ := time.Parse("2006-01-02 15:04:05", "2022-03-10 08:00:05")
        return t
    })
    got := GetCurrentTimeNotice()
    if !strings.Contains(got, "一日之计在于晨") {
        t.Errorf("not expectd, got: %s", got)
    }
    t.Logf("got: %s", got)
}

3.2.3 实例方法打桩

业务代码实例如下:

// method.go

type User struct {
 Name string
 Birthday string
}

// GetAge 计算用户年龄
func (u *User) GetAge() int {
 t, err := time.Parse("2006-01-02", u.Birthday)
 if err != nil {
     return -1
 }
 return int(time.Now().Sub(t).Hours()/24.0)/365
}


// GetAgeNotice 获取用户年龄相关提示文案
func (u *User) GetAgeNotice() string {
    age := u.GetAge()
    if age <= 0 {
        return fmt.Sprintf("%s很神秘,我们还不了解ta。", u.Name)
    } 
    return fmt.Sprintf("%s今年%d岁了,ta是我们的朋友。", u.Name, age)
}

当我们需要控制GetAgeNotice方法中调用的GetAge的返回值时,可以按如下方式进行打桩:

// method_test.go

func TestUser_GetAgeNotice(t *testing.T) {
 var u = &User{
  Name:     "xx",
  Birthday: "1993-12-20",
 }

 // 为对象方法打桩
 monkey.PatchInstanceMethod(reflect.TypeOf(u), "GetAge", func(*User)int {
  return 18
 })

 ret := u.GetAgeNotice()  // 内部调用u.GetAge方法时会返回18
 if !strings.Contains(ret, "朋友"){
  t.Fatal()
 }
}

3.3 注意事项


使用monkey需要注意两点:

1. 它无法对已进行内联优化的函数进行打桩,因此在执行单测时,需要关闭Go语言的内联优化,执行方式如下:

go test -run=TestMyFunc -v -gcflags=-l

2. 它不是线程安全的,不可用到并发的单测中。

04一次由锁引发的在线服务OOM

4.1 首先看一下问题代码示例

func service(){
    var a int
    lock := sync.Mutex{}
    {
     ...//业务逻辑
    }
    lock.Lock()
    if(a > 5){
        return 
    }
    {
     ...//业务逻辑
    }
    lock.UnLock()
}

4.2 分析问题原因

RD同学在编写代码时,因为map是非线程安全的,所以引入了lock。但是当程序return的时候,未进行unlock,导致锁无法被释放,持续占用内存。在goroutine中,互斥锁被lock之后,没有进行unlock,会导致协程一直无法结束,直到请求超时,context cancel,因此以后在使用锁的时候,要多加小心,不在锁中进行io操作,且一定要保证对锁lock之后,有unlock操作。同时在上线时,多观察机器内存和cpu使用情况,在使用Go编写程序时关注goroutine的数量,避免过度创建导致内存泄露。

4.3 goroutine监控视角

图片

4.4 如何快速止损

首先联系OP对问题机房进行切流,然后马上回滚问题点所有上线单,先止损再解决问题。

4.5 可以改进的方式

程序设计阶段:大流量接口,程序设计不完善,考虑的case不够全面,未将机器性能考虑在内。

线下测试阶段:需要对大流量接口进行压测,大流量接口容易产生内存泄露导致的异常。

发布阶段:注意大流量接口上线时机器性能数据。

05Go并发编程时的内存同步问题

现代计算机对内存的写入操作会先缓存在处理器的本地缓存中,必要时才会刷回内存。

在这个前提下,当程序的运行环境中存在多个处理器,且多个 goroutine 分别跑在不同的处理器上时,就可能会出现因为处理器缓存没有及时刷新至内存,而导致其他 goroutine 读取到一个过期值。

如下面这个例子,虽然 goroutine A 和 goroutine B 对变量 X、Y 的访问并不涉及竞态的问题,但仍有可能出现意料之外的执行结果:

var x, y int
// A
go func() {
    x = 1
    fmt.Print("y:", y, " ")
}()

// B
go func() {
    y = 1                 
    fmt.Print("x:", x, " ")
}(

上述代码可能出现的执行结果为:

y:0 x:1
x:0 y:1
x:1 y:1
y:1 x:1
x:0 y:0
y:0 x:0

会出现最后两种情况原因是:goroutine 是串行一致的,但在不使用通道或者互斥量进行显式同步的情况下,多个 goroutine 看到的事件顺序并不一定是完全一致的。

即尽管 goroutine A 一定能够在读取 Y 之前感知到对 X 的写入,但他并不一定能够观测到其他 goroutine 对 Y 的写入,此时它就可能会输出一个 Y 的过期值。

故在上述使用场景时,为避免最后两种情况的出现,需要在读取变量前使用同步原语强制将处理器缓存中的数据刷回内存,保证任何 goroutine 都不会从处理器读到一个过期的缓存值:

var x, y int
var mu sync.RWMutex

go func() {
    mu.RLock() // 同步原语
    defer mu.RUnlock()
    x = 1
    fmt.Print("y:", y, " ")
}()

go func() {
    mu.RLock() // 同步原语
    defer mu.RUnlock()
    y = 1                 
    fmt.Print("x:", x, " ")
}()

常用的Go同步原语:

sync.Mutex
sync.RWMutex
sync.WaitGroup
sync.Once
sync.Cond

推荐阅读【技术加油站】系列:

百度程序员开发避坑指南(3)

百度程序员开发避坑指南(移动端篇)

百度程序员开发避坑指南(前端篇)

百度工程师教你快速提升研发效率小技巧

百度一线工程师浅谈日新月异的云原生

【技术加油站】揭秘百度智能测试规模化落地

【技术加油站】浅谈百度智能测试的三个阶段

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
0 评论
0 收藏
0
分享
返回顶部
顶部