明明还有大量内存,为啥报错“无法分配内存”?
作者 |?張彥飛allen
來源 | 開發(fā)內(nèi)功修煉
近日小伙伴和我說了線上服務(wù)器出現(xiàn)一個(gè)詭異的問題,執(zhí)行任何命令都是報(bào)錯(cuò)“fork:無法分配內(nèi)存”。這個(gè)問題最近出現(xiàn)的,前幾次重啟后解決的,但是每隔 2-3 天就會(huì)出現(xiàn)一次。
#?service?docker?stop -bash?fork:?無法分配內(nèi)存 #?vi?1.txt -bash?fork:?無法分配內(nèi)存看到這個(gè)提示,大家的第一反應(yīng)肯定是懷疑內(nèi)存真的不夠了。我們這位讀者也是這么認(rèn)為的。但查看內(nèi)存占用卻發(fā)現(xiàn)根本沒有,內(nèi)存還空閑了一大把!(多試幾次才有機(jī)會(huì)執(zhí)行成功一次)
飛哥幫出了三個(gè)思路:
是不是numa架構(gòu)下,進(jìn)程啟動(dòng)的時(shí)候綁定了node,導(dǎo)致只有一個(gè)node里的內(nèi)存在起作用?
numa架構(gòu)下,如果所有內(nèi)存都插到一個(gè)槽,其它node就會(huì)沒內(nèi)存
查看下現(xiàn)在的進(jìn)(線)程數(shù)是多少,是不是超過最大限制了
這里直接和大家匯報(bào)結(jié)論,前面關(guān)于 numa 內(nèi)存不足的猜測(cè)是錯(cuò)誤的。真實(shí)的原因是上面第 3 個(gè),這臺(tái)服務(wù)器上面的某幾個(gè)java進(jìn)程創(chuàng)建了太多的線程,導(dǎo)致了這個(gè)報(bào)錯(cuò)的產(chǎn)生,并不真的是內(nèi)存不夠。
底層過程分析
這個(gè)問題中,Linux 報(bào)錯(cuò)提示存在誤導(dǎo)人的地方。導(dǎo)致大家并沒有第一時(shí)間往進(jìn)程數(shù)上想。所以才有了這么復(fù)雜曲折的排錯(cuò)過程,以至于在群里討論才得以解決。
于是我想深入到內(nèi)核里看看,報(bào)錯(cuò)到底是如何提示出來這么一個(gè)不恰當(dāng)?shù)腻e(cuò)誤提示的。然后順便咱們也來了解了解創(chuàng)建進(jìn)程的過程。
讀者的線上服務(wù)器的操作系統(tǒng)是 CentOS 7.8,我查了一下對(duì)應(yīng)的內(nèi)核版本是 3.10.0-1127。
1.1 do_fork 剖析
在 Linux 內(nèi)核里,無論是創(chuàng)建進(jìn)程還是線程,都會(huì)調(diào)用到最核心的 do_fork 上來。在這個(gè)函數(shù)內(nèi)部,通過拷貝的方式來創(chuàng)建新的進(jìn)程(線程)所需要的內(nèi)核數(shù)據(jù)對(duì)象。
//file:kernel/fork.c long?do_fork(unsigned?long?clone_flags,?...) {//所謂的創(chuàng)建,其實(shí)是根據(jù)當(dāng)前進(jìn)程進(jìn)行拷貝//注意:倒數(shù)第二個(gè)參數(shù)傳入的是 NULLp?=?copy_process(clone_flags,?stack_start,?stack_size,child_tidptr,?NULL,?trace);... }整個(gè)進(jìn)程創(chuàng)建的核心都是位于 copy_process 中,我們來看它的源碼。
//file:kernel/fork.c static?struct?task_struct?*copy_process(unsigned?long?clone_flags,?...struct?pid?*pid,int?trace) {//內(nèi)核表示進(jìn)程(線程)的數(shù)據(jù)結(jié)構(gòu)叫task_structstruct?task_struct?*p;......//拷貝方式生成新進(jìn)程的核心數(shù)據(jù)結(jié)構(gòu)p?=?dup_task_struct(current);//拷貝方式生成新進(jìn)程的其它核心數(shù)據(jù)retval?=?copy_semundo(clone_flags,?p);retval?=?copy_files(clone_flags,?p);retval?=?copy_fs(clone_flags,?p);retval?=?copy_sighand(clone_flags,?p);retval?=?copy_mm(clone_flags,?p);retval?=?copy_namespaces(clone_flags,?p);retval?=?copy_io(clone_flags,?p);retval?=?copy_thread(clone_flags,?stack_start,?stack_size,?p);//注意這里!!!!!!//申請(qǐng)整數(shù)形式的?pid?值if?(pid?!=?&init_struct_pid)?{retval?=?-ENOMEM;pid?=?alloc_pid(p->nsproxy->pid_ns);if?(!pid)goto?bad_fork_cleanup_io;}//將生成的整數(shù)pid值設(shè)置到新進(jìn)程的?task_struct?上p->pid?=?pid_nr(pid);p->tgid?=?p->pid;if?(clone_flags?&?CLONE_THREAD)p->tgid?=?current->tgid;bad_fork_cleanup_io:if?(p->io_context)exit_io_context(p); ...... fork_out:return?ERR_PTR(retval);? }通過以上代碼可以看出,Linux 內(nèi)核創(chuàng)建整個(gè)進(jìn)程內(nèi)核對(duì)象的創(chuàng)建過程都是通過分別調(diào)用不同的 copy_xxx 的方式來實(shí)現(xiàn)的,包括 mm 結(jié)構(gòu)體、包括 namespaces等等。
我們來重點(diǎn) alloc_pid 相關(guān)的這一段。在這一段中,目的是要申請(qǐng)一個(gè) pid 對(duì)象出來。如果申請(qǐng)失敗就返回錯(cuò)誤了。大家注意這段代碼的細(xì)節(jié):無論 alloc_pid 返回的是何種類型的失敗,其錯(cuò)誤類型都寫死的返回 -ENOMEM。。。 為了方便大家理解,我單獨(dú)把這段邏輯再展示一遍。
//file:kernel/fork.c static?struct?task_struct?*copy_process(...){......//申請(qǐng)整數(shù)形式的?pid?值if?(pid?!=?&init_struct_pid)?{retval?=?-ENOMEM;pid?=?alloc_pid(p->nsproxy->pid_ns);if?(!pid)goto?bad_fork_cleanup_io;} bad_fork_cleanup_io: ... fork_out:return?ERR_PTR(retval);? }在準(zhǔn)備調(diào)用 alloc_pid 的時(shí)候,直接就先將錯(cuò)誤類型設(shè)置成了 -ENOMEM(retval = -ENOMEM),只要 alloc_pid 返回的不正確,都是將ENOMEM 這個(gè)錯(cuò)誤返回給上層。而不管 alloc_pid 內(nèi)存究竟是因?yàn)槭裁丛虍a(chǎn)生的錯(cuò)誤。
我們來查看一下 ENOMEM 的定義。它代表的是 Out of memory 的意思。(內(nèi)核只是返回錯(cuò)誤碼,應(yīng)用層再給出具體的錯(cuò)誤提示,所以實(shí)際提示的是中文的“無法分配內(nèi)存”)。
//file:include/uapi/asm-generic/errno-base.h #define?ENOMEM??12?/*?Out?of?memory?*/不得不說。內(nèi)核的這個(gè)錯(cuò)誤提示太成問題了。給使用者造成了很大的困惑。
1.2 導(dǎo)致 alloc_pid 失敗的原因
那我們接著再來詳細(xì)看看都有哪些情況下分配 pid 會(huì)失敗呢?來看 alloc_pid 的源碼
//file:kernel/pid.c struct?pid?*alloc_pid(struct?pid_namespace?*ns) {//第一種情況:申請(qǐng) pid 內(nèi)核對(duì)象失敗pid?=?kmem_cache_alloc(ns->pid_cachep,?GFP_KERNEL);if?(!pid)goto?out;//第二種情況:申請(qǐng)整數(shù)?pid?號(hào)失敗//調(diào)用到alloc_pidmap來分配一個(gè)空閑的pidtmp?=?ns;pid->level?=?ns->level;for?(i?=?ns->level;?i?>=?0;?i--)?{nr?=?alloc_pidmap(tmp);if?(nr?<?0)goto?out_free;pid->numbers[i].nr?=?nr;pid->numbers[i].ns?=?tmp;tmp?=?tmp->parent;}... out:return?pid;? out_free:goto?out;? }我們平時(shí)說的 pid 在內(nèi)核中并不是一個(gè)簡(jiǎn)單的整數(shù)類型,而是一個(gè)小結(jié)構(gòu)體來表示的(struct pid),如下。
//file:include/linux/pid.h struct?pid {atomic_t?count;unsigned?int?level;struct?hlist_head?tasks[PIDTYPE_MAX];struct?rcu_head?rcu;struct?upid?numbers[1]; };所以需要先到內(nèi)存中申請(qǐng)一塊內(nèi)存用來存儲(chǔ)這個(gè)小對(duì)象。第一種錯(cuò)誤情況是如果內(nèi)存申請(qǐng)失敗,alloc_pid 會(huì)返回失敗。這種情況下確實(shí)是內(nèi)存問題,出錯(cuò)后內(nèi)核返回 ENOMEM 無可厚非。
接著往下看第二種情況,alloc_pidmap 是要為當(dāng)前的進(jìn)程申請(qǐng)進(jìn)程號(hào),就是我們平時(shí)所說的 PID 編號(hào)。如果申請(qǐng)失敗,也會(huì)返回錯(cuò)誤。
對(duì)于這種情況來說,只是分配進(jìn)程編號(hào)出錯(cuò)了,和內(nèi)存不夠用半毛錢的關(guān)系都沒有。但在這種情況下內(nèi)核卻會(huì)導(dǎo)致返回給上層的錯(cuò)誤類型是 ENOMEM(Out of memory)。這實(shí)在是挺不合理的。
通過這里我們還額外學(xué)習(xí)到了另外一個(gè)知識(shí)!一個(gè)進(jìn)程并不只是申請(qǐng)一個(gè)進(jìn)程號(hào)就夠了。而是通過一個(gè) for 循環(huán)去申請(qǐng)了多個(gè)。
//file:kernel/pid.c struct?pid?*alloc_pid(struct?pid_namespace?*ns) {//調(diào)用到alloc_pidmap來分配一個(gè)空閑的pidtmp?=?ns;pid->level?=?ns->level;for?(i?=?ns->level;?i?>=?0;?i--)?{nr?=?alloc_pidmap(tmp);if?(nr?<?0)goto?out_free;pid->numbers[i].nr?=?nr;pid->numbers[i].ns?=?tmp;tmp?=?tmp->parent;} }假如說當(dāng)前創(chuàng)建的進(jìn)程是一個(gè)容器中的進(jìn)程,那么它至少得申請(qǐng)兩個(gè) PID 號(hào)才行。一個(gè) PID 是在容器命名空間中的進(jìn)程號(hào),一個(gè)是根命名空間(宿主機(jī))中的進(jìn)程號(hào)。
這也符合我們平時(shí)的經(jīng)驗(yàn)。在容器中的每一個(gè)進(jìn)程其實(shí)我們?cè)谒拗鳈C(jī)中也都能看到。但是在容器中看到的進(jìn)程號(hào)一般是和在宿主機(jī)上看到的是不一樣的。比如一個(gè)進(jìn)程在容器中的 pid 是 5,在宿主機(jī)命名空間下是 1256。那么該進(jìn)程在內(nèi)核中的對(duì)象大概是如下這個(gè)樣子。
新版本是否有所改觀
接下來,我首先想到的可能是因?yàn)樵蹅冇玫膬?nèi)核版本太舊了。(我用的內(nèi)核版本是 3.10.1)
所以我又到非常新的 Linux 5.16.11 翻了一翻,看看新版本是否有修復(fù)這個(gè)不恰當(dāng)?shù)奶崾尽?/p>
推薦一個(gè)工具:https://elixir.bootlin.com/ 。在這個(gè)網(wǎng)站上可以查看任意版本的 linux 內(nèi)核源碼。如果只是臨時(shí)看一下,用它非常的合適。
//file:kernel/fork.c static?__latent_entropy?struct?task_struct?*copy_process(...) {...pid?=?alloc_pid(p->nsproxy->pid_ns_for_children,?args->set_tid,args->set_tid_size);if?(IS_ERR(pid))?{retval?=?PTR_ERR(pid);goto?bad_fork_cleanup_thread;} }貌似看起來有戲,retval 不再寫死的是 ENOMEM 了,而是根據(jù) alloc_pid 實(shí)際的錯(cuò)誤進(jìn)行了設(shè)置。我們?cè)賮砜?alloc_pid 是不是正確地設(shè)置錯(cuò)誤類型了呢?
當(dāng)我打開 alloc_pid 的源碼里,看到這一大段注釋的時(shí)候,我的心涼了半截。。。
//file:include/pid.c struct?pid?*alloc_pid(struct?pid_namespace?*ns,?...) {/**?ENOMEM?is?not?the?most?obvious?choice?especially?for?the?case*?where?the?child?subreaper?has?already?exited?and?the?pid*?namespace?denies?the?creation?of?any?new?processes.?But?ENOMEM*?is?what?we?have?exposed?to?userspace?for?a?long?time?and?it?is*?documented?behavior?for?pid?namespaces.?So?we?can't?easily*?change?it?even?if?there?were?an?error?code?better?suited.*/retval?=?-ENOMEM;.......return?retval }我把這段注釋給大家大致翻譯一下。它的意思是“ENOMEM不是最明顯的選擇,尤其是對(duì)于 pid 創(chuàng)建失敗的情況下。但是,ENOMEM 是我們長(zhǎng)期暴露給用戶空間的東西。因此,即使有更適合的錯(cuò)誤代碼,我們也無法輕易更改它”
看到這兒,我想起了有不少人也稱 Linux 為屎山,可能這就是其中的一坨吧!最新的版本里也并沒有很好地解決這個(gè)問題。
結(jié)論
在 Linux 里創(chuàng)建進(jìn)程時(shí),如果在 pid 不足的時(shí)候竟然返回的錯(cuò)誤提示是“內(nèi)存不足”。這個(gè)不恰當(dāng)?shù)腻e(cuò)誤提示導(dǎo)致很多同學(xué)都困惑不已。
通過今天的文章,以后你再遇到這種內(nèi)存不足錯(cuò)誤的時(shí)候,你就要多留個(gè)心眼兒了,別被內(nèi)核被蒙騙了,先來看看自己的進(jìn)程(線程)數(shù)是不是過多了。
至于說發(fā)現(xiàn)了這個(gè)問題該如何解決嘛,可以通過修改內(nèi)核參數(shù)加大可用 pid 數(shù)量(/proc/sys/kernel/pid_max)。
但是我覺得最根本的方法還是要揪出來為啥系統(tǒng)中會(huì)出現(xiàn)這么多的進(jìn)程(線程),然后把它干掉。默認(rèn)情況下的兩三萬個(gè)進(jìn)程數(shù)對(duì)于絕大多數(shù)的服務(wù)器來說已經(jīng)是一個(gè)過于龐大的數(shù)字了,連這個(gè)數(shù)都超過了,一定是不合理的。
往期推薦
Redis 緩存擊穿(失效)、緩存穿透、緩存雪崩怎么解決?
如果被問到分布式鎖,應(yīng)該怎樣回答?
性能突出的 Redis 是咋使用 epoll 的?
Java 底層知識(shí):什么是?“橋接方法”??
點(diǎn)分享
點(diǎn)收藏
點(diǎn)點(diǎn)贊
點(diǎn)在看
總結(jié)
以上是生活随笔為你收集整理的明明还有大量内存,为啥报错“无法分配内存”?的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 2017,人工智能技术如何让中国开发者“
- 下一篇: 彻底理解内存泄漏,memory leak