Go語言中內(nèi)存泄漏的常見案例與解決方法
Go雖然是自動GC類型的語言,但在編碼過程中如果不注意,很容易造成內(nèi)存泄漏的問題。比較常見的是發(fā)生在 slice、time.Ticker、goroutine 等的使用過程中,這里結(jié)合我們?nèi)粘V薪?jīng)常遇到的,以及網(wǎng)上搜集到一些Case進(jìn)行系統(tǒng)性的總結(jié)一下,希望對你的日常工作有所幫助。
slice 類型引起內(nèi)存泄漏
傳入的參數(shù)被切片返回,導(dǎo)致局部變量不能被釋放
Golang是自帶GC的,如果資源一直被占用,是不會被自動釋放的,比如下面的代碼,如果傳入的slice b是很大的,然后引用很小部分給全局量a,那么b未被引用的部分就不會被釋放,造成了所謂的內(nèi)存泄漏。
var a []int func test(b []int) { a = b[:3] return }
想要理解這個內(nèi)存泄漏,主要就是理解上面的a = b[:3]是一個引用,其實新、舊slice指向的都是同一片內(nèi)存地址,那么只要全局量a在,b就不會被回收。
如果想避免這個問題,可以使用append方法的實現(xiàn),如果append的目標(biāo)slice空間不夠,會重新申請一個array來放需要append的內(nèi)容,所以&b[0]和&a[0]的值是不一樣的,而&a[0]和&c[0]地址是一致的:
time.Sleep(time.Second * 5) fmt.Println("main func") var b []int var c []int // 現(xiàn)在,如果再沒有其它值引用著承載著a元素的內(nèi)存塊, // 則此內(nèi)存塊可以被回收了。 func test(a []int) { c = a[:1] b = append(a[:0:0], a[:1]...) // 秀操作而已,也可以使用nil fmt.Println(&a[0], &c[0], &b[0]) //0xc0000aa030 0xc0000aa030 0xc0000b2038 }
也可以使用 copy()函數(shù)來實現(xiàn)引用類型的深拷貝。copy(dst[], src[])
切片容量導(dǎo)致內(nèi)存泄漏
假如我們從網(wǎng)絡(luò)中接受了很大的數(shù)據(jù),該協(xié)議使用前5個字節(jié)標(biāo)識消息類型。
func consumeMessages() { msg := receiveMessage() // a storeMessageType(getMessageType(msg)) //b // 其他的邏輯處理 } // 然后msg作為一個參數(shù) func getMessageType(msg []byte) []byte { //c return msg[:5] }
我們只想存儲每個消息的前5字節(jié)代表的消息類型,但同時我們將每條消息的整個容量的數(shù)據(jù)也存儲在了內(nèi)存中。
解決方式可以使用copy方法,來替代對msg進(jìn)行切分:
func getMessageType(msg []byte) []byte { msgType := make([]byte, 5) copy(msgType, msg) return msgType }
數(shù)組值傳遞
由于數(shù)組是Golang的基本數(shù)據(jù)類型,每個數(shù)組占用不同的內(nèi)存空間,生命周期互不干擾,很難出現(xiàn)內(nèi)存泄漏的情況,但是數(shù)組作為形參傳輸時,遵循的是值拷貝,如果函數(shù)被多個goroutine調(diào)用且數(shù)組過大時,則會導(dǎo)致內(nèi)存使用激增。
因此對于大數(shù)組放在形參場景下通常使用切片或者指針進(jìn)行傳遞,避免短時間的內(nèi)存使用激增。
goroutine導(dǎo)致內(nèi)存泄漏
Go內(nèi)存泄露,大部分都是goroutine泄露導(dǎo)致的。 雖然每個goroutine僅占用少量(棧)內(nèi)存,但當(dāng)大量goroutine被創(chuàng)建卻不會釋放時(即發(fā)生了goroutine泄露),也會消耗大量內(nèi)存,造成內(nèi)存泄露。
另外,如果goroutine里還有在堆上申請空間的操作,則這部分堆內(nèi)存也不能被垃圾回收器回收。
Go 10次內(nèi)存泄漏,8次goroutine泄漏,1次是真正內(nèi)存泄漏,還有1次是cgo導(dǎo)致的內(nèi)存泄漏 (“才高八斗”的既視感..)
在Go中大概單個goroutine占用2.6k左右的內(nèi)存空間。
Goroutine 內(nèi)存泄漏的原因
Go 語言的內(nèi)存泄漏通常因為錯誤地使用 goroutine 和 channel。例如以下幾種情況:
- 在 goroutine 里打開一個連接(如 gRPC)但是忘記 close。
- 在 goroutine 里的全局變量對象沒有釋放。
- 在 goroutine 里讀 channel, 但是沒有寫入端,而被阻塞。
- 在 goroutine 里寫入無緩沖的 channel,但是由于 channel 的讀端被其他協(xié)程關(guān)閉而阻塞。
- 在 goroutine 里寫入有緩沖的 channel,但是 channel 緩沖已滿。
- select操作在所有case上都阻塞,造成內(nèi)存泄漏
其實本質(zhì)上還是channel問題, 因為 select..case只能處理 channel類型, 即每個 case 必須是一個通信操作, 要么是發(fā)送要么是接收,select 將隨機(jī)執(zhí)行一個可運(yùn)行 case, 如果沒有 case 可運(yùn)行,它將阻塞,直到有 case 可運(yùn)行。 有個獨(dú)立 goroutine去做某些操作的場景下,為了能在外部結(jié)束它,通常有兩種方法:
同時傳入一個用于控制goroutine退出的 quit channel,配合 select,當(dāng)需要退出時close 這個 quit channel,該 goroutine 就可以退出
使用context
包的WithCancel,可參考context.WithCancel()
的使用
I/O問題,I/O連接未設(shè)置超時時間,導(dǎo)致goroutine一直在等待,代碼會一直阻塞。
互斥鎖未釋放,goroutine無法獲取到鎖資源,導(dǎo)致goroutine阻塞
//協(xié)程拿到鎖未釋放,其他協(xié)程獲取鎖會阻塞 func mutexTest() { mutex := sync.Mutex{} for i := 0; i < 10; i++ { go func() { mutex.Lock() fmt.Printf("%d goroutine get mutex", i) //模擬實際開發(fā)中的操作耗時 time.Sleep(100 * time.Millisecond) }() } time.Sleep(10 * time.Second) }
死鎖,當(dāng)程序死鎖時其他goroutine也會阻塞
func mutexTest() { m1, m2 := sync.Mutex{}, sync.RWMutex{} //g1得到鎖1去獲取鎖2 go func() { m1.Lock() fmt.Println("g1 get m1") time.Sleep(1 * time.Second) m2.Lock() fmt.Println("g1 get m2") }() //g2得到鎖2去獲取鎖1 go func() { m2.Lock() fmt.Println("g2 get m2") time.Sleep(1 * time.Second) m1.Lock() fmt.Println("g2 get m1") }() //其余協(xié)程獲取鎖都會失敗 go func() { m1.Lock() fmt.Println("g3 get m1") }() time.Sleep(10 * time.Second) }
waitgroup使用不當(dāng)。
waitgroup的Add、Done和wait數(shù)量不匹配會導(dǎo)致wait一直在等待。
上面列的情況,在日常開發(fā)過程中不容易發(fā)現(xiàn),因此會經(jīng)常帶來一些線上的問題。
select-case誤用導(dǎo)致的內(nèi)存泄露
func TestLeakOfMemory(t *testing.T) { fmt.Println("NumGoroutine:", runtime.NumGoroutine()) chanLeakOfMemory() time.Sleep(time.Second * 3) // 等待 goroutine 執(zhí)行,防止過早輸出結(jié)果 fmt.Println("NumGoroutine:", runtime.NumGoroutine()) } func chanLeakOfMemory() { errCh := make(chan error) // 1 go func() { // (5) time.Sleep(2 * time.Second) errCh <- errors.New("chan error") // 2 fmt.Println("finish sending") }() var err error select { case <-time.After(time.Second): // 3 大家也經(jīng)常在這里使用 <-ctx.Done() fmt.Println("超時") case err = <-errCh: // 4 if err != nil { fmt.Println(err) } else { fmt.Println(nil) } } }
輸出結(jié)果如下:
NumGoroutine: 2
超時
NumGoroutine: 3
這是 go channel 導(dǎo)致內(nèi)存泄漏的經(jīng)典場景。 根據(jù)輸出結(jié)果(開始有兩個 goroutine,結(jié)束時有三個 goroutine),我們可以知道,直到測試函數(shù)結(jié)束前,仍有一個 goroutine 沒有退出。
原因是由于 1 處創(chuàng)建的 errCh 是不含緩存隊列的 channel,如果 channel 只有發(fā)送方發(fā)送,那么發(fā)送方會阻塞;如果 channel 只有接收方,那么接收方會阻塞。
可以看到由于沒有發(fā)送方往 errCh 發(fā)送數(shù)據(jù),所以 4 處代碼一直阻塞。
直到 3 處超時后,打印“超時”,函數(shù)退出,4 處代碼都未接收成功。
而 2 處的所在的 goroutine 在“超時”被打印后,才開始發(fā)送。
由于外部的 goroutine 已經(jīng)退出了,errCh 沒有接收者,導(dǎo)致 2 處一直阻塞。
因此 2 處代碼所在的協(xié)程一直未退出,造成了內(nèi)存泄漏。
如果代碼中有許多類似的代碼,或在 for 循環(huán)中使用了上述形式的代碼,隨著時間的增長會造成多個未退出的 gorouting,最終導(dǎo)致程序 OOM。
這種情況其實還比較簡單。我們只需要為 channel 增加一個緩存隊列。即把 (1) 處代碼改為 errCh := make(chan error, 1) 即可。修改后輸出如下所示,可知我們創(chuàng)建的 goroutine 已經(jīng)退出了。
NumGoroutine: 2
超時
NumGoroutine: 2
可能會有人想要使用 defer close(errCh) 關(guān)閉 channel。比如把 1 處代碼改為如下形式(錯誤):
errCh := make(chan error) defer close(errCh)
由于 2 處代碼沒有接收者,所以一直阻塞。直到 close(errCh) 運(yùn)行,2 處仍在阻塞。這導(dǎo)致關(guān)閉 channel 時,仍有 goroutine 在向 errCh 發(fā)送。然而在 golang 中,在向 channel 發(fā)送時不能關(guān)閉 channel,否則會 panic。因此這種方式是錯誤的。
又或在 5 處 goroutine 的第一句加上 defer close(errCh)。由于 2 處阻塞, defer close(errCh) 會一直得不到執(zhí)行。因此也是錯誤的。 即便對調(diào) 2 處和 4 處的發(fā)送者和接收者,也會因為 channel 關(guān)閉,導(dǎo)致輸出無意義的零值。
for range 導(dǎo)致的協(xié)程泄漏
func leakOfMemory_1(nums ...int) { out := make(chan int) // sender go func() { defer close(out) for _, n := range nums { // c. out <- n fmt.Printf("sender success: %v\n", n) time.Sleep(time.Second) } }() // receiver go func() { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() for n := range out { //b. if ctx.Err() != nil { //a. fmt.Println("ctx timeout ") return } fmt.Println(n) } }() } // 單測文件中執(zhí)行 func TestLeakOfMemory(t *testing.T) { fmt.Println("NumGoroutine:", runtime.NumGoroutine()) leakOfMemory_1(1, 2, 3, 4, 5, 6, 7) time.Sleep(3 * time.Second) fmt.Println("main exit...") fmt.Println("NumGoroutine:", runtime.NumGoroutine()) }
執(zhí)行結(jié)果如下:
=== RUN TestLeakOfMemory
NumGoroutine: 2
1
sender success: 1
sender success: 2
2
ctx timeout
sender success: 3
main exit...
NumGoroutine: 3
--- PASS: TestLeakOfMemory (3.00s)
PASS
理論上,是不是最開始只有2個goruntine ,實際上執(zhí)行完出現(xiàn)了3個gorountine。
說明 leakOfMemory_1 里面起碼有一個協(xié)程沒有退出。 因為時間到了,在 a 處,程序就準(zhǔn)備退出了,也就是說 b 這個就退出了,沒有接收者繼續(xù)接受 chan 中的數(shù)據(jù)了。c處往chan 寫數(shù)據(jù)就阻塞了,因此協(xié)程一直沒有退出,就造成了泄漏。
如何解決上面說的協(xié)程泄漏問題? 可以加個管道通知來防止內(nèi)存泄漏。
goruntine 中 map 并發(fā)
map 是引用類型,函數(shù)值傳值是調(diào)用,參數(shù)副本依然指向m,因為值傳遞的是引用,對于共享變量,資源并發(fā)讀寫會產(chǎn)生競爭。 下面的場景在工作中經(jīng)常遇到(測的時候不容易發(fā)現(xiàn))。
func TestConcurrencyMap(t *testing.T) { m := make(map[int]int) go func() { for { m[3] = 3 } }() go func() { for { m[2] = 2 } }() //select {} time.Sleep(10 * time.Second) }
time.Ticker** 誤用造成內(nèi)存泄漏**
注意:Ticker 和 Timer 是不同的。Timer 只會定時一次,而 Ticker 如果不 Stop,就會一直發(fā)送定時。
func TestTickerNormal(t *testing.T) { ticker := time.NewTicker(time.Second) defer ticker.Stop() // stop一定不能漏了 go func() { for { fmt.Println(<-ticker.C) } }() time.Sleep(time.Second * 3) fmt.Println("finish") }
time.After()使用注意事項
看下面的例子:
func TestTimeAfter(t *testing.T) { defer func() { fmt.Println(runtime.NumGoroutine()) }() go func() { ticker := time.NewTicker(time.Second * 1) for { select { case <-ticker.C: fmt.Println("hello world") case <-time.After(time.Second * 3): fmt.Println("exit") return } } }() time.Sleep(time.Second * 5) fmt.Println("main func") } // 輸出結(jié)果如下 === RUN TestTimeAfter hello world hello world hello world hello world hello world main func 3 --- PASS: TestTimeAfter (5.00s) PASS
從輸出結(jié)果看,程序根本沒有打印exit, 也證明了goroutine不是由time.After() 退出,而是函數(shù)執(zhí)行結(jié)果退出。
看下關(guān)于time.After() 實現(xiàn)原理:After底層是用NewTimer實現(xiàn), NewTimer(d).C 每次都是 return 了一個新的對象。
func After(d Duration) <-chan Time { return NewTimer(d).C } func NewTimer(d Duration) *Timer { c := make(chan Time, 1) t := &Timer{ C: c, r: runtimeTimer{ when: when(d), f: sendTime, arg: c, }, } startTimer(&t.r) return t }
可以進(jìn)行如下的修改
func TestTimeAfter(t *testing.T) { defer func() { fmt.Println(runtime.NumGoroutine()) }() idleDuration := time.After(time.Second * 3) ticker := time.NewTicker(time.Second * 1) defer ticker.Stop() for { select { case <-ticker.C: fmt.Println("hello world") case <-idleDuration: fmt.Println("exit") return } } time.Sleep(time.Second * 5) fmt.Println("main func") }
下面的這個例子,是經(jīng)常遇到的一定要注意: 定時器定義位置
func main() { chi := make(chan int) go func() { for { // 定時器都是新創(chuàng)建的,那么就會造成永久性的泄露。 timer := time.After(10 * time.Second) select { case <-ch: fmt.Println("get it") case <-timer: fmt.Println("end") } } }() for i:= 1; i< 1000000; i++ { chi <- i time.sleep(time.Millisecond) } }
到此這篇關(guān)于Go語言中內(nèi)存泄漏的常見案例與解決方法的文章就介紹到這了,更多相關(guān)Go內(nèi)存泄漏內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
golang 40行代碼實現(xiàn)通用協(xié)程池
golang協(xié)程機(jī)制很方便的解決了并發(fā)編程的問題,但是協(xié)程并不是沒有開銷的,所以也需要適當(dāng)限制一下數(shù)量。這篇文章主要介紹了golang 40行代碼實現(xiàn)通用協(xié)程池,需要的朋友可以參考下2018-08-08Go語言開源庫實現(xiàn)Onvif協(xié)議客戶端設(shè)備搜索
這篇文章主要為大家介紹了Go語言O(shè)nvif協(xié)議客戶端設(shè)備搜索示例實現(xiàn),有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-04-04