写点什么

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

作者:百度Geek说
  • 2022 年 4 月 13 日
  • 本文字数:3889 字

    阅读完需:约 13 分钟

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

本期我们根据一线开发的同学在开发过程中遇到的实际问题,提炼出来五个关于 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 somethingruntime.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// Ago func() {    x = 1    fmt.Print("y:", y, " ")}()
// Bgo func() { y = 1 fmt.Print("x:", x, " ")}(
复制代码


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


y:0 x:1x:0 y:1x:1 y:1y:1 x:1x:0 y:0y:0 x:0
复制代码


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


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


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


var x, y intvar 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.Mutexsync.RWMutexsync.WaitGroupsync.Oncesync.Cond
复制代码


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


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


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


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


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


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


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


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

用户头像

百度Geek说

关注

百度官方技术账号 2021.01.22 加入

关注我们,带你了解更多百度技术干货。

评论

发布
暂无评论
百度程序员开发避坑指南(Go语言篇)_后端_百度Geek说_InfoQ写作平台