內存泄漏?從用戶態跟蹤到內核去

“不吃涼粉讓板凳”

內存泄漏可謂整個軟件行業最痛最常見的問題之一,往往比較隱蔽,有時需要特殊的異常場景才能觸發,有時是一種慢性病,需要長達幾周或幾個月才能暴露問題。內存的飆漲導致系統內存越來越吃緊,系統需要爲新的內存申請而不斷東挪西湊,這些內存釘子戶也導致內存出現碎片,後來系統只有將部分內存內容swap到磁盤才能解決問題了,之後便只能頻繁在內存與磁盤間折騰,進而磁盤IO負載拉昇、CPU負載拉昇,導致系統性能每況愈下,吞吐能力降低,出現卡頓,直到某一天系統宕機了,或許才引起開發者的注意。

“不吃涼粉讓板凳”,怎麼找出這些不吃涼粉還一直霸佔着板凳的客人呢?

對於內存泄漏,一般可以藉助top或者腳本來週期性採集進程內存信息找出問題進程,然後對這個進程strace,結合代碼review,基本都可以快速、圓滿地解決問題。另外,還有強大的Valgrind 等可用於內存泄漏檢測的工具。誠然,預防纔是最好的解決方案,要儘量把問題在上線發佈前挖掘出來,可以藉助一些靜態分析工具來掃描代碼中存在內存泄漏隱患的地方,這類工具很多應用很成熟,這裏不做進一步闡述。

Linux發生內存泄漏後,最後內核將觸發OOM killer,之後系統到底是觸發panic死機?還是選擇性kill掉一些內存score比較高的進程?這些行爲都可以通過內核的參數panic_on_oom、oom_kill_allocating_task來配置。

背景

線上環境機器遇到一個隱蔽的問題,某天機器突然掛掉了,看監控系統發現應該是系統使用內存一直飆漲導致的問題,但是後來登入機器去觀察各個進程所佔內存並無明顯變化。如下圖,一天多的時間,系統使用內存可以從4G漲到10G以上,然後接下來導致機器OOM,服務不可用。

系統已佔用內存飆漲曲線

定位

這類內存飆漲問題,和系統請求量曲線並無明顯關聯,曲線單調遞增,首先想到的是內存泄漏問題。

一般情況下,通過top監控都能找出那個內存泄漏的進程。

但是,假如top並不能找出問題進程呢?

這裏用top、ps統計觀察各個進程佔用內存,發現系統內存飆漲前後,各個進程佔用內存並沒有明顯的增長行爲。

ps -eo pid,pmem,pcpu,rss,vsize,args

按照內存排序:

PID %MEM %CPU   RSS    VSZ COMMAND
 8580  0.2  0.0 35556  70884 ./friend_push_worker
 8578  0.2  0.0 34744  70744 ./friend_push_worker
 8576  0.2  0.0 34132  70768 ./friend_push_worker
 8574  0.2  0.0 33008  70868 ./friend_push_worker
 8572  0.2  0.0 32280  70756 ./friend_push_worker
 8570  0.2  0.0 31788  70748 ./friend_push_worker

那系統內存哪裏去了呢?

不過,在排查過程中,發現系統裏有一個叫friend_push的服務,這個服務殺死之後,系統佔用內存就會恢復如初,這個服務啓動之後,系統內存繼續飆漲。

接下來,就圍繞friend_push 的進程展開排查,系統內存從4G漲到10G時,去統計friend_push 的所有進程佔用物理內存之和也就是幾百兆而已。

綜合看起來,用戶態進程佔用內存並無內存泄漏,那無非就是內核態佔用內存出現了泄漏。對於內核態佔用內存的多少,並沒有直接的工具可以查看,在top下即便看到內核態進程,也是沒有統計各個內核態進程佔用的內存信息。

不過,linux有強大豐富的/proc系統,我們用的絕大多數linux統計的命令也是根據這裏的數據做統計展示的,我們可以藉助這裏來計算出內核佔用內存,有這樣一個公式:

Total Mem = User + Kernel + shared + cache(/buffer) + free

其中:

  • Total Mem:機器總內存,是已知的;

  • User:系統所有用戶態進程佔用內存總和;

  • Kernel:內核佔用內存總和;

  • shared + cache(/buffer) + free :通過free命令也可以查看到。

所以,要求出Kernel這一項的話,需要先求出User這一項,User這一項沒有現成的工具可以查看到,需要藉助於工具統計,可以累加所有進程的smaps下的Pss這一項之和,命令如下:

