关于后端:检测代码中泄漏的goroutine

42次阅读

共计 4971 个字符,预计需要花费 13 分钟才能阅读完成。

goroutine 作为 golang 并发实现的外围组成部分,非常容易上手应用,但却很难驾驭得好。咱们常常会遭逢各种模式的 goroutine 透露,这些透露的 goroutine 会始终存活直到过程终结。它们的占用的栈内存始终无奈开释、关联的堆内存也不能被 GC 清理,零碎的可用内存会随透露 goroutine 的增多越来越少,直至解体!

Uber 开源了 goleak 库能够帮忙咱们检测代码中可能存在的 goroutine 泄露问题;

源代码:

https://github.com/JasonkayZK…

应用 Uber 开源的 goleak 库进行 goroutine 泄露检测

什么是 Goroutine 泄露

goroutine leak 的意思是 go 协程透露,那么什么又是协程透露呢?

咱们晓得,每次应用 go 关键字开启一个 gorountine 工作,通过一段时间的运行,最终是会完结,从而进行系统资源的开释回收。而如果因为操作不当导致一些 goroutine 始终处于阻塞状态或者永远运行中,永远也不会完结,这就必定会始终占用系统资源;最坏的状况下是随着零碎运行,始终在创立此类 goroutine,那么最终后果就是程序解体或者零碎解体;这种状况咱们个别称为 goroutine leak;

例如上面的一段代码:

package main

import (  
    "fmt"  
    "math/rand"  
    "runtime"  
    "time"  
)

func query() int {n := rand.Intn(100)  
    time.Sleep(time.Duration(n) * time.Millisecond)  
    return n  
}

// 每次执行此函数,都会导致有两个 goroutine 处于阻塞状态
func queryAll() int {ch := make(chan int)  
    go func() { ch <- query() }()  
    go func() { ch <- query() }()  
    go func() { ch <- query() }()  
    // <-ch
    // <-ch
    return <-ch  
}

func main() {  
    // 每次循环都会透露两个 goroutine
    for i := 0; i < 4; i++ {queryAll()  
        // main()也是一个主 groutine
        fmt.Printf("#goroutines: %d\n", runtime.NumGoroutine())  
    }  
}

输入如下:

#goroutines: 3
#goroutines: 5
#goroutines: 7
#goroutines: 9

这里发现 goroutine 的数量始终在增涨,按理说这里的值应该始终是 1 才对(只有一个 Main 函数的主 goroutine),其实这里产生了 goroutine 透露的问题:

次要问题产生在 queryAll() 函数里,这个函数在 goroutine 里往 ch 里间断三次写入了值,因为这里是无缓冲的 ch,所以在写入值的时候,要有在 ch 有接收者时才能够写入胜利,也就是说在从接收者从 ch 中获取值之前, 后面三个 ch<-query() 始终处于阻塞的状态;

当执行到 queryAll()函数的 return 语句时,ch 接收者获取一个值 (意思是说三个 ch<-query() 中执行最快的那个 goroutine 写值到 ch 胜利了,还剩下两个执行慢的 ch<-query() 处于阻塞) 并返回给调用主函数时,仍有两个 ch 处于节约的状态;

在 Main 函数中对于 for 循环:

  • 第一次:goroutine 的总数量为 1 个主 goroutine + 2 个节约的 goroutine = 3;
  • 第二次:3 + 再个节约的 2 个 goroutine = 5;
  • 第三次:5 + 再个节约的 2 个 goroutine = 7;
  • 第三次:7 + 再个节约的 2 个 goroutine = 9;
    正好是程序的输入后果;

解决方案:

能够看到,次要是 ch 写入值次数与读取的值的次数不统一导致的有 ch 始终处于阻塞节约的状 > 态,咱们所以咱们只有保留写与读的次数齐全一样就能够了;

这里咱们把下面 queryAll() 函数代码正文掉的 <-ch 两行勾销掉,再执行就失常了,输入内容如下:

#goroutines: 1

#goroutines: 1

#goroutines: 1

#goroutines: 1

当然对于解决 goroutine 的办法不是仅仅这一种,也能够利用 context 来解决,参考:

https://www.cnblogs.com/chenq…

总结如下:

