goroutine泄露的危害
Go内存泄露,相当多数都是goroutine泄露导致的。 虽然每个goroutine仅占用少量(栈)内存,但当大量goroutine被创建却不会释放时(即发生了goroutine泄露),也会消耗大量内存,造成内存泄露。
另外,如果goroutine里还有在堆上申请空间的操作,则这部分堆内存也不能被垃圾回收器回收
坊间有说法,Go 10次内存泄漏,8次goroutine泄漏,1次是真正内存泄漏,还有1次是cgo导致的内存泄漏 才高八斗的既视感..
关于单个Goroutine占用内存 ,可参考Golang计算单个Goroutine占用内存 , 在不发生栈扩张情况下, 新版本Go大概单个goroutine 占用2.6k左右的内存
massiveGoroutine.go:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 package mainimport ( "net/http" "runtime/pprof" ) var quit chan struct {} = make (chan struct {})func f () { <-quit } func getGoroutineNum (w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type" , "text/plain" ) p := pprof.Lookup("goroutine" ) p.WriteTo(w, 1 ) } func deal0 () { for i := 0 ; i < 100_0000 ; i++ { go f() } http.HandleFunc("/" , getGoroutineNum) http.ListenAndServe(":11181" , nil ) } func main () { deal0() }
参考 golang使用pprof检查goroutine泄露
造成goroutine泄露的原因 && 检测goroutine泄露的工具
原因:
goroutine泄露:原理、场景、检测和防范 比较全面总结了造成goroutine泄露的几个原因:
从 channel 里读,但是同时没有写入操作
向 无缓冲 channel 里写,但是同时没有读操作
向已满的 有缓冲 channel 里写,但是同时没有读操作
select操作在所有case上都阻塞()
goroutine进入死循环,一直结束不了
可见,很多都是因为channel使用不当造成阻塞,从而导致goroutine也一直阻塞无法退出导致的。
检测:
可以使用pprof做分析,但大多数情况都是发生在事后,无法在开发阶段就把问题提早暴露(即“测试左移”)
而uber出品的goleak 可以 集成到单元测试中,能快速检测 goroutine 泄露,达到避免和排查的目的
channel使用不当造成的泄露:
例如以下代码 (2.向 无缓冲 channel 里写,但是同时没有读操作 )
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 package mainimport ( "fmt" "log" "net/http" "runtime" "strconv" "time" ) func sumInt (s []int , c chan int ) { sum := 0 for _, v := range s { sum += v } c <- sum } func sumConcurrent (w http.ResponseWriter, r *http.Request) { x := deal() fmt.Fprintf(w, strconv.Itoa(x)) } func deal () int { s := []int {7 , 2 , 8 , -9 , 4 , 0 } c1 := make (chan int ) c2 := make (chan int ) go sumInt(s[:len (s)/2 ], c1) go sumInt(s[len (s)/2 :], c2) x := <-c1 fmt.Println("x is:" , x) return x } func main () { StasticGroutine := func () { for { time.Sleep(1e9 ) total := runtime.NumGoroutine() fmt.Println("当前协程数:" , total) } } go StasticGroutine() http.HandleFunc("/sum" , sumConcurrent) err := http.ListenAndServe(":8001" , nil ) if err != nil { log.Fatal("ListenAndServe: " , err) } }
使用goleak
leak_test.go:
1 2 3 4 5 6 7 8 9 10 11 package mainimport ( "go.uber.org/goleak" "testing" ) func TestLeak (t *testing.T) { defer goleak.VerifyNone(t) deal() }
每次都会新建两个协程去处理 但对其中一个无缓冲的channel c2只写不读,在这里发生了阻塞,如报错提示:
Goroutine 21 in state chan send,这个协程一直在通道发送状态(因为没有读取,所以一直阻塞着)
另外几种(1. 从 channel 里读,但是同时没有写入操作; 3. 向已满的 有缓冲 channel 里写,但是同时没有读操作 )使用channel不当造成阻塞的情况与之类似
select操作在所有case上都阻塞造成的泄露
其实本质上还是channel问题, 因为 select..case只能处理 channel类型, 即每个 case 必须是一个通信操作, 要么是发送要么是接收
select 将随机执行一个可运行的 case。如果没有 case 可运行,它将阻塞,直到有 case 可运行。
Golang中select的四大用法
4. select操作在所有case上都阻塞 的情况:
select+channel阻塞,导致协程不能退出
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 package mainimport ( "fmt" "runtime" "time" ) func fibonacci (c chan int ) { fmt.Println("进入协程,开始计算" ) x, y := 0 , 1 for { select { case c <- x: x, y = y, x+y } } } func deal4 () { c := make (chan int ) go fibonacci(c) for i := 0 ; i < 10 ; i++ { fmt.Println(<-c) } } func main () { fmt.Println("开始时goroutine的数量:" , runtime.NumGoroutine()) deal4() time.Sleep(3e9 ) fmt.Println("结束时goroutine的数量:" , runtime.NumGoroutine()) }
解决方案:
有个独立 goroutine去做某些操作的场景下,为了能在外部结束它,通常有两种方法:
a. 同时传入一个用于控制goroutine退出的 quit channel,配合 select,当需要退出时close 这个 quit channel,该 goroutine 就可以退出
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 package mainimport ( "fmt" "runtime" "time" ) func fibonacci (c, quit chan int ) { fmt.Println("进入协程,开始计算" ) x, y := 0 , 1 for { select { case c <- x: x, y = y, x+y case <-quit: fmt.Printf("收到退出的信号,信号值为(%d)\n" , <-quit) return } } } func deal4 () { c := make (chan int ) quit := make (chan int ) go fibonacci(c, quit) for i := 0 ; i < 10 ; i++ { fmt.Println(<-c) } fmt.Println("未close时goroutine的数量:" , runtime.NumGoroutine()) close (quit) time.Sleep(1e9 ) fmt.Println("close后goroutine的数量:" , runtime.NumGoroutine()) } func main () { fmt.Println("开始时goroutine的数量:" , runtime.NumGoroutine()) deal4() time.Sleep(3e9 ) fmt.Println("结束时goroutine的数量:" , runtime.NumGoroutine()) }
b. 使用 context 包的WithCancel,可参考context.WithCancel()的使用
time.After和select搭配使用时存在的坑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 package mainimport ( "context" "fmt" "runtime" "time" ) func fibonacci (c chan int , ctx context.Context) { fmt.Println("进入协程,开始计算" ) x, y := 0 , 1 for { select { case c <- x: x, y = y, x+y case <-ctx.Done(): fmt.Printf("收到取消的信号,cancel!,信号值为(%#v)\n" , <-ctx.Done()) return } } } func deal4 () { ctx, cancel := context.WithCancel(context.Background()) defer cancel() c := make (chan int ) go fibonacci(c, ctx) for i := 0 ; i < 10 ; i++ { fmt.Println(<-c) } fmt.Println("未close时goroutine的数量:" , runtime.NumGoroutine()) cancel() time.Sleep(1e9 ) fmt.Println("close后goroutine的数量:" , runtime.NumGoroutine()) } func main () { fmt.Println("开始时goroutine的数量:" , runtime.NumGoroutine()) deal4() time.Sleep(3e9 ) fmt.Println("结束时goroutine的数量:" , runtime.NumGoroutine()) }
再来一个例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 package mainimport ( "fmt" "math/rand" "os" "runtime" "time" ) func main () { deal2() } func deal2 () { fmt.Fprintf(os.Stderr, "最初的协程数%d\n" , runtime.NumGoroutine()) newRandStream := func () <-chan int { randStream := make (chan int ) go func () { defer fmt.Println("newRandStream closure exited." ) defer close (randStream) for { randStream <- rand.Int() } }() return randStream } randStream := newRandStream() fmt.Println("3 random ints:" ) for i := 1 ; i <= 3 ; i++ { fmt.Printf("%d: %d\n" , i, <-randStream) } fmt.Fprintf(os.Stderr, "当前协程数%d\n" , runtime.NumGoroutine()) time.Sleep(10e9 ) fmt.Fprintf(os.Stderr, "10s后的协程数%d\n" , runtime.NumGoroutine()) }
执行:
1 2 3 4 5 6 7 最初的协程数1 3 random ints:1 : 5577006791947779410 2 : 8674665223082153551 3 : 6129484611666145821 当前协程数2 10 s后的协程数2
leak_test.go:
1 2 3 4 func TestLeak2 (t *testing.T) { defer goleak.VerifyNone(t) deal2() }
解决方案:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 package mainimport ( "fmt" "math/rand" "os" "runtime" "time" ) func main () { fmt.Fprintf(os.Stderr, "最初的协程数%d\n" , runtime.NumGoroutine()) newRandStream := func (done <-chan interface {}) <-chan int { randStream := make (chan int ) go func () { defer fmt.Println("newRandStream closure exited." ) defer close (randStream) for { select { case randStream <- rand.Int(): case <-done: return } } }() return randStream } done := make (chan interface {}) randStream := newRandStream(done) fmt.Println("3 random ints:" ) for i := 1 ; i <= 3 ; i++ { fmt.Printf("%d: %d\n" , i, <-randStream) } fmt.Fprintf(os.Stderr, "当前协程数%d\n" , runtime.NumGoroutine()) close (done) time.Sleep(1 * time.Second) fmt.Fprintf(os.Stderr, "最后的协程数%d\n" , runtime.NumGoroutine()) }
输出:
1 2 3 4 5 6 7 8 最初的协程数1 3 random ints:1 : 5577006791947779410 2 : 8674665223082153551 3 : 6129484611666145821 当前协程数2 newRandStream closure exited. 最后的协程数1
详细代码及解决方案,参考 Go并发编程–goroutine leak的产生和解决之道
goroutine leak 往往是由于协程在channel上发生阻塞,或协程进入死循环,在使用channel和goroutine时要注意:
创建goroutine时就要想好该goroutine该如何结束
使用channel时,要考虑到channel阻塞时协程可能的行为
要注意平时一些常见的goroutine leak的场景,包括:master-worker模式,producer-consumer模式等等。
关于goleak的更具体使用及简单源码分析,可参考 远离P0线上事故,一个可以事前检测 Go 泄漏的工具
知名开源项目中的goroutine泄露
在项目中引入了ants,用goleak检测发现有内存泄露
issues/212
2022年3月8号的这次提交修复的
在2022年5月7号release了v2.5.0版本,修复了一下(但其实依然会被goleak检测到存在goroutine泄露)
但我们是在2022年4月份用的,当时是v2.4.8版本
至于出现问题的原因,可以参考Go项目的执行顺序
即在main.go执行前,会先依次执行用到的其他包的const,var,init()…
推荐阅读:
关于内存泄露: 鸟窝-Hi, 使用多年的go pprof检查内存泄漏的方法居然是错的?!
鸟窝-Go内存泄漏?不是那么简单!
文中罗列了Go内存泄露的一些情况:
获取长字符串中的一段导致长字符串未释放
同样,获取长slice中的一段导致长slice未释放
在长slice新建slice导致泄漏
goroutine泄漏
time.Ticker未关闭导致泄漏
Finalizer导致泄漏
Deferring Function Call导致泄漏
记一次Golang内存分析——基于go pprof
记一次 pprof 分析解决 golang 项目内存泄漏的过程
golang 内存分析/动态追踪
具体到 goroutine泄漏
go使用context包避免goroutine泄露问题
快速定位 Goroutine 泄露
使用Uber开源的goleak库进行goroutine泄露检测
Goroutine 泄漏防治神器 goleak
原文链接: https://dashen.tech/2022/04/27/goroutine泄露/
版权声明: 转载请注明出处.