OOM-KILLer的演进与新的启发式策略

linux在2.6.36内核中修正了oom-killer的行为,oom-killer在引入之初就曾引发过争论,这个东西到底应不应该存在,内存不够用了的时候,到底应不应该由操作系统内核替我们做一些事,比如选出一个吃内存的大户,然后干掉它,这种行为甚是鲁莽,按照机制和策略分离的原则,内核其实应该将这件事报告给用户,让用户空间进程判断应该怎么做,然而此时已经没有内存,机器可能已经无法操作,起码已经是死寂之神态,内核又如何通知用户,因此索性就oom-killer了。
     早期的oom-killer在选择应该被杀掉的进程的时候的策略非常之简单,就是以该进程之虚拟地址空间的大小为基准,然后以运行时间以及fork情况加上nice值等细小因素微扰之,最终取出一个得分最高者,杀之!这种策略显然无法服众啊,操作系统实际需要的是物理内存页面,此时已经没有,因此操作系统需要的是其它进程的物理内存页面被释放,而早期策略使用虚拟地址空间的大小为基准实则不合适,毕竟要知道虚拟地址空间只是组织保护模式操作系统之所用,进程实际使用的是物理内存,物理内存页面映射进虚拟内存之后,方可展现保护模式的操作系统之多作业并发之态。以上提及只是老的策略不足之处之一,另还有,当时需要的仅仅是一些内存页面,实则不必将内存占用最大者杀掉,而且不管此内存占用最大者是什么,不问青红皂白一律处死,即使它映射了大量的物理内存,如果它同时又做了极其重要之事,也是合理的,比如对于KDE桌面主进程,它必然占据大量页面,然而它也确实重要,相反,整机内存告罄的主谋可能是一些占据内存很小然而很多的进程,只需大量fork,然后做很少的事即可,内核如何识别这种情形?因此对于oom-killer需要该进之处确实有三:第一点就是将物理内存页面的使用作为基准而不是虚拟地址空间的大小;第二则是导出用户策略的控制权;第三是内核要有一个简单然而合理的默认策略。以上三点在2.6.36内核中完全实现。
unsigned int oom_badness(struct task_struct *p, struct mem_cgroup *mem,
              const nodemask_t *nodemask, unsigned long totalpages)
{
    int points;
    ...//0
    //第一要点的体现:完全按照物理内存的占用情况计算分数:rss表示物理页面的占用,totalpages表示当前节点的所有页面数量
    points = (get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS)) * 1000 /
            totalpages;
    ...
    //第三要点的体现之一:特权进程最好不要被杀掉
    if (has_capability_noaudit(p, CAP_SYS_ADMIN))
        points -= 30;
    //第二要点的体现:可配置的用户建议的策略,oom_score_adj可以通过/proc/pid/oom_adj文件写入,如果你写入-1000,那就表示这个进程是不能被oom-killer杀死的,这种情况将在注释0处返回
    points += p->signal->oom_score_adj;
    ...//返回一个1到1000之间的分数
}
第三要点的体现之二是什么呢?在out_of_memory函数中有下面的逻辑:
retry:
    //下面的select的核心就是上面的oom_badness函数
    p = select_bad_process(&points, totalpages, NULL, mpol_mask);
    //如果找不到一个可杀的进程,直接panic即可,内存已经告罄!!
    if (!p) {
        dump_header(NULL, gfp_mask, order, NULL, mpol_mask);
        read_unlock(&tasklist_lock);
        panic("Out of memory and no killable processes.../n");
    }
    //oom_kill_process体现了要点之三的第二部分
    if (oom_kill_process(p, gfp_mask, order, points, totalpages, NULL,
                nodemask, "Out of memory"))
        goto retry;
    killed = 1;
在oom_kill_process中,oom-killer逻辑进行最后的抉择:
static int oom_kill_process(...)
{
    struct task_struct *victim = p;
    struct task_struct *child;
    struct task_struct *t = p;
    unsigned int victim_points = 0;
    ...
    //不是已经找到一个进程p了吗,为何还要触摸其子进程呢?
    do {
        list_for_each_entry(child, &t->children, sibling) {
            unsigned int child_points;
            child_points = oom_badness(child, mem, nodemask, totalpages);
            if (child_points > victim_points) {
                victim = child;
                victim_points = child_points;
            }
        }
    } while_each_thread(p, t);
    return oom_kill_task(victim, mem);
}
是的,问题是为何还要触摸其子进程呢?这就是第三要点的第二部分:杀子不杀父的原则。
有两个理由这么做,第一个理由是如果杀掉了parent,那么由于linux进程按照树型结构组织,那么一旦父进程退出,将会有很多工作要做,比如收养它的子进程之类的事,本来一个很小的腾出内存空间的事,最后招致这么多完全额外的事情,这是不合适的,第二个理由就是可能这个父进程是一个关键服务,比如一个网络daemon进程,或者桌面管理器程序等等,杀掉的话会严重影响用户空间的,而子进程往往都是一些工作者进程(这已经成了unix/linux的编程模式了),因此杀掉子进程能腾出一些内存够这次使用就可以了,如果不够的话那么再杀一个子进程即可,可见这里使用了一点懒惰的思想。
     另外,新旧(2.6.36内核之前和之后)两个版本的oom-killer对待候选进程的子进程采取了几乎相反的策略,我觉得就版本实现的稍显复杂但是更有意思,旧版本的内核在badness函数中处理子进程相关的逻辑(基于2.6.35内核):
