Linux 內(nèi)存變低會(huì)發(fā)生什么問(wèn)題
作者 | cynrikluo
內(nèi)存不是無(wú)限的,總有不夠用的時(shí)候,linux內(nèi)核用三個(gè)機(jī)制來(lái)處理這種情況:內(nèi)存回收、內(nèi)存規(guī)整、oom-kill。
當(dāng)發(fā)現(xiàn)內(nèi)存不足時(shí),內(nèi)核會(huì)先嘗試內(nèi)存回收,從一些進(jìn)程手里拿回一些頁(yè);如果這樣還是不能滿(mǎn)足申請(qǐng)需求,則觸發(fā)內(nèi)存規(guī)整;再不行,則觸發(fā)oom主動(dòng)kill掉一個(gè)不太重要的進(jìn)程,釋放內(nèi)存。
低內(nèi)存情況下,內(nèi)核的處理邏輯
內(nèi)存申請(qǐng)的核心函數(shù)是__alloc_pages_nodemask:
/*
* This is the 'heart' of the zoned buddy allocator.
*/
struct page *
__alloc_pages_nodemask(gfp_t gfp_mask, unsigned int order, int preferred_nid,
nodemask_t *nodemask)
{
struct page *page;
unsigned int alloc_flags = ALLOC_WMARK_LOW;
gfp_t alloc_mask; /* The gfp_t that was actually used for allocation */
struct alloc_context ac = { };
__alloc_pages_nodemask會(huì)先嘗試調(diào)用get_page_from_freelist從伙伴系統(tǒng)的freelist里拿空閑頁(yè),如果能拿到就直接返回:
如果拿不到,則進(jìn)入慢速路徑:
__alloc_pages_slowpath,慢速路徑,顧名思義,就是拿得慢一點(diǎn),需要做一些操作以后再拿。
首先, __alloc_pages_slowpath會(huì)喚醒kswapd:
kswapd是一個(gè)守護(hù)進(jìn)程,專(zhuān)門(mén)進(jìn)行內(nèi)存回收操作,執(zhí)行路徑:
它被喚醒后,會(huì)立1刻開(kāi)始進(jìn)行回收,效率高的話,freelist上會(huì)立刻多出很多空閑頁(yè)。
所以 __alloc_pages_slowpath會(huì)馬上再次嘗試從freelist獲取頁(yè)面,獲取成功則直接返回了。
若還是失敗, __alloc_pages_slowpath則會(huì)進(jìn)入direct_reclaim階段:
direct_reclaim,顧名思義,就是直接內(nèi)存回收,回收到的頁(yè)不用放回freelist再get_page_from_freelist這么麻煩了,也不用喚醒某個(gè)進(jìn)程幫忙回收,而是由當(dāng)前進(jìn)程(current)親自下場(chǎng)去回收,執(zhí)行路徑:
如果direct_reclaim也回收不上來(lái), __alloc_pages_slowpath還會(huì)垂死掙扎下,做一下內(nèi)存規(guī)整,嘗試把零散的頁(yè)輾轉(zhuǎn)騰挪,拼成為大order頁(yè)(僅在申請(qǐng)order>0的頁(yè)時(shí)有用)。
如果還是無(wú)法滿(mǎn)足要求,則進(jìn)入oom-kill了:
總結(jié)上面的邏輯:內(nèi)存申請(qǐng)時(shí),首先嘗試直接從freelist里拿;失敗了則先喚醒kswapd幫忙回收內(nèi)存;若內(nèi)存低到讓kswapd也愛(ài)莫能助,則進(jìn)入direct reclaim直接回收內(nèi)存;若direct reclaim也無(wú)能為力,則oom:
三條水線
實(shí)際上,從freelist上拿頁(yè)不是簡(jiǎn)單地直接拿,而是先檢查下該zone是否滿(mǎn)足水線要求,不滿(mǎn)足那就直接失敗。
內(nèi)核給內(nèi)存管理劃了三條水線:MIN、LOW、HIGH。
三者大小關(guān)系從字面即可推斷,MIN < LOW < HIGH。
在首次嘗試從freelist拿頁(yè)時(shí),門(mén)檻水線是LOW;喚醒kswapd后再次嘗試拿頁(yè),門(mén)檻水線是MIN。
所以實(shí)際邏輯如下:
所以,可以簡(jiǎn)單地認(rèn)為,可用內(nèi)存低于LOW水線時(shí),喚醒kswapd;低于MIN水線時(shí),進(jìn)行direct reclaim;而HIGH水線,是kswapd的回收終止線:
為什么內(nèi)存回收時(shí),磁盤(pán)IO會(huì)被打滿(mǎn)?
可以看到,kswapd和direct_reclaim最終都是走到了shrink_node:
shrink_node是內(nèi)存回收的核心函數(shù),顧名思義,讓整個(gè)node進(jìn)行一次“收縮”,把不要的數(shù)據(jù)清掉,空出空閑頁(yè)。
get_scan_count決定本次掃描多少個(gè)anon page和file page。
anon page就是Anonymous Page,匿名頁(yè),是進(jìn)程的堆棧、數(shù)據(jù)段等。內(nèi)核回收匿名頁(yè)時(shí),將這些數(shù)據(jù)進(jìn)行壓縮(壓縮比大概為3),然后移動(dòng)到內(nèi)存中的一個(gè)小角落中(swap空間),這個(gè)過(guò)程并沒(méi)有與磁盤(pán)發(fā)生交互,因此不會(huì)產(chǎn)生IO,但需要壓縮數(shù)據(jù),所以耗CPU。
file page就是文件頁(yè),是進(jìn)程的代碼段、映射的文件。內(nèi)核回收文件頁(yè)時(shí),先將“臟”數(shù)據(jù)回寫(xiě)到磁盤(pán),然后釋放掉這些緩存數(shù)據(jù),干凈的數(shù)據(jù)則直接釋放掉。這個(gè)過(guò)程涉及到寫(xiě)磁盤(pán),因此會(huì)產(chǎn)生IO。
簡(jiǎn)單總結(jié)一下get_scan_count的邏輯:
所以說(shuō),不論開(kāi)沒(méi)開(kāi)swap,內(nèi)存回收都是傾向于回收f(shuō)ile page。
如果file page中有臟頁(yè),那內(nèi)存回收大概率就會(huì)產(chǎn)生一些IO,無(wú)非是IO量多少罷了。
以下情況IO可能會(huì)打滿(mǎn)或者暴增:
- 當(dāng)前內(nèi)存不是特別緊張,但low、min水線設(shè)置得太低,之前一直沒(méi)怎么觸發(fā)過(guò)內(nèi)存回收,以致于臟頁(yè)已經(jīng)累積到大量,一觸發(fā)回收,立刻就是回寫(xiě)大量臟頁(yè),導(dǎo)致IO暴增。
- 內(nèi)存極度緊張 (free 和available同時(shí)很低)。這種情況下,anon page遠(yuǎn)比f(wàn)ile page多,這意味著可回收的內(nèi)存很少,內(nèi)核會(huì)對(duì)活躍數(shù)據(jù)下手,一些進(jìn)程上一秒還用著的數(shù)據(jù),這一秒可能就被不幸回收了,但下一秒馬上又要被使用,會(huì)再次被讀入內(nèi)存。如此,同一份數(shù)據(jù),內(nèi)核就進(jìn)行了多次回收和讀入,IO就加倍了。
為什么低內(nèi)存有時(shí)會(huì)引發(fā)hungtask?
低內(nèi)存時(shí),通常不是個(gè)別進(jìn)程觸發(fā)了direct reclaim,而是大量進(jìn)程都在direct reclaim。
大家都要回寫(xiě)臟頁(yè),于是IO被打滿(mǎn)了。
這時(shí)候,進(jìn)程會(huì)頻繁地被IO阻塞,被阻塞的進(jìn)程為了不占用CPU,會(huì)調(diào)用io_schedule_timeout或io_schedule來(lái)掛起自己,直到IO完成。
這種等待是D狀態(tài)的,一旦超過(guò)了120S,就會(huì)觸發(fā)hungtask。當(dāng)然,這是非常極端的情況,IO已經(jīng)完全沒(méi)救的情況。
大部分時(shí)候,IO雖然打滿(mǎn)了,但是總能周轉(zhuǎn)過(guò)來(lái),所以這些進(jìn)程并不會(huì)等太久。
然而,這些進(jìn)程若是來(lái)自同一個(gè)業(yè)務(wù),則大概率會(huì)訪問(wèn)同一個(gè)數(shù)據(jù),這就需要通過(guò)mutex、rwsem、semaphore等同步機(jī)制來(lái)控制訪問(wèn)行為。
而這些同步機(jī)制的基本接口都是uninterruptible性質(zhì)的,以semaphore為例:
extern void down(struct semaphore *sem); // 基本接口。獲取信號(hào)量,獲取不到則進(jìn)入uninterruptible睡眠
extern int __must_check down_interruptible(struct semaphore *sem); // 其他接口
extern int __must_check down_killable(struct semaphore *sem); // 其他接口
extern int __must_check down_trylock(struct semaphore *sem); // 其他接口
extern int __must_check down_timeout(struct semaphore *sem, long jiffies); // 其他接口
所謂uninterruptible性質(zhì),即當(dāng)進(jìn)程獲取不到同步資源時(shí),直接進(jìn)入D狀態(tài)等待其他進(jìn)程釋放資源。
其他同步資源,rwsem、mutex等,都有這樣的uninterruptible性質(zhì)接口。
正常情況下,只要持有同步資源的進(jìn)程正常運(yùn)行不卡頓,那么即使有上百個(gè)進(jìn)程來(lái)爭(zhēng)搶這些同步資源,對(duì)于排序靠后的進(jìn)程來(lái)說(shuō),時(shí)間也是夠的,一般不會(huì)等待超過(guò)120s。
但在低內(nèi)存情況下,大家都在等IO,這些持有資源的進(jìn)程也不能幸免,引發(fā)堵車(chē)連鎖反應(yīng)。
如果此時(shí)同步資源的waiter們已累計(jì)了幾十個(gè)甚至上百個(gè),那么就算只有一瞬間的io卡頓,排序靠后的waiter也容易等待超過(guò)120s,觸發(fā)hungtask。
一個(gè)非常典型的案例,一臺(tái)CVM在連續(xù)報(bào)了幾條hungtask warning后,徹底無(wú)響應(yīng)了,通過(guò)魔術(shù)建觸發(fā)重啟。
系統(tǒng)信息如下:
內(nèi)存狀況不容樂(lè)觀,典型的低內(nèi)存:
log上有很多hungtask warning,超時(shí)原因都是等rwsem太長(zhǎng),寫(xiě)者waiter和讀者waiter都有:
這些進(jìn)程在等同一個(gè)rwsem,這個(gè)rwsem的地址為:ffff880e9703f370
進(jìn)一步探究,發(fā)現(xiàn)當(dāng)前對(duì)ffff880e9703f370有引用的進(jìn)程為19個(gè),11個(gè)正在讀,8個(gè)排隊(duì)。
而這11個(gè)正在讀的進(jìn)程,都在做同一件事——direct reclaim,并且都卡在IO等待:
這11個(gè)進(jìn)程,雖然也是D狀態(tài),但由于時(shí)不時(shí)能調(diào)度到IO,相當(dāng)于D狀態(tài)的持續(xù)時(shí)間不斷重置,所以本身并沒(méi)有觸發(fā)hungtask。
而這8個(gè)waiter進(jìn)程就沒(méi)這個(gè)好運(yùn)了,被前面11個(gè)進(jìn)程你方唱罷我登場(chǎng)地阻塞,持續(xù)時(shí)間也沒(méi)有機(jī)會(huì)重置,最終超過(guò)120s,引發(fā)hungtask了。
優(yōu)化低內(nèi)存處理
我們已經(jīng)知道了低內(nèi)存會(huì)導(dǎo)致IO突增,甚至導(dǎo)致hungtask,那要如何避免呢?
可以從兩方面來(lái)避免。
(1) 調(diào)整臟頁(yè)回刷頻率
將平時(shí)的臟頁(yè)回刷頻率調(diào)高,這樣內(nèi)存回收時(shí),需要回收的臟頁(yè)就更少,降低IO的增量。
- 調(diào)低 /proc/sys/vm/dirty_writeback_centisecs
- 調(diào)低/proc/sys/vm/dirty_background_ratio
調(diào)高水線,可以更早地進(jìn)入內(nèi)存回收邏輯,這樣可以將free維持在一個(gè)較高水平,避免陷入極端場(chǎng)景。由于low和min同時(shí)受min_free_kbytes管控,所以可以直接調(diào)整min_free_kbytes值。
調(diào)高/proc/sys/vm/min_free_kbytes