linux内核mem_cgroup浅析
memory cgroup
mem_cgroup是cgroup体系中提供的用于memory隔离的功能。
admin可以创建若干个mem_cgroup,形成一个树型结构。可以将进程加入到这些mem_cgroup中。(类似这样的管理功能都是由cgroup框架自带的。)
为了实现memory隔离,每个mem_cgroup主要有两个维度的限制:
1、res - 物理内存
2、memsw - memory + swap,物理内存 + swap
其中,memsw肯定是大于等于memory的。
另外注意,memory控制是针对于组的,而不是单个进程的。(当然,你也可以一个进程一个组。)
每个维度又有三个指标:
1、usage - 组内进程已经使用的内存
2、soft_limit - 非强制内存上限。usage超过这个上限后,组内进程使用的内存可能会被加快步伐进行回收
3、hard_limit - 强制内存上限。usage不能超过这个上限。如果试图超过,则会触发同步的内存回收过程,或者OOM(挑选并杀掉一个进程,以释放空间。见《linux页面回收浅析》)
其中,soft_limit和hard_limit是由admin在mem_cgroup的参数中进行配置的(soft_limit肯定是要小于hard_limit才能发挥其作用)。而usage则是由内核实时统计该组所使用的内存值。
mem_cgroup有hierarchy的概念。如果设置某个组的hierarchy为真,则其子组的计数会累加到它身上;而在它需要回收page时,也会尝试对子组进行回收;OOM时也会考虑杀掉子组中的进程;
反过来,如果hierarchy为假,则子组跟父组就是形同陌路的两个组了,仅仅在cgroup的层次结构上有父子关系,实则没有任何联系。计数、回收、OOM都是各顾各的。(另一个影响在于mem_cgroup的删除,下文会提到。)
一个mem_cgroup创建的时候总是继承其父组的hierarchy。
usage
讨论mem_cgroup,第一个问题就是:内存的usage如何统计,也就是如何对res/memsw的usage计数进行charge/uncharge。
首先,在mem_cgroup的内存统计逻辑中,有一个基本思想:一个page最多只会被charge一次,并且一般就charge在第一次使用这个page的那个进程所在的mem_cgroup上。
如果有多个mem_cgroup的进程引用同一个page,也只会有一个mem_cgroup为它埋单。
其次,uncharge往往是跟page的释放相对应的。这就意味着mem_cgroup为它不再使用的page埋单是正常现象。
一个进程引用了某个page,使其所在的mem_cgroup被charge;随后该进程不再引用这个page,不过这个page可能因为某种原因不能被释放,所以对应的mem_cgroup就不能得到uncharge。
page
那么对于usage的统计来说,当进程使用到新的page时,怎么知道这个page有没有charge过,是否应该charge相应的mem_cgroup呢?
而当进程释放page时,又需要知道这个page是由哪个mem_cgroup charge的,以便给它uncharge。
内核的做法是,给page安排一个指向mem_cgroup的指针,非NULL的指针表示这个page已经charge过了,而page释放时也可以通过该指针得知应该uncharge那个mem_cgroup。
不过实际上这个指向mem_cgroup的指针并不存在于page结构,而是在对应的page_cgroup结构中。
为了支持mem_cgroup,内核维护了一组跟page结构一一对应的page_cgroup,其主要成员为:
mem_cgroup - 指向一个mem_cgroup
lru - 链入mem_cgroup的lru(见后面对reclaim的讨论)
由此可知,设一个mem_cgroup-A的res计数为N,那么必有N个这样的page,其对应的page_cgroup->mem_cgroup指向mem_cgroup-A(或其子组)。
(理论上是这样,而实际会有所出入。见后面关于per-CPU的stock的讨论。)
swap
然后,关于swap呢?page的内容可能被swap-out到交换区,从而释放page。
可以想象,这将导致对应mem_cgroup的res计数得到uncharge,memsw计数不变。而当这个swap entry被释放时,memsw计数才能uncharge。
所以,swap entry也应该有一个类似于page_cgroup->mem_cgroup的指针,能够找到为它埋单的那个mem_cgroup。
类似的,swap entry会有一个与之对应的swap_cgroup结构,其主要成员为:
id - 对应mem_cgroup在cgroup体系中的id,通过它能够得到对应的mem_cgroup
由此可知,设一个mem_cgroup-B在cgroup体系中的id为id-B,其memsw计数为M。
那么必有I个这样的page,其对应的page_cgroup->mem_cgroup指向mem_cgroup-B(或其子组);和J个这样的swap entry,其对应的swap_cgroup->id为id-B(或其子组)。且M == I + J。
(理论上是这样,而实际会有所出入。见后面关于per-CPU的stock的讨论。)
相对应的情况是swap-in,这时会分配新的page,然后重新charge相应的mem_cgroup的res计数。这个要被charge的mem_cgroup怎么取得呢?其实并不是page_cgroup->mem_cgroup,而是swap_cgroup->id对应的mem_cgroup。因为swap-in时的这个page是重新分配出来的,已经不是当年swap-out时的那个page了(新的page里面会装上跟原来一样的内容,但是没人保证两个page是同一个物理页面),所以此时的page_cgroup->mem_cgroup是无意义的。当然,swap-in完成之后,新的page对应的page_cgroup->mem_cgroup会被赋值,指向swap_cgroup->id对应的mem_cgroup,而swap_cgroup则被回收掉。
mm owner
另外,一般我们会说某某进程使用了某些page。但是实际上,进程和page并不是直接联系的,而是:进程 => mm => page。也就是说,对物理内存的计数是跟mm相关的。
而mem_cgroup却是跟进程相关的(cgroup体系是按进程来分组的)。在一个mm上发生内存使用/释放时,需要找到对应的进程,再找到对应的mem_cgroup,然后charge/uncharge。
但问题是,mm到进程可能是一对多的关系,多个进程引用同一个mm(比如vfork产生的子进程、clone产生的线程、等)。如何定义mm应该对应哪个进程呢?
这里就用到了mm->owner的概念,每个mm有其对应的owner进程。fork时父进程将自己的mm copy一份给子进程,于是子进程拥有了自已的mm,它就是这个新mm的owner。
而如果是vfork、clone导致子进程共享父进程的mm时,mm的owner依然是父进程。而类似这样的子进程则不是任何mm的owner(将来可能是,比如evecve以后)。
于是,通过mm->owner就打通了page => mm => 进程 => mem_cgroup的路径。同时也意味着,对于那些不是任何mm的owner的进程,它们存在于哪个mem_cgroup其实是无关紧要的。
charge/uncharge
mem_cgroup统计的对象主要是用户空间使用的内存,分匿名映射(anon page)和文件映射(page cache)两种类型的page。而这两种page又存在swap的情况。
至于其他的内存,则是由内核空间使用的,不在统计之列。
下面就分别来看看这些page是如何计数的。
page cache
page cache的计数原则是:谁把page请进了page cache,对应的mem_cgroup就为此而charge。主要有这么几种情况:
1、read/write系统调用;
2、mmap文件之后,在对应区域进行内存读写;
3、伴随1和2两种情况产生的预读;
反之,当page被释放(一般就在它离开page cache之时),对应的mem_cgroup得以uncharge。主要有这么几种情况:
1、page回收算法将page cache中的page回收;
2、使用direct-io导致对应区域的page cache被释放;
3、类似/proc/sys/vm/drop_caches、fadvice(DONTDEED)这样的方式主动清理page cache;
4、类似文件truncate这样的事件造成对应区域的page cache被释放;
5、等等;
注意,使用direct-io方式进行read/write是不跟page cache打交道的,所以mem_cgroup也不会因此而charge。(当然,read/write需要一块buffer,这个是要charge好的。)
NOTICE:如果某个mem_cgroup内的进程访问了某些文件,从而填充了它们的page cache。那么这个mem_cgroup就成了冤大头,一直要等到page被从page cache里释放掉,才能uncharge。就算这个进程早已不再使用这些数据了。而与此同时,其他mem_cgoup的进程则可以免费使用这些page。所以,使用相同数据的进程应该尽可能划分到同一个mem_cgroup中。
page cache的swap情况。这主要涉及tmpfs和shm的逻辑,它们表面上看跟文件映射没什么两样,每个文件(或shmid)都有着自己的page cache,并且都可以按照文件的那一套逻辑来操作。
但它们却是完全基于内存的,并没有外设作为存储介质。当需要回收page的时候,只能swap。
swap-out,在page被释放时uncharge对应mem_cgroup的res计数,memsw计数不变:
a、page在离开page cache后并不会马上释放,而是先被移动到swap cache、然后swap到交换区、最后才能释放;
b、交换区是有大小限制的,如果分配swap entry不成功,则page不能被回收,依然放在page cache中;
c、直到page释放,才uncharge;
swap-in,在page重新回到page cache时charge:
a、page先被读入(或预读)swap cache,此时并没有charge操作;
b、随后,需要swap-in的page会从swap cache移动到page cache,此时对应mem_cgroup的charge;
c、而其他被预读进swap cache的page,并不会引起charge,也不会被移动到page cache,直到它真正需要swap-in时;
NOTICE:swap cache与page cache的不同。
两者都可能会有预读,但是swap cache里面的page只有当真正要使用的时候才会charge,而page cache只要读进cache就charge。
因为文件预读是为操作它的进程服务的,而swap预读则未必,交换区里的数据可能是离散的,属于不同的进程。
anon page
anon的计数原则是:谁分配了page,谁就为此而charge。主要有这么几种情况:
1、写一个未建立映射的属于匿名vma的虚拟内存时,page被分配,并建立映射;
2、写一个待COW的page时,新page被分配,并重新建立映射。这些待COW的page可能产生于如下场景:
a、读一个未建立映射的属于匿名vma的虚拟内存时,page不会被分配,而且将相应地址临时只读的映射到一个全0的特殊page,等待COW;
b、fork后,父子进程会共享原来的anon page,并且映射被更改为只读,等待COW;(在COW之前如果对page的引用已经减为1,则不需要分配新page,也就不需要再charge。)
c、private文件映射的page是以只读方式映射到page cache中的page,等待COW;(比较有趣的情况,新的page是anon的,而对应的vma还是映射到文件的。)
反之,当page被释放(一般在对它的映射完全撤销时),对应的mem_cgroup得以uncharge。主要有这么几种情况:
1、进程munmap掉一段虚拟内存,则对应的已经映射的page会被减引用,可能导致引用减为0而释放;(比如主动munmap、exit退出程序、等。)
NOTICE:如果父子进程不在同一个mem_cgroup,则对于fork后那些尚未COW的anon page来说,很可能是charge在父进程所对应的mem_cgroup上的。父进程就算撤销了映射,计数依然会算在它头上(直到page被释放)。而如果是因为父进程的写操作引发了COW,则新分配的page和老的page都要算在父进程头上。
不过子进程默认是跟父进程在同一个mem_cgroup的,除非刻意去移动它。
anon page可能被page回收算法swap掉,也会导致对应mem_cgroup的res计数uncharge。
swap-out,在page的最后一个映射被撤销时uncharge;
a、swap-out时,anon page会先放放置在swap cache上,然后对每一个映射它的进程进行unmap(前提是分配swap entry成功,否则不会swap-out);
b、在最后一个映射被撤销时进行uncharge;
c、映射撤销后,这个page可能还会呆在swap cache上,等待写回交换区(不过写不写回已经不影响mem_cgroup的计数了);
swap-in,在page的第一个映射建立时charge;
a、对swap page的缺页异常,以及由此触发的预读,将导致新page被分配,并放到swap cache,再从交换区读入数据;
b、新page被放到swap cache并不会导致对应mem_cgroup的charge;
c、等这个新page第一次被映射的时候,对应mem_cgroup才会charge;
NOTICE:对于共享的anon page,charge在第一次映射它的mem_cgroup上。如果swap-out,再被其他mem_cgroup的进程swap-in,则还是计在原来的mem_cgroup上。
因为swap-out后,原mem_cgroup的memsw计数是没有改变的,所以也不能因为swap-in而改变。
anon page被多个进程共享主要是fork()时父子进程共享这一种情况。
总的来说:
page cache里的page,charge/uncharge是以page加入/脱离page cache为准的;
anon page,charge/uncharge是以page的分配/释放为准的;
swap的page,charge/uncharge是以page被使用/未使用为准的;
reclaim
page回收的过程详见《linux页面回收浅析》。
page要被回收,首先是要加入到lru。区别于内核中早已经存在的全局lru,每个mem_cgroup都独自维护了一组lru。
mem_cgroup下的lru跟全局lru的构成是类似的,对于每个NUMA node下的每一个zone,会有一套lru。而lru又包含active_file、inactive_file、active_anon、inactive_anon、等若干个list。
page被加入到lru的时候,总是会找到自己所归属的NUMA node和zone,然后根据自身属性,加入其中一个lrulist。
上面提到的两种page都会被加入到全局的lru,如果它归属于某个mem_cgroup的话,也会被加入该mem_cgroup的lru。
一个page怎么加入两个lru呢?其实加入全局lru的是page,而加入mem_cgroup的lru的则是其对应的page_cgroup(前面已经介绍了page_cgroup有lru这么个成员)。
lru
总的来说,anon page和page cache都是在分配的时候分加入lru、释放前脱离lru。
anon page:
1、alloc => add_lru => del_lru => free
2、alloc => add_lru => add_to_swap_cache => del_from_swap_cache => del_lru => free
page cache:
1、alloc => add_lru => add_to_page_cache => del_from_page_cache => del_lru => free
2、alloc => add_lru => add_to_page_cache => add_to_swap_cache => del_from_page_cache => del_from_swap_cache => del_lru => free
而能够被swap的page,包括anon page和属于tmpfs/shm的page cache,总是加入anon对应的lrulist。其他的page cache中的page总是加入file对应的lrulist。
reclaim
reclaim有三条路径:
1、普通的reclaim流程(包括kswapd和内存紧缺时的主动回收)。
这个是视整个系统的内存使用情况而定的,有无mem_cgroup都一样。
注意,在普通的reclaim流程中同样可能回收掉属于某个mem_cgroup的page,从而导致对该mem_cgroup的uncharge。
2、普通的reclaim流程中额外会尝试对soft limit超额最多的几个mem_cgroup进行回收。
这里就是soft limit主要产生作用的地方。
3、在试图对mem_cgroup做charge的时候,如果hard_limit超额,会同步地对其进行页面回收,以便charge成功;
这三个回收过程走的基本上是同一个逻辑:扫描lru,将active链表中的一些老page移动到inactive链表、对inactive链表中的一些老page进行回收。
略有不同之处在于:
1、普通的回收流程关心的是全局的lru,而后两种则是关心特定mem_cgroup的lru;
2、按照lru的组织结构,在尝试回收一个mem_cgroup时,要先选定mem_cgroup => NUMA node => zone,才能得到一个lru:
A、mem_cgroup。如果设置了hierarchy,回收逻辑会在mem_cgroup自己及其子孙mem_cgroup间轮循一个进行回收。否则就只能回收自己;
B、NUMA node。hard limit超限时会轮循一个NUMA node;而soft limit超限时则是使用普通的reclaim流程所针对的NUMA node(比如分别有一个kswapd线程来对每一个NUMA node进行回收);
C、zone。hard limit超限时会对所有zone尝试进行回收;而soft limit超限时则是随普通的reclaim流程对需要reclaim的zone进行回收;
3、hard limit超限时可能存在no-swap逻辑,如果是memsw超限的话,swap-out是无意义的;
4、hard limit超限时一次回收过程可能无法释放足够的page,则继续进行回收(会轮循到不同的子mem_cgroup和NUMA node),最终回收无果还会进入oom逻辑;而soft limit超限时则没有回收数目的要求;
5、等等;
oom
就像内核在系统内存不足且回收无果的情况下会进入oom流程一样,在尝试charge超过hard limit情况下,如果同步的回收过程无法回收足够的page,也会进入oom流程。
当然,针对特定mem_cgroup的oom,只会挑选属于该mem_cgroup的进程来kill。
跟全局的oom一样,mem_cgroup的oom也分成select_bad_process和oom_kill_process两个过程:
1、select_bad_process找出该mem_cgroup下最该被kill的进程(如果mem_cgroup设置了hierarchy,也会考虑子mem_cgroup下的进程);
2、oom_kill_process杀掉选中的进程及与其共用mm的进程(杀进程的目的是释放内存,所以当然要把mm的所有引用都干掉);
其中还是有不少细节的:
1、select_bad_process认为谁最该死?
select_bad_process会给mem_cgroup(或及其子mem_cgroup)下的每个进程打一个分,得分最高者被选中。评分因素每个版本不尽相同,主要会考虑以下因素:
a、进程拥有page和swap entry越多,分得越高;
b、可以通过/proc/$pid/oom_score_adj进行一些分值干预;
c、拥有CAP_SYS_ADMIN的root进程分值会被调低;
不过我觉得既然是在mem_cgroup中,进程所在的mem_cgroup超出其soft_limit的比例也可以作为一个评分因素。YY一下:
d、如果进程所属的mem_cgroup的soft_limit超限,分值会按超限额增加一定比例的分值;
2、oom时机
oom是在同步的reclaim流程无法回收足够的page时触发的。但是reclaim流程无法继续回收,其实并不代表绝对的不可回收。
比如active的page、装有可执行代码的page、等都是尽量不要去回收的。
因为在一个上下文进行reclaim的时候,其他的上下文还各自在干其他的事情,无时不涉及内存的使用。
那么,如果你把能回收的page都回收了,随着其他上下文的运行又会把很多page恢复回来。其结果很可能最终还是没能回收到空间,却徒增了换入换出的开销。
所以,虽说oom是在内存回收无果时触发的,却也并非完全不能再回收。至于其中的“度”,也只能靠调试和经验来把握了。
3、oom过程同步
oom过程会向选中的进程发送SIGKILL进程。但是距离进程处理信号、释放空间,还是需要经历一定时间的。
如果系统负载较高,则这段时间内很可能有其他上下文也需要却得不到page,而触发新的oom。那么如果大量oom在短时间内爆发,可能会大面积杀死系统中的进程,带来一场浩劫。
所以oom过程需要同步:在给选中的进程发送SIGKILL后,会设置其TIF_MEMDIE标记。而在select_bad_process的过程中如果发现记有TIF_MEMDIE的进程,则终止当前的oom过程,并等待上一个oom过程结束。
这样做可以避免oom时大面积的kill进程,但是目前并没有保证每次oom只会kill一个进程(假设kill的这个进程已经能够释放足够的空间)。
因为在一个mem_cgroup下触发oom时,应该选择该mem_cgroup下的进程。而一个进程是否属于这个mem_cgroup,看的是mm->owner是否属于这个mem_cgroup。
而在进程退出时,会先将task->mm置为NULL,再mmput(mm)释放掉引用计数,从而导致内存空间被释放(如果引用计数减为0的话)。
所以,只要task->mm被置为NULL(内存即将开始释放),就没人认得它是属于哪个mem_cgroup的了,针对那个mem_cgroup的新的oom过程就可以开始。
others
config change
关于配置更改,mem_cgroup还有很多麻烦的事情需要处理,主要是涉及到mem_cgroup参数的调整以及进程的迁移:
1、hierarchy参数的调整
a、只有当父组的hierarchy为假时才能设置;
这就规定是继承关系的断代是不允许的。貌似实在不好定义断代了的继承关系该如何来处理。
b、只有当mem_cgroup没有子组只才能设置;
这个规定省去了很多麻烦。否则可以想象,hierarchy调整之后,整棵mem_cgroup子树上的计数都需要同步地进行调整。
2、进程在mem_cgroup之间移动
按理说,移动进程也是很麻烦的事情。对于进程所占有的page将在原来的mem_cgroup上uncharge,并在新的mem_cgroup上charge。不过这个逻辑默认是禁止的,也就是说,进程在mem_cgroup间移动,不会触发charge/uncharge。
也可以设置mem_cgroup的move_charge_at_immigrate参数来支持进程移动时的charge/uncharge行为。move_charge_at_immigrate是一个bitmap,bit-0代表anon和swap的行为、bit-1代表file的行为。
那么如何进行计数迁移呢?关键的问题是,移动的这个进程应该被认为带走了哪些page?注意,page的计数是跟mem_cgroup关联的,而跟进程没有直接关系。所以要判断一个进程应该带走哪些page,只能反过来,从进程的页表出发,看看它引用了哪些page(那么当然,如果没有mmu,也就不能支持)。另外,当然,需要计数迁移的page,其对应的page_cgroup->mem_cgroup一定是指向源mem_cgroup的。而迁移所需要做的事情就是charge目标mem_cgroup、uncharge源mem_cgroup、再修改page_cgroup->mem_cgroup指向目标mem_cgroup。具体哪些page应该发生计数迁移,大致的规则如下:
a、页表有引用:如果是映射数目为1的anon page,或是page cache,则计数迁移;
b、页表指向swap:如果是swap的引用数目为1,则计数迁移;
c、页表项为空:查看vma映射的文件位置上是否有page cache,有则计数迁移;
总的来说,判断条件比较暴力,page cache只要被该进程引用,则迁移;而anon和swap则在被且仅被该进程映射的情况下,才迁移。
3、mem_cgroup的删除
mem_cgroup能够被删除,有两个前提:
a、mem_cgroup下没有进程;
b、mem_cgroup没有子组;
删除时,属于该mem_cgroup的计数将被增加到其父组上、lru里面的page也会移动到父组的lru。(不管有没有设置hierarchy。)
既然mem_cgroup已经没有了进程,为什么还有计数呢?因为计数是基于mem_cgroup的,进程的退出并不意味着一定会uncharge所有的计数(它有很多当冤大头的机会)。
如果父组设置了hierarchy,则实际上并不会增加其计数(因为子组的计数已经在它头上charge过了)。
否则,父组charge,可能导致hard limit超限。这时可能触发同步的reclaim,但是并不会触发oom。而如果父组charge失败,则对子组的rmdir操作将返回-EBUSY。
如果希望干净地删掉一个子组,而避免将计数charge到父组上,则可以通过echo 0 > memory.force_empty将该组的计数清空。force_empty的前提也是mem_cgroup下没有进程也没有子组。force_empty将试图回收mem_cgroup下所有的page,如果有些page未能回收,则还是会将其charge到父组上。
stock cache
并非对于每个page的charge/uncharge都直接跟mem_cgroup的计数打交道,这样的话多个CPU可能带来不少的竞争。
解决办法是加一个per-CPU的cache,即每个CPU在需要charge的时候,先charge一个较大的数目(如32),则之后的charge操作就可能直接在本地完成。
这个cache就是memcg_stock_pcp,其主要成员有:一个指向mem_cgroup的指针和一个nr_pages计数。
也就是说,它只cache一个mem_cgroup的计数,如果下一次需要charge的mem_cgroup跟cache中的不同,则会将cache替换掉,而cache的计数也会随之uncharge。只cache一个mem_cgroup也已经足够了,因为同一个进程几乎总是跟一个mm打交道的,从而也只会影响到一个mem_cgroup的计数。
因为有这个cache的存在,有时候尝试charge超过hard limit限制可能并不是真正的超限,所以在进行同步的reclaim之前,会先将cache清空。
mem_cgroup是cgroup体系中提供的用于memory隔离的功能。
admin可以创建若干个mem_cgroup,形成一个树型结构。可以将进程加入到这些mem_cgroup中。(类似这样的管理功能都是由cgroup框架自带的。)
为了实现memory隔离,每个mem_cgroup主要有两个维度的限制:
1、res - 物理内存
2、memsw - memory + swap,物理内存 + swap
其中,memsw肯定是大于等于memory的。
另外注意,memory控制是针对于组的,而不是单个进程的。(当然,你也可以一个进程一个组。)
每个维度又有三个指标:
1、usage - 组内进程已经使用的内存
2、soft_limit - 非强制内存上限。usage超过这个上限后,组内进程使用的内存可能会被加快步伐进行回收
3、hard_limit - 强制内存上限。usage不能超过这个上限。如果试图超过,则会触发同步的内存回收过程,或者OOM(挑选并杀掉一个进程,以释放空间。见《linux页面回收浅析》)
其中,soft_limit和hard_limit是由admin在mem_cgroup的参数中进行配置的(soft_limit肯定是要小于hard_limit才能发挥其作用)。而usage则是由内核实时统计该组所使用的内存值。
mem_cgroup有hierarchy的概念。如果设置某个组的hierarchy为真,则其子组的计数会累加到它身上;而在它需要回收page时,也会尝试对子组进行回收;OOM时也会考虑杀掉子组中的进程;
反过来,如果hierarchy为假,则子组跟父组就是形同陌路的两个组了,仅仅在cgroup的层次结构上有父子关系,实则没有任何联系。计数、回收、OOM都是各顾各的。(另一个影响在于mem_cgroup的删除,下文会提到。)
一个mem_cgroup创建的时候总是继承其父组的hierarchy。
usage
讨论mem_cgroup,第一个问题就是:内存的usage如何统计,也就是如何对res/memsw的usage计数进行charge/uncharge。
首先,在mem_cgroup的内存统计逻辑中,有一个基本思想:一个page最多只会被charge一次,并且一般就charge在第一次使用这个page的那个进程所在的mem_cgroup上。
如果有多个mem_cgroup的进程引用同一个page,也只会有一个mem_cgroup为它埋单。
其次,uncharge往往是跟page的释放相对应的。这就意味着mem_cgroup为它不再使用的page埋单是正常现象。
一个进程引用了某个page,使其所在的mem_cgroup被charge;随后该进程不再引用这个page,不过这个page可能因为某种原因不能被释放,所以对应的mem_cgroup就不能得到uncharge。
page
那么对于usage的统计来说,当进程使用到新的page时,怎么知道这个page有没有charge过,是否应该charge相应的mem_cgroup呢?
而当进程释放page时,又需要知道这个page是由哪个mem_cgroup charge的,以便给它uncharge。
内核的做法是,给page安排一个指向mem_cgroup的指针,非NULL的指针表示这个page已经charge过了,而page释放时也可以通过该指针得知应该uncharge那个mem_cgroup。
不过实际上这个指向mem_cgroup的指针并不存在于page结构,而是在对应的page_cgroup结构中。
为了支持mem_cgroup,内核维护了一组跟page结构一一对应的page_cgroup,其主要成员为:
mem_cgroup - 指向一个mem_cgroup
lru - 链入mem_cgroup的lru(见后面对reclaim的讨论)
由此可知,设一个mem_cgroup-A的res计数为N,那么必有N个这样的page,其对应的page_cgroup->mem_cgroup指向mem_cgroup-A(或其子组)。
(理论上是这样,而实际会有所出入。见后面关于per-CPU的stock的讨论。)
swap
然后,关于swap呢?page的内容可能被swap-out到交换区,从而释放page。
可以想象,这将导致对应mem_cgroup的res计数得到uncharge,memsw计数不变。而当这个swap entry被释放时,memsw计数才能uncharge。
所以,swap entry也应该有一个类似于page_cgroup->mem_cgroup的指针,能够找到为它埋单的那个mem_cgroup。
类似的,swap entry会有一个与之对应的swap_cgroup结构,其主要成员为:
id - 对应mem_cgroup在cgroup体系中的id,通过它能够得到对应的mem_cgroup
由此可知,设一个mem_cgroup-B在cgroup体系中的id为id-B,其memsw计数为M。
那么必有I个这样的page,其对应的page_cgroup->mem_cgroup指向mem_cgroup-B(或其子组);和J个这样的swap entry,其对应的swap_cgroup->id为id-B(或其子组)。且M == I + J。
(理论上是这样,而实际会有所出入。见后面关于per-CPU的stock的讨论。)
相对应的情况是swap-in,这时会分配新的page,然后重新charge相应的mem_cgroup的res计数。这个要被charge的mem_cgroup怎么取得呢?其实并不是page_cgroup->mem_cgroup,而是swap_cgroup->id对应的mem_cgroup。因为swap-in时的这个page是重新分配出来的,已经不是当年swap-out时的那个page了(新的page里面会装上跟原来一样的内容,但是没人保证两个page是同一个物理页面),所以此时的page_cgroup->mem_cgroup是无意义的。当然,swap-in完成之后,新的page对应的page_cgroup->mem_cgroup会被赋值,指向swap_cgroup->id对应的mem_cgroup,而swap_cgroup则被回收掉。
mm owner
另外,一般我们会说某某进程使用了某些page。但是实际上,进程和page并不是直接联系的,而是:进程 => mm => page。也就是说,对物理内存的计数是跟mm相关的。
而mem_cgroup却是跟进程相关的(cgroup体系是按进程来分组的)。在一个mm上发生内存使用/释放时,需要找到对应的进程,再找到对应的mem_cgroup,然后charge/uncharge。
但问题是,mm到进程可能是一对多的关系,多个进程引用同一个mm(比如vfork产生的子进程、clone产生的线程、等)。如何定义mm应该对应哪个进程呢?
这里就用到了mm->owner的概念,每个mm有其对应的owner进程。fork时父进程将自己的mm copy一份给子进程,于是子进程拥有了自已的mm,它就是这个新mm的owner。
而如果是vfork、clone导致子进程共享父进程的mm时,mm的owner依然是父进程。而类似这样的子进程则不是任何mm的owner(将来可能是,比如evecve以后)。
于是,通过mm->owner就打通了page => mm => 进程 => mem_cgroup的路径。同时也意味着,对于那些不是任何mm的owner的进程,它们存在于哪个mem_cgroup其实是无关紧要的。
charge/uncharge
mem_cgroup统计的对象主要是用户空间使用的内存,分匿名映射(anon page)和文件映射(page cache)两种类型的page。而这两种page又存在swap的情况。
至于其他的内存,则是由内核空间使用的,不在统计之列。
下面就分别来看看这些page是如何计数的。
page cache
page cache的计数原则是:谁把page请进了page cache,对应的mem_cgroup就为此而charge。主要有这么几种情况:
1、read/write系统调用;
2、mmap文件之后,在对应区域进行内存读写;
3、伴随1和2两种情况产生的预读;
反之,当page被释放(一般就在它离开page cache之时),对应的mem_cgroup得以uncharge。主要有这么几种情况:
1、page回收算法将page cache中的page回收;
2、使用direct-io导致对应区域的page cache被释放;
3、类似/proc/sys/vm/drop_caches、fadvice(DONTDEED)这样的方式主动清理page cache;
4、类似文件truncate这样的事件造成对应区域的page cache被释放;
5、等等;
注意,使用direct-io方式进行read/write是不跟page cache打交道的,所以mem_cgroup也不会因此而charge。(当然,read/write需要一块buffer,这个是要charge好的。)
NOTICE:如果某个mem_cgroup内的进程访问了某些文件,从而填充了它们的page cache。那么这个mem_cgroup就成了冤大头,一直要等到page被从page cache里释放掉,才能uncharge。就算这个进程早已不再使用这些数据了。而与此同时,其他mem_cgoup的进程则可以免费使用这些page。所以,使用相同数据的进程应该尽可能划分到同一个mem_cgroup中。
page cache的swap情况。这主要涉及tmpfs和shm的逻辑,它们表面上看跟文件映射没什么两样,每个文件(或shmid)都有着自己的page cache,并且都可以按照文件的那一套逻辑来操作。
但它们却是完全基于内存的,并没有外设作为存储介质。当需要回收page的时候,只能swap。
swap-out,在page被释放时uncharge对应mem_cgroup的res计数,memsw计数不变:
a、page在离开page cache后并不会马上释放,而是先被移动到swap cache、然后swap到交换区、最后才能释放;
b、交换区是有大小限制的,如果分配swap entry不成功,则page不能被回收,依然放在page cache中;
c、直到page释放,才uncharge;
swap-in,在page重新回到page cache时charge:
a、page先被读入(或预读)swap cache,此时并没有charge操作;
b、随后,需要swap-in的page会从swap cache移动到page cache,此时对应mem_cgroup的charge;
c、而其他被预读进swap cache的page,并不会引起charge,也不会被移动到page cache,直到它真正需要swap-in时;
NOTICE:swap cache与page cache的不同。
两者都可能会有预读,但是swap cache里面的page只有当真正要使用的时候才会charge,而page cache只要读进cache就charge。
因为文件预读是为操作它的进程服务的,而swap预读则未必,交换区里的数据可能是离散的,属于不同的进程。
anon page
anon的计数原则是:谁分配了page,谁就为此而charge。主要有这么几种情况:
1、写一个未建立映射的属于匿名vma的虚拟内存时,page被分配,并建立映射;
2、写一个待COW的page时,新page被分配,并重新建立映射。这些待COW的page可能产生于如下场景:
a、读一个未建立映射的属于匿名vma的虚拟内存时,page不会被分配,而且将相应地址临时只读的映射到一个全0的特殊page,等待COW;
b、fork后,父子进程会共享原来的anon page,并且映射被更改为只读,等待COW;(在COW之前如果对page的引用已经减为1,则不需要分配新page,也就不需要再charge。)
c、private文件映射的page是以只读方式映射到page cache中的page,等待COW;(比较有趣的情况,新的page是anon的,而对应的vma还是映射到文件的。)
反之,当page被释放(一般在对它的映射完全撤销时),对应的mem_cgroup得以uncharge。主要有这么几种情况:
1、进程munmap掉一段虚拟内存,则对应的已经映射的page会被减引用,可能导致引用减为0而释放;(比如主动munmap、exit退出程序、等。)
NOTICE:如果父子进程不在同一个mem_cgroup,则对于fork后那些尚未COW的anon page来说,很可能是charge在父进程所对应的mem_cgroup上的。父进程就算撤销了映射,计数依然会算在它头上(直到page被释放)。而如果是因为父进程的写操作引发了COW,则新分配的page和老的page都要算在父进程头上。
不过子进程默认是跟父进程在同一个mem_cgroup的,除非刻意去移动它。
anon page可能被page回收算法swap掉,也会导致对应mem_cgroup的res计数uncharge。
swap-out,在page的最后一个映射被撤销时uncharge;
a、swap-out时,anon page会先放放置在swap cache上,然后对每一个映射它的进程进行unmap(前提是分配swap entry成功,否则不会swap-out);
b、在最后一个映射被撤销时进行uncharge;
c、映射撤销后,这个page可能还会呆在swap cache上,等待写回交换区(不过写不写回已经不影响mem_cgroup的计数了);
swap-in,在page的第一个映射建立时charge;
a、对swap page的缺页异常,以及由此触发的预读,将导致新page被分配,并放到swap cache,再从交换区读入数据;
b、新page被放到swap cache并不会导致对应mem_cgroup的charge;
c、等这个新page第一次被映射的时候,对应mem_cgroup才会charge;
NOTICE:对于共享的anon page,charge在第一次映射它的mem_cgroup上。如果swap-out,再被其他mem_cgroup的进程swap-in,则还是计在原来的mem_cgroup上。
因为swap-out后,原mem_cgroup的memsw计数是没有改变的,所以也不能因为swap-in而改变。
anon page被多个进程共享主要是fork()时父子进程共享这一种情况。
总的来说:
page cache里的page,charge/uncharge是以page加入/脱离page cache为准的;
anon page,charge/uncharge是以page的分配/释放为准的;
swap的page,charge/uncharge是以page被使用/未使用为准的;
reclaim
page回收的过程详见《linux页面回收浅析》。
page要被回收,首先是要加入到lru。区别于内核中早已经存在的全局lru,每个mem_cgroup都独自维护了一组lru。
mem_cgroup下的lru跟全局lru的构成是类似的,对于每个NUMA node下的每一个zone,会有一套lru。而lru又包含active_file、inactive_file、active_anon、inactive_anon、等若干个list。
page被加入到lru的时候,总是会找到自己所归属的NUMA node和zone,然后根据自身属性,加入其中一个lrulist。
上面提到的两种page都会被加入到全局的lru,如果它归属于某个mem_cgroup的话,也会被加入该mem_cgroup的lru。
一个page怎么加入两个lru呢?其实加入全局lru的是page,而加入mem_cgroup的lru的则是其对应的page_cgroup(前面已经介绍了page_cgroup有lru这么个成员)。
lru
总的来说,anon page和page cache都是在分配的时候分加入lru、释放前脱离lru。
anon page:
1、alloc => add_lru => del_lru => free
2、alloc => add_lru => add_to_swap_cache => del_from_swap_cache => del_lru => free
page cache:
1、alloc => add_lru => add_to_page_cache => del_from_page_cache => del_lru => free
2、alloc => add_lru => add_to_page_cache => add_to_swap_cache => del_from_page_cache => del_from_swap_cache => del_lru => free
而能够被swap的page,包括anon page和属于tmpfs/shm的page cache,总是加入anon对应的lrulist。其他的page cache中的page总是加入file对应的lrulist。
reclaim
reclaim有三条路径:
1、普通的reclaim流程(包括kswapd和内存紧缺时的主动回收)。
这个是视整个系统的内存使用情况而定的,有无mem_cgroup都一样。
注意,在普通的reclaim流程中同样可能回收掉属于某个mem_cgroup的page,从而导致对该mem_cgroup的uncharge。
2、普通的reclaim流程中额外会尝试对soft limit超额最多的几个mem_cgroup进行回收。
这里就是soft limit主要产生作用的地方。
3、在试图对mem_cgroup做charge的时候,如果hard_limit超额,会同步地对其进行页面回收,以便charge成功;
这三个回收过程走的基本上是同一个逻辑:扫描lru,将active链表中的一些老page移动到inactive链表、对inactive链表中的一些老page进行回收。
略有不同之处在于:
1、普通的回收流程关心的是全局的lru,而后两种则是关心特定mem_cgroup的lru;
2、按照lru的组织结构,在尝试回收一个mem_cgroup时,要先选定mem_cgroup => NUMA node => zone,才能得到一个lru:
A、mem_cgroup。如果设置了hierarchy,回收逻辑会在mem_cgroup自己及其子孙mem_cgroup间轮循一个进行回收。否则就只能回收自己;
B、NUMA node。hard limit超限时会轮循一个NUMA node;而soft limit超限时则是使用普通的reclaim流程所针对的NUMA node(比如分别有一个kswapd线程来对每一个NUMA node进行回收);
C、zone。hard limit超限时会对所有zone尝试进行回收;而soft limit超限时则是随普通的reclaim流程对需要reclaim的zone进行回收;
3、hard limit超限时可能存在no-swap逻辑,如果是memsw超限的话,swap-out是无意义的;
4、hard limit超限时一次回收过程可能无法释放足够的page,则继续进行回收(会轮循到不同的子mem_cgroup和NUMA node),最终回收无果还会进入oom逻辑;而soft limit超限时则没有回收数目的要求;
5、等等;
oom
就像内核在系统内存不足且回收无果的情况下会进入oom流程一样,在尝试charge超过hard limit情况下,如果同步的回收过程无法回收足够的page,也会进入oom流程。
当然,针对特定mem_cgroup的oom,只会挑选属于该mem_cgroup的进程来kill。
跟全局的oom一样,mem_cgroup的oom也分成select_bad_process和oom_kill_process两个过程:
1、select_bad_process找出该mem_cgroup下最该被kill的进程(如果mem_cgroup设置了hierarchy,也会考虑子mem_cgroup下的进程);
2、oom_kill_process杀掉选中的进程及与其共用mm的进程(杀进程的目的是释放内存,所以当然要把mm的所有引用都干掉);
其中还是有不少细节的:
1、select_bad_process认为谁最该死?
select_bad_process会给mem_cgroup(或及其子mem_cgroup)下的每个进程打一个分,得分最高者被选中。评分因素每个版本不尽相同,主要会考虑以下因素:
a、进程拥有page和swap entry越多,分得越高;
b、可以通过/proc/$pid/oom_score_adj进行一些分值干预;
c、拥有CAP_SYS_ADMIN的root进程分值会被调低;
不过我觉得既然是在mem_cgroup中,进程所在的mem_cgroup超出其soft_limit的比例也可以作为一个评分因素。YY一下:
d、如果进程所属的mem_cgroup的soft_limit超限,分值会按超限额增加一定比例的分值;
2、oom时机
oom是在同步的reclaim流程无法回收足够的page时触发的。但是reclaim流程无法继续回收,其实并不代表绝对的不可回收。
比如active的page、装有可执行代码的page、等都是尽量不要去回收的。
因为在一个上下文进行reclaim的时候,其他的上下文还各自在干其他的事情,无时不涉及内存的使用。
那么,如果你把能回收的page都回收了,随着其他上下文的运行又会把很多page恢复回来。其结果很可能最终还是没能回收到空间,却徒增了换入换出的开销。
所以,虽说oom是在内存回收无果时触发的,却也并非完全不能再回收。至于其中的“度”,也只能靠调试和经验来把握了。
3、oom过程同步
oom过程会向选中的进程发送SIGKILL进程。但是距离进程处理信号、释放空间,还是需要经历一定时间的。
如果系统负载较高,则这段时间内很可能有其他上下文也需要却得不到page,而触发新的oom。那么如果大量oom在短时间内爆发,可能会大面积杀死系统中的进程,带来一场浩劫。
所以oom过程需要同步:在给选中的进程发送SIGKILL后,会设置其TIF_MEMDIE标记。而在select_bad_process的过程中如果发现记有TIF_MEMDIE的进程,则终止当前的oom过程,并等待上一个oom过程结束。
这样做可以避免oom时大面积的kill进程,但是目前并没有保证每次oom只会kill一个进程(假设kill的这个进程已经能够释放足够的空间)。
因为在一个mem_cgroup下触发oom时,应该选择该mem_cgroup下的进程。而一个进程是否属于这个mem_cgroup,看的是mm->owner是否属于这个mem_cgroup。
而在进程退出时,会先将task->mm置为NULL,再mmput(mm)释放掉引用计数,从而导致内存空间被释放(如果引用计数减为0的话)。
所以,只要task->mm被置为NULL(内存即将开始释放),就没人认得它是属于哪个mem_cgroup的了,针对那个mem_cgroup的新的oom过程就可以开始。
others
config change
关于配置更改,mem_cgroup还有很多麻烦的事情需要处理,主要是涉及到mem_cgroup参数的调整以及进程的迁移:
1、hierarchy参数的调整
a、只有当父组的hierarchy为假时才能设置;
这就规定是继承关系的断代是不允许的。貌似实在不好定义断代了的继承关系该如何来处理。
b、只有当mem_cgroup没有子组只才能设置;
这个规定省去了很多麻烦。否则可以想象,hierarchy调整之后,整棵mem_cgroup子树上的计数都需要同步地进行调整。
2、进程在mem_cgroup之间移动
按理说,移动进程也是很麻烦的事情。对于进程所占有的page将在原来的mem_cgroup上uncharge,并在新的mem_cgroup上charge。不过这个逻辑默认是禁止的,也就是说,进程在mem_cgroup间移动,不会触发charge/uncharge。
也可以设置mem_cgroup的move_charge_at_immigrate参数来支持进程移动时的charge/uncharge行为。move_charge_at_immigrate是一个bitmap,bit-0代表anon和swap的行为、bit-1代表file的行为。
那么如何进行计数迁移呢?关键的问题是,移动的这个进程应该被认为带走了哪些page?注意,page的计数是跟mem_cgroup关联的,而跟进程没有直接关系。所以要判断一个进程应该带走哪些page,只能反过来,从进程的页表出发,看看它引用了哪些page(那么当然,如果没有mmu,也就不能支持)。另外,当然,需要计数迁移的page,其对应的page_cgroup->mem_cgroup一定是指向源mem_cgroup的。而迁移所需要做的事情就是charge目标mem_cgroup、uncharge源mem_cgroup、再修改page_cgroup->mem_cgroup指向目标mem_cgroup。具体哪些page应该发生计数迁移,大致的规则如下:
a、页表有引用:如果是映射数目为1的anon page,或是page cache,则计数迁移;
b、页表指向swap:如果是swap的引用数目为1,则计数迁移;
c、页表项为空:查看vma映射的文件位置上是否有page cache,有则计数迁移;
总的来说,判断条件比较暴力,page cache只要被该进程引用,则迁移;而anon和swap则在被且仅被该进程映射的情况下,才迁移。
3、mem_cgroup的删除
mem_cgroup能够被删除,有两个前提:
a、mem_cgroup下没有进程;
b、mem_cgroup没有子组;
删除时,属于该mem_cgroup的计数将被增加到其父组上、lru里面的page也会移动到父组的lru。(不管有没有设置hierarchy。)
既然mem_cgroup已经没有了进程,为什么还有计数呢?因为计数是基于mem_cgroup的,进程的退出并不意味着一定会uncharge所有的计数(它有很多当冤大头的机会)。
如果父组设置了hierarchy,则实际上并不会增加其计数(因为子组的计数已经在它头上charge过了)。
否则,父组charge,可能导致hard limit超限。这时可能触发同步的reclaim,但是并不会触发oom。而如果父组charge失败,则对子组的rmdir操作将返回-EBUSY。
如果希望干净地删掉一个子组,而避免将计数charge到父组上,则可以通过echo 0 > memory.force_empty将该组的计数清空。force_empty的前提也是mem_cgroup下没有进程也没有子组。force_empty将试图回收mem_cgroup下所有的page,如果有些page未能回收,则还是会将其charge到父组上。
stock cache
并非对于每个page的charge/uncharge都直接跟mem_cgroup的计数打交道,这样的话多个CPU可能带来不少的竞争。
解决办法是加一个per-CPU的cache,即每个CPU在需要charge的时候,先charge一个较大的数目(如32),则之后的charge操作就可能直接在本地完成。
这个cache就是memcg_stock_pcp,其主要成员有:一个指向mem_cgroup的指针和一个nr_pages计数。
也就是说,它只cache一个mem_cgroup的计数,如果下一次需要charge的mem_cgroup跟cache中的不同,则会将cache替换掉,而cache的计数也会随之uncharge。只cache一个mem_cgroup也已经足够了,因为同一个进程几乎总是跟一个mm打交道的,从而也只会影响到一个mem_cgroup的计数。
因为有这个cache的存在,有时候尝试charge超过hard limit限制可能并不是真正的超限,所以在进行同步的reclaim之前,会先将cache清空。