list_for_each_entry(child, &p->children, sibling) {
    task_lock(child);
    if (child->mm != mm && child->mm)
        points += child->mm->total_vm/2 + 1;
    task_unlock(child);
}
这里面有两个要点,这两个要点之间有一个很有意思的权衡,第一个要点是如果一个进程多一个子进程,那么它的bad分数就会增加,这明显是不照顾一直在fork的进程,也就是如果一个进程疯狂的fork,那么它的bad分数就会很高,子进程越多,它的bad分数就越高,第二个要点则是每次只加入子进程total_vm的一半,这是为了防止一个吃大量内存的子进程逃出oom-killer的眼线,因为如果父进程被加的分数过高的话,子进程的分数无论怎样也不会超过父进程因而永远不会被选中,特别的,如果每次都将子进程total_vm完全加到父进程的分数里面,子进程的分数再怎么也很难(!!)超过父进程了,因此这里就选择了子进程total_vm的%50加入父进程的bad分数,我倒是觉得这个百分比可调整会比较好,在/proc/pid/中导出一个文件,可调节子进程加百分之多少分数到父进程,这样更合理些,而且用户也可以根据进程的性质或者功能自己调解这个百分比从而影响oom-killer的行为。不管怎么说,旧版本的那个%50是写死的,不可调的,这样就会有一个问题,如果系统中有一个prefork模式的apache服务,有大量的连接有大量的工作进程,那么在oom的时候,该apache主进程就有很大的可能被干掉,这是很不合理的,因此新版本的oom-killer反其道而行之,在oom_badness中去掉了这一段代码,孩子太多不是父亲的错(?),在新的oom-killer设计中,不再试图杀死父进程。如果一个进程有子进程,那么偏向于杀死子进程往往会对整个系统的影响比较小。新内核oom-killer的设计默认了这样一个不总是事实的事,那就是一般而言,父进程都是总体控制者,它们一般都是理性的,合法的,善意的,发生oom的时候,总是不应该杀掉父进程。为了避免老内核在oom时干掉apache这种事,你可以将/proc/pid/oom_adj的值设置的足够低,或者直接设置成-1000,这样它就不会被干掉了。
附:android的killer
android是一个手机(平板电脑)操作系统平台,它运行于内存基本上都很有限的手持设备上,并且这类设备的进程往往都具有屏幕独占性,因此不像pc或者服务器那样,你可以随时调出任务管理器,在手持设备上,调出任务管理器意味着你必须退出或者隐藏当前的任务,这就是说,一般而言,手持设备上同时运行的任务不会太多,最重要的是,手持设备上一般都是直接面向特定的应用,持有者往往只是拿来直接娱乐而几乎不会去搞什么任务管理器之类的东西,另外如果真的到了oom的时候,用户体验就会非常差,因此决不能让手持设备oom,因此需要有一个系统的任务管理器一直在运行,时刻关注着内存情况,由于应用的屏幕独占性,内存低于临界值的时候,要选择杀掉不在前端的一个或几个进程,有了这种支持,程序本身和用户就不用操作内存的问题了,因此我们就会明白为何android的程序几乎都没有“退出”机制了,换句话说,android的进程不是自己退出的,而是被杀的。
     接下来,android的那个一直在运行的任务管理程序会如何选择杀哪些进程呢?策略和linux内核中的oom一样吗?肯定不同啦!android任务系统将所有的进程分成了下面几类:
foreground process:一些顶层容器之类的属于这种进程,比如主界面,这种进程一般不会被杀,除非oom,然而一般任务系统是不会到oom的,因为那时几乎用户都要把手机摔掉了。
visible process:当前的进程。这类进程关系到用户体验,一般也不会被杀掉。
service process:系统服务。这种进程默默耕耘而不露面,一般也不会被杀掉。
background process:已经到后面的进程,就是被前端的进程遮盖住的进程,一般而言这类进程最容易被杀,如果系统内存不吃紧,那么在用户点击别的程序或者点击主界面而将当前程序遮住之后,当前程序并不自己调用exit,而是继续保留,直到任务管理系统认为需要杀死它时才杀死它,这样既提高了效率(如果用户再次使用该程序的话不必重新初始化了),另外对于开发来讲也很省事,不用监控退出事件了,一切交给任务管理系统,只要一个桌面上的程序被点击,任务管理系统首先在全局的lru链表中找这个程序,找到的话则将它置于前端,找不到则启动它并将它加入到lru,当确定要杀进程的时候,从lru中的表头开始杀即可。
     将任务管理机制抽出来变成全局的机制,这完全是为了增强用户体验,用户仅为娱乐而用机器,不必再为资源管理等事自己动手,并且全局的任务管理机制也能在全局上明了当前的内存情况,不待oom则早已开始作为killer来动作了,用户始终感到内存是够用的,并且由于隐藏而不退出,退到后面的程序在没有被杀死之前再次运行之中又起到了cache的作用,增加了性能。进程的生命周期也不再仅仅受程序本身控制,而是受到整体内存情况的牵制和全局任务管理系统的直接控制。
     然而对于习惯于pc编程的我来讲,这种全局的进程控制机制还是不甚习惯,总感觉跛脚!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值