$grep Pss /proc/[1-9]*/smaps | awk '{total+=$2}; END {print total}'
2562230

這裏要注意用Pss,而不是RSS(兩者的區別可查看man)。

系統內存使用情況:

$ free -h
              total        used        free      shared  buff/cache   available
Mem:            13G        8G        3.3G        0.9G         2.2G         4.6G
Swap:            0B          0B          0B

那內核佔用的內存就是:

13G - 2.56G - 3.3G - 0.9G - 2.2G - 4.6G = 4.6G

可以得出結論:在系統內存從4G漲到10G之後,內核佔用物理內存的漲幅超過4G。

內核佔用內存有泄漏?

這裏該怎麼進一步定爲呢?內核內存泄漏有kmemleak可以使用,使用這個工具,需要內核支持,需要重編內核開啓相應的選項,將相應模塊編譯進內核,然後重新安裝系統到機器。
這樣好像越繞越遠了,還能從哪些角度出發呢?

從網絡看看,這裏用netstat觀察各個連接佔用內存的變化,觀察到有一部分連接的Recv-Q(socket接收隊列)一項一直在增長,這一部分連接有一個共同特徵,都是綁定在friend_push進程 。

Recv-Q一直增長是爲什麼呢?入流量太大進程處理不過來嗎?看機器整體網絡負載並不高。並且,停止向friend_push進程發請求之後,Recv-Q會保持不變,並不會減少。

UDP socket的接收隊列一直增長

所以,這些網絡報文積壓的原因,並不是進程處理不過來,而是進程根本沒有處理!這裏根據這個信息,再次從friend_push的代碼着手,主要是查找網絡收發有問題的地方,查找不會接收處理報文的地方,的確查到一處用udp協議來發送請求的地方,發送完之後,並沒有接收。

至此,問題終於定位到了。

解決

問題原因是client進程發送請求,到達server進程之後server處理完請求之後進行了回包,而client並沒有對這個報文進行接收處理,用戶態進程不去讀取,於是報文就一直積壓在內核得不到釋放。

先是採用了臨時解決方案,將server進程代碼修改,改爲接收到請求之後不回包。這樣,就不會導致client這一側的機器內存一直飆漲。修復發佈之後,效果很明顯,如下圖。

修復問題之後系統的已佔用內存曲線

只寫模式的UDP socket的實現

問題是得到了解決,但是怎麼避免其他人踩坑呢?並且這種模式的確很不合理。

有沒有手段能更合理地解決這一問題?也就是即便是對端回包,也不會影響本機內存佔用情況。也就是說,能不能實現一種只寫模式的socket(Write Only Mode),這種socket只可以發包,不可以接收數據,不可以接收自然也不會導致本機內存飆漲。

socket都是雙工的,TCP socket提供了shutdown這一API可以使得socket變成半雙工狀態,但是對於UDP,內核並沒有提供類似的API。這裏採用了一個簡接的方法,將 socket的接收buffer設爲0,而socket默認的接收buffer一般是8M(這裏要注意,使用setsockopt設置時,接收buffer有個最小值,雖然設置爲0時api可以正常返回,但是實際在內核中,這個接收buffer依然會有幾個KB大小,我這裏實驗得到的結果是2K,網上也有512字節等多種實驗結果),這樣之後,我們基本可以忽略socket的接收buffer佔用的內存了,Recv-Q一項也不會增長太大了,超過2K之後,所有的報文都會被丟棄,不會進入接收隊列。

$ man 2 setsockopt 
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

於是,修改了公司內封裝的網絡庫,原先的實現中,會在連接池中獨立維護TCP、UDP兩種連接信息,在此基礎上實現了一種新的連接類型WUDP(Write-only mode UDP,只寫模式的UDP)。

三種連接的連接池模型

和前兩種連接類型一樣來單獨管理。上層應用使用時,在發起網絡請求前,可以通過參數控制來選擇不同的連接類型,如同本文中講到的friend_push一樣,只需要發包請求,並不需要收包處理,那就可以選擇 WUDP,避免遠端回包導致本機內存泄漏。

skb分配機制導致的內存放大效應

skb可謂是socket底層的最核心數據結構,其定義在include/linux/skbuff.h頭文件中可以看到。伴隨着一個個報文從網卡流入內核又被用戶態進程讀取處理,skb會分配、回收。而在linux內核,skb分配時,內核先分配一個較大的內存塊(N頁大小的大內存塊(frag)),然後有網絡包需要接收時,再從這個大內存塊frag裏劃分小片的內存給每一個skb。如此重複。所以,一個frag裏有很多個skb,需要所有的skb都釋放之後,這個宿主frag才能被系統回收。