产生 goroutine leak 的起因

  • goroutine 因为 channel 的读 / 写端退出而始终阻塞,导致 goroutine 始终占用资源,而无奈退出,如只有写入,没有接管,反之一样;
  • goroutine 进入死循环中,导致资源始终无奈开释;
    揭示:垃圾收集器不会收集以下模式的 goroutines:

    go func() {// < 操作会在这里永恒阻塞 >}()
    // Do work

    这个 goroutine 将始终存在,直到整个程序退出;

是否属于 goroutine leak 还须要看如何应用了,如 https://www.jianshu.com/p/b52…。如果解决不好,如 for{} 基本就不可能完结,此时就属于透露;

所以咱们写程序时,至多要保障他们完结的条件,且肯定能够完结才算失常;

  1. goroutine 终止的场景
  2. goroutine 实现它的工作
  3. 因为产生了没有解决的谬误
  4. 收到完结信号,间接终止工作

能够看进去,goroutine 的透露通常随同着简单的协程间通信,而代码评审和惯例的单元测试通常更专一于业务逻辑正确,很难齐全笼罩 goroutine 透露的场景;

同时,pprof 等性能剖析工具更多是作用于监控报警 / 故障之后的复盘。咱们须要一款能在编译部署前辨认 goroutine 透露的工具,从更上游把控工程质量;

goleak 是 Uber 团队开源的一款 goroutine 透露检测工具,它能够十分轻量地集成到测试中,对于 goroutine 透露的防治和工程鲁棒性的晋升很有帮忙;

应用 uber-go/goleak 工具检测 goleak

下面咱们是手动通过获取 groutine 数量来判断是否存在透露的,上面咱们应用 uber-go/goleak 工具来检测是否存在透露问题;

上面的函数在每次调用时都会造成一个 goroutine 泄露:

func leak() {ch := make(chan struct{})
    go func() {ch <- struct{}{}}()}

通常咱们会为 leak 函数写相似上面的测试:

func TestLeak(t *testing.T) {leak()
}

用 go test 执行测试看看后果:

$ go test -v -run ^TestLeak$
=== RUN   TestLeak
--- PASS: TestLeak (0.00s)
PASS
ok      cool-go.gocn.vip/goleak 0.007s

测试不出意外地顺利通过了!

go 内置的测试显然无奈帮咱们辨认 leak 中的 goroutine 透露!

在应用 goleak 进行 goroutine 泄露测试时,通常咱们只需关注 VerifyNoneVerifyTestMain 两个办法,它们也对应了 goleak 的两种集成形式;

随用例集成

在现有测试的首行增加 defer goleak.VerifyNone(t),即可集成 goleak 透露检测:

func TestLeakWithGoleak(t *testing.T) {defer goleak.VerifyNone(t)
    leak()}

这次的 go test 失败了:

$ go test -v -run ^TestLeakWithGoleak$
=== RUN   TestLeakWithGoleak
    leaks.go:78: found unexpected goroutines:
        [Goroutine 19 in state chan send, with cool-go.gocn.vip/goleak.leak.func1 on top of the stack:
        goroutine 19 [chan send]:
        cool-go.gocn.vip/goleak.leak.func1(0xc00008c420)
                /Users/blanet/gocn/goleak/main.go:24 +0x35
        created by cool-go.gocn.vip/goleak.leak
                /Users/blanet/gocn/goleak/main.go:23 +0x4e
        ]
--- FAIL: TestLeakWithGoleak (0.45s)
FAIL
exit status 1
FAIL    cool-go.gocn.vip/goleak 0.459s

测试报告显示名为 leak.func1 的 goroutine 产生了透露(leak.func1 在这里指的是 leak 办法中的第一个匿名办法),并将测试后果置为失败;

咱们胜利通过 goleak 找到了 goroutine 透露;

通过 TestMain 集成

如果感觉逐用例集成 goleak 的形式太过繁琐或“入侵”性太强,能够试试齐全不扭转原有测试用例,通过在 TestMain 中增加 goleak.VerifyTestMain(m) 的形式集成 goleak:

func TestMain(m *testing.M) {goleak.VerifyTestMain(m)
}

这次的 go test 输入如下:

$ go test -v -run ^TestLeak$
=== RUN   TestLeak
--- PASS: TestLeak (0.00s)
PASS
goleak: Errors on successful test run: found unexpected goroutines:
[Goroutine 19 in state chan send, with cool-go.gocn.vip/goleak.leak.func1 on top of the stack:
goroutine 19 [chan send]:
cool-go.gocn.vip/goleak.leak.func1(0xc00008c2a0)
        /Users/blanet/gocn/goleak/main.go:24 +0x35
created by cool-go.gocn.vip/goleak.leak
        /Users/blanet/gocn/goleak/main.go:23 +0x4e
]
exit status 1
FAIL    cool-go.gocn.vip/goleak 0.455s

可见,goleak 再次胜利检测到了 goroutine 透露;

但与逐用例集成不同的是,goleak.VerifyTestMain 会先报告用例执行的后果,而后再进行透露剖析;

同时,如果单次测试执行了多个用例且最终产生透露,那么以 TestMain 形式集成的 goleak 并不能精准定位产生 goroutine 透露的用例,还需进一步剖析;

goleak 提供了如下脚本用于进一步推断具体产生 goroutine 透露的用例,其本质是逐个执行所有用例进行剖析:

# Create a test binary which will be used to run each test individually
$ go test -c -o tests

# Run each test individually, printing "." for successful tests, or the test name
# for failing tests.
$ for test in $(go test -list . | grep -E "^(Test|Example)"); do
    ./tests -test.run "^$test\$" &>/dev/null && echo -n "." || echo "\n$test failed"
done

总结

goleak 通过对运行时的栈剖析获取 goroutine 状态,并设计了十分简洁易用的接口与测试框架进行对接,是一款玲珑强悍的 goroutine 透露防治利器;

当然,齐备的测试用例反对是 goleak 发挥作用的根底,大家还是要老老实实写测试,稳稳当当搞生产!

附录
文章参考:

https://mp.weixin.qq.com/s/3i…
https://blog.haohtml.com/arch…
源代码:

https://github.com/JasonkayZK…

原文地址 > https://jasonkayzk.github.io/…

关注 golang 技术实验室

获取更多好文

本文由 mdnice 多平台公布

正文完
 0