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{} 基本就不可能完结,此时就属于透露;
所以咱们写程序时,至多要保障他们完结的条件,且肯定能够完结才算失常;
- goroutine 终止的场景
- goroutine 实现它的工作
- 因为产生了没有解决的谬误
- 收到完结信号,间接终止工作
能够看进去,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 泄露测试时,通常咱们只需关注 VerifyNone
和 VerifyTestMain
两个办法,它们也对应了 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 多平台公布