1. 明明還有大量內(nèi)存,為啥報錯“無法分配內(nèi)存”?

        共 6033字,需瀏覽 13分鐘

         ·

        2022-03-09 21:00

        大家好,我是飛哥!

        讀者群里一位同學(xué)的線上服務(wù)器出現(xiàn)一個詭異的問題,執(zhí)行任何命令都是報錯“fork:無法分配內(nèi)存”。這個問題最近出現(xiàn)的,前幾次重啟后解決的,但是每隔 2-3 天就會出現(xiàn)一次。

        #?service?docker?stop
        -bash?fork:?無法分配內(nèi)存
        #?vi?1.txt
        -bash?fork:?無法分配內(nèi)存

        看到這個提示,大家的第一反應(yīng)肯定是懷疑內(nèi)存真的不夠了。我們這位讀者也是這么認為的。但查看內(nèi)存占用卻發(fā)現(xiàn)根本沒有,內(nèi)存還空閑了一大把?。ǘ嘣噹状尾庞袡C會執(zhí)行成功一次)

        飛哥和群里的同學(xué)們一起參謀這個問題以后,幫出了三個思路。讓這位讀者回去挨個試。
        • 1.是不是numa架構(gòu)下,進程啟動的時候綁定了node,導(dǎo)致只有一個node里的內(nèi)存在起作用?
        • 2.numa架構(gòu)下,如果所有內(nèi)存都插到一個槽,其它node就會沒內(nèi)存
        • 3.查看下現(xiàn)在的進(線)程數(shù)是多少,是不是超過最大限制了

        在經(jīng)過一段時間的排查以后,這位讀者的問題順利解決。這里直接和大家匯報結(jié)論,前面關(guān)于 numa 內(nèi)存不足的猜測是錯誤的。真實的原因是上面第 3 個,這臺服務(wù)器上面的某幾個java進程創(chuàng)建了太多的線程,導(dǎo)致了這個報錯的產(chǎn)生,并不真的是內(nèi)存不夠。

        一、底層過程分析

        這個問題中,Linux 報錯提示存在誤導(dǎo)人的地方。導(dǎo)致大家并沒有第一時間往進程數(shù)上想。所以才有了這么復(fù)雜曲折的排錯過程,以至于在群里討論才得以解決。

        于是我想深入到內(nèi)核里看看,報錯到底是如何提示出來這么一個不恰當?shù)腻e誤提示的。然后順便咱們也來了解了解創(chuàng)建進程的過程。

        讀者的線上服務(wù)器的操作系統(tǒng)是 CentOS 7.8,我查了一下對應(yīng)的內(nèi)核版本是 3.10.0-1127。

        1.1 do_fork 剖析

        在 Linux 內(nèi)核里,無論是創(chuàng)建進程還是線程,都會調(diào)用到最核心的 do_fork 上來。在這個函數(shù)內(nèi)部,通過拷貝的方式來創(chuàng)建新的進程(線程)所需要的內(nèi)核數(shù)據(jù)對象。

        //file:kernel/fork.c
        long?do_fork(unsigned?long?clone_flags,?...)
        {
        ?//所謂的創(chuàng)建,其實是根據(jù)當前進程進行拷貝
        ?//注意:倒數(shù)第二個參數(shù)傳入的是 NULL
        ?p?=?copy_process(clone_flags,?stack_start,?stack_size,
        ????child_tidptr,?NULL,?trace);
        ?...
        }

        整個進程創(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)核表示進程(線程)的數(shù)據(jù)結(jié)構(gòu)叫task_struct
        ?struct?task_struct?*p;

        ?......

        ?//拷貝方式生成新進程的核心數(shù)據(jù)結(jié)構(gòu)
        ?p?=?dup_task_struct(current);

        ?//拷貝方式生成新進程的其它核心數(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);

        ?//注意這里?。。。。?!
        ?//申請整數(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è)置到新進程的?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)建整個進程內(nèi)核對象的創(chuàng)建過程都是通過分別調(diào)用不同的 copy_xxx 的方式來實現(xiàn)的,包括 mm 結(jié)構(gòu)體、包括 namespaces等等。

        我們來重點 alloc_pid 相關(guān)的這一段。在這一段中,目的是要申請一個 pid 對象出來。如果申請失敗就返回錯誤了。大家注意這段代碼的細節(jié):無論 alloc_pid 返回的是何種類型的失敗,其錯誤類型都寫死的返回 -ENOMEM。。。 為了方便大家理解,我單獨把這段邏輯再展示一遍。

        //file:kernel/fork.c
        static?struct?task_struct?*copy_process(...){
        ?......

        ?//申請整數(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);?
        }?

        在準備調(diào)用 alloc_pid 的時候,直接就先將錯誤類型設(shè)置成了 -ENOMEM(retval = -ENOMEM),只要 alloc_pid 返回的不正確,都是將 ENOMEM 這個錯誤返回給上層。而不管 alloc_pid 內(nèi)存究竟是因為什么原因產(chǎn)生的錯誤

        我們來查看一下 ENOMEM 的定義。它代表的是 Out of memory 的意思。(內(nèi)核只是返回錯誤碼,應(yīng)用層再給出具體的錯誤提示,所以實際提示的是中文的“無法分配內(nèi)存”)。

        //file:include/uapi/asm-generic/errno-base.h
        #define?ENOMEM??12?/*?Out?of?memory?*/

        不得不說。內(nèi)核的這個錯誤提示太成問題了。給使用者造成了很大的困惑。

        1.2 導(dǎo)致 alloc_pid 失敗的原因

        那我們接著再來詳細看看都有哪些情況下分配 pid 會失敗呢?來看 alloc_pid 的源碼

        //file:kernel/pid.c
        struct?pid?*alloc_pid(struct?pid_namespace?*ns)
        {
        ?//第一種情況:申請 pid 內(nèi)核對象失敗
        ?pid?=?kmem_cache_alloc(ns->pid_cachep,?GFP_KERNEL);
        ?if?(!pid)
        ??goto?out;

        ?//第二種情況:申請整數(shù)?pid?號失敗
        ?//調(diào)用到alloc_pidmap來分配一個空閑的pid
        ?tmp?=?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;?
        }

        我們平時說的 pid 在內(nèi)核中并不是一個簡單的整數(shù)類型,而是一個小結(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)存中申請一塊內(nèi)存用來存儲這個小對象。第一種錯誤情況是如果內(nèi)存申請失敗,alloc_pid 會返回失敗。這種情況下確實是內(nèi)存問題,出錯后內(nèi)核返回 ENOMEM 無可厚非。

        接著往下看第二種情況,alloc_pidmap 是要為當前的進程申請進程號,就是我們平時所說的 PID 編號。如果申請失敗,也會返回錯誤。

        對于這種情況來說,只是分配進程編號出錯了,和內(nèi)存不夠用半毛錢的關(guān)系都沒有。但在這種情況下內(nèi)核卻會導(dǎo)致返回給上層的錯誤類型是 ENOMEM(Out of memory)。這實在是挺不合理的。

        通過這里我們還額外學(xué)習(xí)到了另外一個知識!一個進程并不只是申請一個進程號就夠了。而是通過一個 for 循環(huán)去申請了多個。

        //file:kernel/pid.c
        struct?pid?*alloc_pid(struct?pid_namespace?*ns)
        {
        ?//調(diào)用到alloc_pidmap來分配一個空閑的pid
        ?tmp?=?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;
        ?}
        }

        假如說當前創(chuàng)建的進程是一個容器中的進程,那么它至少得申請兩個 PID 號才行。一個 PID 是在容器命名空間中的進程號,一個是根命名空間(宿主機)中的進程號。

        這也符合我們平時的經(jīng)驗。在容器中的每一個進程其實我們在宿主機中也都能看到。但是在容器中看到的進程號一般是和在宿主機上看到的是不一樣的。比如一個進程在容器中的 pid 是 5,在宿主機命名空間下是 1256。那么該進程在內(nèi)核中的對象大概是如下這個樣子。

        二、新版本是否有所改觀

        接下來,我首先想到的可能是因為咱們用的內(nèi)核版本太舊了。(熟悉飛哥的讀者都知道,我用的內(nèi)核版本是 3.10.1,這是為了和我們公司線上服務(wù)器的版本保持一致。)

        所以我又到非常新的 Linux 5.16.11 翻了一翻,看看新版本是否有修復(fù)這個不恰當?shù)奶崾尽?/p>

        推薦一個工具:https://elixir.bootlin.com/ 。在這個網(wǎng)站上可以查看任意版本的 linux 內(nèi)核源碼。如果只是臨時看一下,用它非常的合適。

        //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è)置。我們再來看 alloc_pid 是不是正確地設(shè)置錯誤類型了呢?

        當我打開 alloc_pid 的源碼里,看到這一大段注釋的時候,我的心涼了半截。。。

        //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不是最明顯的選擇,尤其是對于 pid 創(chuàng)建失敗的情況下。但是,ENOMEM 是我們長期暴露給用戶空間的東西。因此,即使有更適合的錯誤代碼,我們也無法輕易更改它

        看到這兒,我想起了有不少人也稱 Linux 為屎山,可能這就是其中的一坨吧!最新的版本里也并沒有很好地解決這個問題。

        結(jié)論

        在 Linux 里創(chuàng)建進程時,如果在 pid 不足的時候竟然返回的錯誤提示是“內(nèi)存不足”。這個不恰當?shù)腻e誤提示導(dǎo)致很多同學(xué)都困惑不已。

        通過今天的文章,以后你再遇到這種內(nèi)存不足錯誤的時候,你就要多留個心眼兒了,別被內(nèi)核被蒙騙了,先來看看自己的進程(線程)數(shù)是不是過多了。

        至于說發(fā)現(xiàn)了這個問題該如何解決嘛,可以通過修改內(nèi)核參數(shù)加大可用 pid 數(shù)量(/proc/sys/kernel/pid_max)。

        但是我覺得最根本的方法還是要揪出來為啥系統(tǒng)中會出現(xiàn)這么多的進程(線程),然后把它干掉。默認情況下的兩三萬個進程數(shù)對于絕大多數(shù)的服務(wù)器來說已經(jīng)是一個過于龐大的數(shù)字了,連這個數(shù)都超過了,一定是不合理的。

        瀏覽 43
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
        評論
        圖片
        表情
        推薦
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
          
          

            1. 同性三级大尺度 | 国产 都市 中文 自拍 | 好大好爽噼里啪啦的视频 | 日韩欧美在线观看一区二区 | 欧美日韩一区,二区,三区,久久精品 |