skb的分配過程主流程摘錄示例:

net\core\pktgen.c
	fill_packet_ipv4
		__netdev_alloc_skb //__netdev_alloc_skb - allocate an skbuff for rx on a specific device
			if (fragsz <= PAGE_SIZE && !(gfp_mask & (__GFP_WAIT | GFP_DMA)))
				__netdev_alloc_frag( ); //按照2的N次方個page的大小來分配一個frag
			else
				__alloc_skb( );	//從slab分配
					kmem_cache_alloc_node

我們的一臺機器中,往往會有多種進程同時進行網絡收發,這背後伴隨着頻繁的skb的分配和銷燬。

假如有些skb當了“釘子戶”,遲遲不離開(不被用戶態進程讀取處理),那這個frag就一直得不到回收。同理,當kernel中存在很多種類似情況,導致frag上的碎片空間得不到利用,導致很多的frag都不能回收,這樣,因爲這種碎片空間的存在,就會導致系統佔用內存出現放大效應,出現“內核內存泄漏”。但是,這種泄漏也不是沒有底線的。內核約束了協議棧佔用的內存空間,通過參數來控制(net.ipv4.udp_mem)、動態調整行爲。

$ man 7 udp
net.ipv4.udp_mem
       udp_mem (since Linux 2.6.25)
              This is a vector of three integers governing the number of
              pages allowed for queueing by all UDP sockets.

              min       Below this number of pages, UDP is not bothered
                        about its memory appetite.  When the amount of
                        memory allocated by UDP exceeds this number, UDP
                        starts to moderate memory usage.

              pressure  This value was introduced to follow the format of
                        tcp_mem (see tcp(7)).

              max       Number of pages allowed for queueing by all UDP
                        sockets.

驗證對比

下面分別驗證舊的UDP連接和新增的WUDP連接的實驗效果。

對於server端,在兩臺機器上分別執行server程序,監聽9743端口,並向client端回包。

udp

clien端,在這個IP(xxx.xxx.xxx.76)的機器上執行, 開啓100個進程,併發向server請求。

cat socketPoolNew.sh 
#!/bin/bash 
for i in {1..100};do 
	./socketPoolNew 5000000 udp 0 & 
done 

這時,觀察client端機器中的socket接收隊列的增長情況、系統已佔用內存的增長情況如下:

	$ sh socketPoolNew.sh $ netstat -nup | sort -k 2 -r | grep 9734 

socket接收隊列(上圖第二列)增長達到極限

可以看到,每個UDP socket接收隊列佔用內存達到8M。而系統的已佔用內存,如下,也增長了1G多。

運行前:

$ date;free -m
Thu Nov  7 22:50:36 CST 2019
              total        used        free      shared  buff/cache   available
Mem:          31915         910        4562       17070       26442       11568
Swap:             0           0           0

運行5分鐘之後:

$ date;free -m
Thu Nov  7 22:55:37 CST 2019
              total        used        free      shared  buff/cache   available
Mem:          31915        2345        2048       17070       27521        9057
Swap:             0           0           0

kill掉所有進程之後,內存恢復到最初水平:

$ killall socketPoolNew
$ date;free -m
Thu Nov  7 22:58:56 CST 2019
              total        used        free      shared  buff/cache   available
Mem:          31915         906        4516       17070       26491       11522
Swap:             0           0           0

wudp

類似的實驗環境,這次試用新增的WUDP這一連接類型,也開啓100個進程,併發向server請求。

$ sh socketPoolNew.sh

socket接收隊列(上圖第二列)只可以增長到2k+字節

而系統的已佔用內存,如下,僅僅有幾十兆的增長,相比之前1G多的內存增長以及微不足道。

運行前:

$ date;free -m
Thu Nov  7 23:05:52 CST 2019
              total        used        free      shared  buff/cache   available
Mem:          31915         911        4545       17070       26457       11554
Swap:             0           0           0

運行5分鐘之後:

date;free -m
Thu Nov  7 23:11:09 CST 2019
              total        used        free      shared  buff/cache   available
Mem:          31915         935        4544       17070       26458       11551
Swap:             0           0           0

作者介紹

餘昌葉,騰訊音樂公司高級工程師,《騰訊知識獎》獲得者,多篇專利發明者。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章