当前位置: 首页 > 科技观察

明明还有很多内存,为什么会报“Unabletoallocatememory”的错误呢?

时间:2023-03-18 11:17:45 科技观察

大家好,我是飞哥!读者群一个同学的在线服务器出现了一个奇怪的问题。执行任何命令时,报错“fork:unabletoallocatememory”。这个问题最近出现,在最初几次重新启动后得到解决,但每隔2-3天就会出现一次。#servicedockerstop-bashfork:Unabletoallocatememory#vi1.txt-bashfork:Unabletoallocatememory看到这个提示,大家的第一反应肯定是怀疑内存是不是真的不够用了。我们的读者也这么认为。但是查看内存使用情况,发现根本就没有内存,而且内存还是空闲的!(多试几次才有机会成功执行一次)。就这个问题和群里的同学商量后,飞哥提出了三个想法。让读者回过头来一一尝试。1.numa架构下,进程启动时绑定节点,这样只有一个节点中的内存起作用?2、在numa架构下,如果所有的内存都插在一个slot上,其他节点就没有内存了。3、查看当前传入(线)线程数,是否超过最大限制。经过一段时间的排查,顺利解决了读者的问题。这里直接给大家汇报一下。之前关于numa内存不足的猜测是错误的。真正的原因是上面的第三个。此服务器上的某些java进程创建了过多的线程,导致了此错误的产生。并不是真的内存不够。1.底层流程分析在这个问题中,Linux的错误信息是误导性的。结果大家一开始就没有考虑进程数。所以才会有这么复杂曲折的排查过程,以至于只有在群里讨论才能解决。所以想深入内核看看错误报告是怎么提示这样不恰当的错误信息的。那么顺便也了解一下创建进程的过程。读者在线服务器操作系统为CentOS7.8。查了一下对应的内核版本是3.10.0-1127。1.1do_fork分析在Linux内核中,无论是创建进程还是创建线程,都会调用核心的do_fork。在这个函数内部,通过复制创建新进程(线程)所需的内核数据对象。//file:kernel/fork.clongdo_fork(unsignedlongclone_flags,...){//所谓创建其实就是根据当前进程进行复制//注:倒数第二个参数为NULLp=copy_process(clone_flags,stack_start,stack_size,child_tidptr,NULL,trace);...}整个进程创建的核心就在copy_process中,我们来看一下它的源码。//file:kernel/fork.cstaticstructtask_struct*copy_process(unsignedlongclone_flags,...structpid*pid,inttrace){//内核代表进程(线程)的数据结构叫做task_structstructtask_struct*;......//复制方式生成新进程的核心数据结构p=dup_task_struct(current);//复制方式生成新进程的其他核心数据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);//注意这里!!!!!!//申请整数形式的pid值if(pid!=&init_struct_pid){retval=-ENOMEM;pid=alloc_pid(p->nsproxy->pid_ns);如果(!pid)转到bad_fork_cleanup_io;}//将生成的整数pid值设置为新进程的task_structp->pid=pid_nr(pid);p->tgid=p->pid;如果(clone_flags&CLONE_THREAD)p->tgid=current->tgid;bad_fork_cleanup_io:if(p->io_context)exit_io_context(p);......fork_out:返回ERR_PTR(retval);}从上面的代码可以看出,Linux内核创建整个进程的内核对象的过程是通过分别调用不同的copy_xxx来实现的,包括mm结构体,包括命名空间等。我们重点看这段与alloc_pid有关。本段的目的是申请一个pid对象。如果应用程序失败,则返回错误。请注意这段代码的细节:无论alloc_pid返回什么类型的失败,错误类型都硬编码为返回-ENOMEM……为了方便大家理解,我再展示一下这个逻辑。//file:kernel/fork.cstaticstructtask_struct*copy_process(...){......//申请整数形式的pid值if(pid!=&init_struct_pid){retval=-ENOMEM;pid=alloc_pid(p->nsproxy->pid_ns);如果(!pid)转到bad_fork_cleanup_io;}bad_fork_cleanup_io:...fork_out:returnERR_PTR(retval);}在准备调用alloc_pid时,设置错误类型为-ENOMEM(retval=-ENOMEM),只要alloc_pid返回错误,就会向上层返回ENOMEM错误。不管alloc_pid内存错误的原因。让我们看看ENOMEM的定义。它代表内存不足。(内核只是返回一个错误码,而应用层给出了具体的错误提示,所以实际的中文提示是“cannotallocatememory”)。//file:include/uapi/asm-generic/errno-base.h#defineENOMEM12/*Outofmemory*/不得不说。内核的这个报错信息太有问题了。给用户造成了很大的困惑。1.2alloc_pid失败的原因那么我们来仔细看看在什么情况下pid分配会失败呢?查看alloc_pid的源码://file:kernel/pid.cstructpid*alloc_pid(structpid_namespace*ns){//第一种情况:申请pid内核对象失败pid=kmem_cache_alloc(ns->pid_cachep,GFP_KERNEL);如果(!pid)跳出;//第二种情况:申请整数pid号失败//调用alloc_pidmap分配一个空闲的pidtmp=ns;pid->level=ns->level;对于(i=ns->level;i>=0;i--){nr=alloc_pidmap(tmp);如果(nr<0)转到out_free;pid->numbers[i].nr=nr;pid->numbers[i].ns=tmp;tmp=tmp->父级;}...输出:返回pid;out_free:转到外面;通常提到的pid在内核中并不是一个简单的整型,而是一个小结构体(structpid),如下。//file:include/linux/pid.hstructpid{atomic_tcount;无符号整数级别;结构hlist_head任务[PIDTYPE_MAX];结构rcu_headrcu;结构upid数字[1];};所以需要先在内存中申请一块内存来存放这个小对象。第一种错误情况是,如果内存分配失败,alloc_pid将返回失败。这种情况确实是内存的问题,内核出错后返回ENOMEM也没什么问题。再往下看第二种情况,alloc_pidmap是为当前进程申请进程号,也就是我们通常所说的PID号。如果应用程序失败,也会返回错误。这种情况只是进程号分配错误,与内存不足无关。但在这种情况下,内核会导致返回给上层的错误类型为ENOMEM(Outofmemory)。这实在是不合理。通过这个,我们也学到了另外一个知识!一个流程是不够的,只申请一个流程号。相反,多个应用程序通过for循环应用。//file:kernel/pid.cstructpid*alloc_pid(structpid_namespace*ns){//调用alloc_pidmap分配一个空闲的pidtmp=ns;pid->level=ns->level;for(i=ns->level;i>=0;i--){nr=alloc_pidmap(tmp);如果(nr<0)转到out_free;pid->numbers[i].nr=nr;pid->numbers[i].ns=tmp;tmp=tmp->父级;}}如果当前创建的进程是容器中的进程,那么它至少要申请两个PID号。一个PID是容器命名空间中的进程ID,另一个是根命名空间(host)中的进程ID。这也符合我们通常的经验。容器中的每个进程实际上都可以在宿主机中看到。但是,在容器中看到的进程ID与在宿主机上看到的一般是不一样的。比如一个进程在容器中的pid是5,在host命名空间中是1256。那么进程在内核中的对象大概是这样的。2、新版本是否有变化。接下来,我首先想到的可能是我们使用的内核版本太旧了。(熟悉飞哥的读者都知道,我用的内核版本是3.10.1,这是为了和我们公司线上服务器的版本保持一致。)于是就上了很新的Linux5.16.11,翻了一番,看如果新版本修复了这个不恰当的提示。推荐一个工具:https://elixir.bootlin.com/。在此站点上,您可以查看任何版本的linux内核的源代码。如果只是临时看看,用起来还是很合适的。//file:kernel/fork.cstatic__latent_entropystructtask_struct*copy_process(...){...pid=alloc_pid(p->nsproxy->pid_ns_for_children,args->set_tid,args->set_tid_size);如果(IS_ERR(pid)){retval=PTR_ERR(pid);转到bad_fork_cleanup_thread;}}好像有点意思,retval不再硬编码为ENOMEM,而是根据alloc_pid的实际误差来设置。看看alloc_pid是不是设置错了类型?当我打开alloc_pid的源码看到这个大注释的时候,心凉了半截。。。//file:include/pid.cstructpid*alloc_pid(structpid_namespace*ns,...){/**ENOMEM这不是最明显的选择,特别是对于子subreaper已经退出并且pid*命名空间拒绝创建任何新进程的情况。但ENOMEM*是我们长期以来向用户空间公开的内容,它是pid命名空间的记录行为。所以即使有更合适的错误代码,我们也不能轻易*更改它。*/retval=-ENOMEM;.......returnretval}我会给你一个粗略的翻译这个笔记。这意味着“ENOMEM不是最明显的选择,特别是对于pid创建失败。然而,ENOMEM是我们长期暴露给用户空间的东西。因此,即使有更合适的错误码,我们也不能轻易更改”。看到这里,我想起了很多人也把Linux称为狗屎山。也许这就是其中之一!最新版本也没有解决这个问题问题很好。结语在linux中创建进程时,如果pid不足,返回的错误信息是“内存不足”,这个不合适的错误信息让很多同学感到困惑,通过今天的文章,当你遇到这种out-of-memoryerror以后又出现了,大家要多注意了,别被kernel忽悠了,先看看是不是进程(threads)太多了,至于我找到了怎么解决这个问题.可以通过修改内核参数(/proc/sys/kernel/pid_max)来增加可用pid的个数。不过我觉得最根本的办法还是找出系统中pid为什么那么多进程(thread),然后杀掉.默认数量20000到30000processes对于大多数服务器来说太大了,超过这个数量肯定是不合理的。