golang channel 未關閉導致的記憶體漏失

2023-07-23 21:01:05

現象

某一個週末我們的服務 oom了,一個比較重要的job 沒有跑完,需要重跑,以為是偶然,重跑成功,因為是週末沒有去定位原因
又一個工作日,它又oom了,重跑成功,持續觀察,job 在oom之前竟然佔用了30g左右(這裡我們的任務比較大的資料量都在記憶體中計算,所以這裡機器記憶體量大一點)

應用使用30g記憶體肯定是不正常的,懷疑記憶體漏失了,怎麼定位記憶體漏失呢?

定位

搜了一下網上經常用到的工具是 go 的 pprof 火焰圖,自己在本地跑了一下,因為資料量比較少,並沒有發現什麼,暫時放下了。
後續某個早上在公司工具裡面開啟了一下,發現有火焰圖的工具,開啟看了一下一個函數佔用了 7224.46mb,佔用了 7個g, 而且這個函數是已經跑完了,這個時候定位到那個函數了,和旁邊同事說了一下,同事幫忙看了下郵件告警,每個下午都會有任務失敗告警(任務失敗會進行重試的); 這裡懷疑是失敗了, channel 沒有關閉,導致 消費的go routine 沒有回收。

舉個例子看下程式碼:

package main

import (
	"context"
	"fmt"
	"golang.org/x/sync/errgroup"
)

func main() {
	readGroup, _ := errgroup.WithContext(context.Background())
	consumeGroup, _ := errgroup.WithContext(context.Background())

	var (
		data = make(chan []int, 10)
	)

	//  3個生產者往裡面進行進行生產
	readGroup.Go(func() error {
		for i := 0; i < 3; i++ {
			data <- []int{i}
		}
		return nil
	})

	readGroup.Go(func() error {
		for i := 3; i < 6; i++ {
			data <- []int{i}
		}
		return nil
	})

	readGroup.Go(func() (err error) {
		for i := 6; i < 9; i++ {
			// error
			if i == 7 {
				err = fmt.Errorf("error le")
				return
			}
			data <- []int{i}
		}
		return nil
	})

	// 其中一個生產者遇到error 返回導致 channel 沒有關閉,消費者沒有退出

	// 1個消費者進行消費

	consumeGroup.Go(func() error {
		for i := range data {
			fmt.Println(i)
		}
		return nil
	})

	if err := readGroup.Wait(); err != nil {
		fmt.Println(err)
		return
	}

	close(data)

	if err := consumeGroup.Wait(); err != nil {
		fmt.Println(err)
		return
	}

	fmt.Println("end it")
}

這個case裡面,readGroup 遇到error 直接退出了,channel並沒有關閉,如果是常駐程序的程式,消費的go routine 並沒有回收,就導致了記憶體漏失

最簡單的關閉修復
將 close 放到最上面的 defer close(data)

不過最好的還是生產者進行關閉,我們可以優化一下程式碼,把生產者的程式碼放到一個函數中,這樣就可以讓生產者去進行關閉的操作了

package main

import (
	"context"
	"fmt"
	"golang.org/x/sync/errgroup"
)

func main() {
	var (
		data = make(chan []int, 10)
		err  error

		eg, _ = errgroup.WithContext(context.Background())
	)

	eg.Go(func() (err error) {
		defer close(data)

		err = readGroup(data)

		return
	})

	eg.Go(func() (err error) {
		err = consumeGroup(data)
		return
	})

	err = eg.Wait()
	if err != nil {
		return
	}

	fmt.Println("end it")
}

func consumeGroup(data chan []int) (err error) {
	consumeGroup, _ := errgroup.WithContext(context.Background())

	consumeGroup.Go(func() error {
		for i := range data {
			fmt.Println(i)
		}
		return nil
	})

	if err = consumeGroup.Wait(); err != nil {
		fmt.Println(err)
		return
	}

	return
}

func readGroup(data chan []int) (err error) {
	readGroup, _ := errgroup.WithContext(context.Background())

	//  3個生產者往裡面進行進行生產
	readGroup.Go(func() error {
		for i := 0; i < 3; i++ {
			data <- []int{i}
		}
		return nil
	})

	readGroup.Go(func() error {
		for i := 3; i < 6; i++ {
			data <- []int{i}
		}
		return nil
	})

	readGroup.Go(func() (err error) {
		for i := 6; i < 9; i++ {
			// error
			if i == 7 {
				err = fmt.Errorf("error le")
				return
			}
			data <- []int{i}
		}
		return nil
	})

	if err = readGroup.Wait(); err != nil {
		fmt.Println(err)
		return
	}

	return
}

修復

將生產者放在一個 go routine 裡面,最後如果遇到error的話 defer()的時候會把channel給關閉了

The Channel Closing Principle
One general principle of using Go channels is don't close a channel from the receiver side and don't close a channel if the channel has multiple concurrent senders. In other words, we should only close a channel in a sender goroutine if the sender is the only sender of the channel.

簡單點:就是在生產者中進行channel的關閉

後續討論和遇到的新問題

拆分程式碼函數的時候又遇到新的問題了,有一個切片陣列我拆分函數的時候,我沒有去接受切片函數的返回值,導致了切片發生擴容返回的是一個空切片,並沒有修改掉原來的切片。之前以為在golang裡面切片是參照型別,會自動改變其中的值最後查了一下,在go 裡面都是值傳遞,可以修改其中的值其實是使用了指標修改了同一塊地址中的值所以值發生了變化

總結

使用channel 的時候在生產者中進行關閉,思考一些遇到error的時候channel是否可以正常的關閉
go 中只有值傳遞,參照傳遞是修改了同一個指向記憶體地址中的值

參考文章:

如何優雅地關閉Go channel
Go語言引數傳遞是傳值還是傳參照