1.1 Oom
1.1.1 简介
Oom的全称是out-of-memory,是内核在处理系统内存不足而又回收无果的情况下采取的一种措施,内核会经过选择杀死一些进程,以释放一些内存,满足当前内存申请的需求。所以oom是一种系统行为,对应到memcg的oom,其原理和动机跟全局oom是一样的,区别只在于对象的不同,全局oom的对象是整个系统中所有进程,而memcg oom只针对memcg中的进程(如果使能了hierarchy,还包括所有子memcg中的进程),这里的对象主要是指oom时内核选择从哪些进程中杀死一些进程,所以memcg的oom只可能杀死属于该memcg的进程。
1.1.2 实现
跟全局oom一样,memcg的oom也分成select_bad_process和oom_kill_process两个过程:
a. select_bad_process找出该memcg下最该被kill的进程(如果memcg设置了hierarchy,也会考虑子memcg下的进程);
b. oom_kill_process杀掉选中的进程及与其共用mm的进程(杀进程的目的是释放内存,所以当然要把mm的所有引用都干掉);
对于实现的代码细节,不同的版本代码演进较快,之前memcg中会直接调用内核的select_bad_process和oom_kill_process,在最新的3.10中,memcg实现了自己的select_bad_process,即在memcg的代码中自己来找到要杀死的进程。虽然函数调用不同,但是找到要杀死的进程的原理都是类似的,select的过程会给memcg(或及其子memcg)下的每个进程打一个分,得分最高者被选中。评分因素每个版本不尽相同,主要会考虑以下因素:
a. 进程拥有page和swap entry越多,分得越高;
b. 可以通过/proc/$pid/oom_score_adj进行一些分值干预;
c. 拥有CAP_SYS_ADMIN的root进程分值会被调低;
kill的过程比较简单,简单的说就是向要杀死的进程发送SIGKILL信号,但其中依然有一些细节:
a. 如果被选中的进程有一些子进程跟他不共用同一个mm,并且也是可以被杀死的,那么就挑选这些子进程中badness得分最高的一个来代替父进程被杀死,这样是为了确保我们在释放内存的同时失去更少的东西;
b. 上面已经说了,oom_kill的过程会杀死选中的进程及与其共用mm的进程,所以会遍历所有用户态进程,找到并杀死与选中进程共用同一个mm的进程;
c. 遍历进程的过程中,会过滤掉通过/proc/$pid/oom_score_adj干预的不可被oom_kill掉的进程(目前是设置为OOM_SCORE_ADJ_MIN的进程);
在oom的过程中,另外值得一说的是其中的同步过程。
oom过程会向选中的进程发送SIGKILL信号,但是距离进程处理信号、释放空间,还是需要经历一定时间的。如果系统负载较高,则这段时间内很可能有其他上下文也需要却得不到page,而触发新的oom。那么如果大量oom在短时间内爆发,可能会大面积杀死系统中的进程,带来一场浩劫。
所以oom过程需要同步:在给选中的进程发送SIGKILL后,会设置其TIF_MEMDIE标记。而在select被杀死进程的过程中如果发现记有TIF_MEMDIE的进程,则终止当前的oom过程,并等待上一个oom过程结束。这样做可以避免oom时大面积的kill进程。
而在进程退出时,会先将task->mm置为NULL,再mmput(mm)释放掉引用计数,从而导致内存空间被释放(如果引用计数减为0的话)。所以,只要task->mm被置为NULL(内存即将开始释放),就没人认得它是属于哪个memcg的了,针对那个memcg的新的oom过程就可以开始。
1.1.3 关闭oom
从oom的实现可知,虽然在oom的设计中已经考虑的各种情况,并采取了各种措施来让oom在杀死进程释放内存的同时对系统造成最小的伤害,并且这种努力和改进一直在继续。但系统的环境复杂多变,内核永远无法完美的识别某个进程对系统的重要性,在实际应用中,oom常常会造成未知的影响,所以我们有时会希望可以关闭oom,而memcg也正好提供了这样的功能。
在memcg的用户态目录下,memory.oom_control文件提供了这样的接口,通过查看该文件:
其中显示了两个字段,oom_kill_disable表示是否使能了该memcg的oom_kill,1为使能,0为关闭;而under_oom则表示当前memcg是否在oom过程中。
该文件的特殊性在于其显示的内容和写入的内容不同,对该文件,我们只可以写入0或1,表示关闭或开启该memcg的oom。当写入0关闭oom时,当某个进程遇到上面说的内存不足而又回收无果的情况下,内核会将该进程放到memcg的oom等待队列中,休眠该进程,等到有足够的内存可以使用时,再唤醒该进程。这样就可以避免出现进程被意外杀死的情况,但是这么做的话,需要确保你的进程可以接受睡眠。
对于全局的oom,并没有提供关闭的功能,这是因为全局的oom可能在任何情况下触发,比如中断上下文等,在这些情况下是不能睡眠的,否则可能造成比杀死某个进程更严重的后果。
1.2 Kmem
1.2.1 概述
Kmem controller是对mem controller的补充,原来的memcg只能对用户程序申请的用户态内存进行统计和管理,kmem只有网络tcp部分的支持,最近几个版本才加入了kmem controller的支持,彻底支持了对内核内存的统计和管理。但截止目前(3.12),kmem controller还是有很多不完善,还不建议在商用中使用。
因为kmemcontroller是在memcg的基础上加上的一个相对独立的特性,所以本文用一种新的方式,即通过分析完整加入整个kmem controller的patchset,选取最重要的几个部分,只要大家对整个kmem controller是如何一步一步实现的,就可以对整个kmem controller的实现及原理有了清晰的认识。
阅读本节需要对原来的memcg实现有一定的了解,同时对内核的slab/slub机制有一定的了解,因为kmem controller的本质就是让每个memcg管理自己的slab/slub。(本文分析的代码基于主线3.12)
1.2.2 实现
完整的patch在:https://lkml.org/lkml/2012/11/1/171
原始patchset一共29个,但本文只会选取其中最主要的几个进行分析,通过这些patch将会了解整个kmemcontroller的实现步骤和原理。
一.实现kmem accounting的基本框架
Upstream commit: 510fc4e11b772fd60f2c545c64d4c55abd07ce36
[04/29] memcg: kmem accounting basic infrastructure
主要是在mem_cgroup中增加了struct res_counter kmem,同时增加了kmem统计和限制相关的cgroup文件及其处理函数,在这部分的处理上跟原来mem cgroup中的处理是完全一样的,只是对象换成了kmem。
另外,这里需要说明的是kmem cgroup设计的统计方法:所有统计在kmem counter上的内存同时也会统计在mem counter上,所以在设置内存限制时,如果要实现对kmem的限制,那么只有将memory.kmem.limit_in_bytes设置成小于memory.limit_in_bytes才是有意义的。
二.实现kmem controller的基本框架
Upstream commit: 7ae1e1d0f8ac2927ed7e3ca6d15e42d485903459
[06/29] memcg: kmem controller infrastructure
这个patch很关键,它实现了kmemcontroller的核心框架。
即对memcg中使用的内核内存进行统计跟踪,只要进程在某个cgroup内(非root cgroup),而且内存申请中有__GFP_KMEMCG标志,该patch引入的controller框架就会对申请的内存进行统计(利用上一步加入的kmem account),该框架主要实现了三个接口:
1. memcg_kmem_newpage_charge
如果cgroup可以满足内存申请(还没有超过上限),则会更新相应的account,并返回true,此时page还没有被分配。
这个patch只是加了这个接口,后续该接口会在具体的地方被调用,目前只有在伙伴算法的核心函数__alloc_pages_nodemask中被调用。需要注意的是,在有一些情况下,对kmem的申请是不会统计进来的,目前有:
a. 申请时没有加__GFP_KMEMCG标记;
b. 申请时加了__GFP_NOFAIL标记;(因为加了该标记即使charge失败了内存申请也要继续,所以这里的策略是干脆不统计了,该策略并不完美,后续可能会修改)
c. 在中断上下文中;
d. 当前进程是线程组的线程(!current->mm);
e. 当前线程是内核线程(current->flags & PF_KTHREAD);
所以即使把内核线程加入cgroup中,它们使用的kmem,也是不会统计进来的。
2. memcg_kmem_commit_charge
如果申请内存失败,这里会执行计数的恢复操作,如果内核申请成功了,就会绑定该page和cgroup的关系(设置该page对应的page_cgroup的mem_cgroup成员指向进程所在memcg,同时更新page_cgroup的标记,说明该page已经在某个cgroup中了);
charge和commit_charge是在一起用的,目前都只在__alloc_pages_nodemask函数中用,即先charge,成功后再申请内存,然后commit_charge。
3. memcg_kmem_uncharge_pages
在free的时候调用,主要做计数的恢复操作。
三.实现slab/slub跟memcg的关系
Upstream commit: ba6c496ed834a37a26fc6fc87fc9aecb0fa0014d
[15/29] slab/slub: struct memcg_params
这个patch很小,但它建立了slab/slub跟memcg的关系,上面两个patch都只是计数和控制的框架,还没有具体的对象让这个框架来操作,而它操作的对象正是memcg中对应的slab/slub,建立这个联系后,后面才会一点点把操作对象加进来。
该patch加入了这个结构(patch本身加入的该结构体要简单很多,这个是3.12代码中最新的结构体),kmem_cache结构体中增加了指向该结构体的成员。参考附录了解slub的基本机构,加入kmem controller后,跟原来相比的变化是:
1. 原来一类object对应一个kmem_cache,现在同一类object会有多个kmem_cache,其中原来的kmem_cache现在作为root cache,其他的kmem_cache是每个memcg对应一个,都作为root cache的child cache;
这里很重要的是引入了rootcache的概念,可以简单的理解,即在加入kmem controller之前,原来内核中所有的kmem_cache,在加入kmem controller之后,这些kmem cache都变成了root cache,而之后为每个memcg创建的kmem_cache,都是相应的kmem_cache的child cache;
2. 这些新加入的kmem_cache也会加入到slab_caches链表中(同一个memcg中使用的kmem_cache也会通过memcg_cache_params->list链接在一起),所以相对原来,内核中kmem_cache的数量将大大增加,不过memcg中的kmem_cache只有在使能了kmem.limit_in_bytes,且执行了slab内存申请的操作,才会创建具体的kmem_cache。
对于这些关系,我们常常会利用数据结构间的联系做一些查找,比如
a. 通过kmem_cache找到对应的memcg:
Kmem_cache --> 找到 memcg_cache_params --> 找到memcg。
b. 通过root cache和memcg找到对应的kmem_cache:
Root cache --> 找到 memcg_cache_params,加上memcg的id -->memcg对应的kmem_cache。
为了实现上面提到的一些功能,patchset中其他一些patch辅助实现了一些函数,比如创建memcg对应的kmem_cache,将对应的kmem_cache与memcg建立联系等,这些patch不在本文一一列出。
四.实现memcg_kmem_get_cache接口
Upstream commit: d7f25f8a2f81252d1ac134470ba1d0a287cf8fcd
[19/29] memcg: infrastructure to match an allocation to theright cache
实现了memcg_kmem_get_cache接口,即某进程在尝试获取某kmem_cache时,获取到的是该进程所在cgroup的kmem_cache。其中需要注意的是:当某个cgroup中的进程第一次获取某kmem_cache时,返回的还是root kmem_cache,同时将创建该cgroup对应的kmem_cache的任务加入work queue中异步进行,下次获取就可以拿到cgroup对应的kmem_cache。
另外,在创建cgroup对应的kmem_cache后,需要维护该kmem_cache的数据关系,比如对应的memcg_cache_params,以及跟root kmem_cache间的指向关系等,这个操作放到kmem_cache_create_memcg中,通过memcg_register_cache函数实现。
这一步跟第三步中提到的一些,如创建memcg对应的kmem_cache,这些patch进一步为memcg中的进程使用自己的slab/slub做了准备。
五.实现slab/slub中内存分配的memcg适配
Upstream commit: d79923fad95b0cdf7770e024677180c734cb7148
[22/29] sl[au]b: allocate objects from memcg cache
这里正式将slab/slub中的内存管理跟memcg对接了起来。
主要有两个地方:
1. kmalloc正常走__kmalloc时在__cache_alloc时,将传入的kmem_cache重新通过memcg_kmem_get_cache获取到memcg中的kmem_cache。
2. 如果kmalloc的内存过大,走kmalloc_large到伙伴系统分配内存时,在内存分配标记中增加__GFP_KMEMCG,这样可以将分配的内存统计到相应的memcg中。
至此,在slab/slub的内存分配中使用了上面各个步骤添加的一系列接口,在内存分配时,如果使能了kmemcontroller,且申请内存的进程在非rootcgroup内,则在通过传入的size获取到对应的kmem_cache后,调用会调用接口memcg_kmem_get_cache通过该root cache获取到对应的memcg使用的kmem_cache(如果是第一次申请该kmem_cache中的object,则返回的还是该root cache,然后将创建该memcg对应的该kmem_cache的操作放到work queue中,下次就可以直接获取到该memcg对应的该kmem_cache);如果分配的内存过大,不走slab/slub,则会在内存分配的flag中增加__GFP_KMEMCG,直接通过伙伴系统分配内存,并将内存计数统计到memcg对应的kmem account中。(在走slab/slub时,在创建memcg对应的kmem_cache时,分配的内存也是从伙伴系统中得到,这些内存也已经统计到memcg对应的kmem account中了)所以我们就可以对memcg中使用的内核内存进行计数和控制,实现了kmem controller。
1.2.3 现状
Kmem controller的特性是在2012年底加入upstream的,但之后该特性的开发者转去做其他的项目,一直到现在(2013年底),kmem controller的相关代码并没有什么改变和发展,实际上目前的代码并不完善,还不能真正应用到实际项目中,因为目前对memcg中进程使用的kmem,还只是简单的统计,如果超过了限制的上限,对于分配kmem的请求会直接返回失败。对于memcg使用的kmem的内存回收,还依赖全局的内存回收机制(memcg的内存回收机制还只能回收用户态内存)。所以要实现完整的kmemcontroller,我们还需要memcg实现per memcg的kmem reclaim机制。
目前,已经有人在做这个事了,正在review中,在不久的将来,kmemcontroller应该会逐步完善。
2 附录
2.1 Slub结构图
1. 内核中所有的slab通过slab_caches链接起来;
2. 每个kmem_cache结构代表一类object对象,比如inode等;
3. Kmem_cache_cpu存放当前cpu使用的cache对象(避免不同cpu同时取partial链表上对象时导致的竞争问题和加锁导致的开销);
4. Kmem_cache_node存放针对不同内存节点的对象;
5. Kmem_cache_node下的partial链表链接了可用的page,page中包含了最终使用的object;
(更多slab/slub信息请参考其他相关资料)