1 概述
1.1 应用背景
Cgroup的memory子系统,即memory cgroup(本文以下简称memcg),提供了对系统中一组进程的内存行为的管理,从而对整个系统中对内存有不用需求的进程或应用程序区分管理,实现更有效的资源利用和隔离。
在实际业务场景中,为了防止一些应用程序对资源的滥用(可能因为应用本身的bug,如内存泄露),导致对同一主机上其他应用造成影响,我们往往希望可以控制应用程序的内存使用量,这是memcg提供的主要功能之一,当然它还可以做的更多。
Memcg的应用场景,往往来自一些虚拟化的业务需求,所以memcg往往作为cgroup的一个子系统与容器方案一起应用。在容器方案中,与一般的虚拟化方案不同,memcg在管理内存时,并不会在物理内存上对每个容器做区分,也就是说所有的容器使用的是同一个物理内存(有一种例外情况,如果存在多个内存节点,则可以通过cgroup中的cpuset子系统将不同的内存节点应用到不同的容器中)。对于共用的物理内存,memcg也不会对不同的容器做物理页面的预分配,也就是说同一个内存page,可能会被容器A使用,也可能被容器B使用。
所以memcg应用在容器方案中,虽然没有实现真正意义上的内存虚拟化,但是通过内核级的内存管理,依然可以实现某种意义上的虚拟化的内存管理,而且是真正的轻量级的。
(注:本文的功能及代码基于Linux 3.4)
1.2 功能简介
Memcg的主要应用场景有:
a. 隔离一个或一组应用程序的内存使用
对于内存饥渴型的应用程序,我们可以通过memcg将其可用内存限定在一定的数量以内,实现与其他应用程序内存使用上的隔离。
b. 创建一个有内存使用限制的控制组
比如在启动的时候就设置mem=XXXX。
c. 在虚拟化方案中,控制虚拟机的内存大小
比如可应用在LXC的容器方案中。
d. 确保应用的内存使用量
比如在录制CD/DVD时,通过限制系统中其他应用可以使用的内存大小,可以保证录制CD/DVD的进程始终有足够的内存使用,以避免因为内存不足导致录制失败。
e. 其他
各种通过memcg提供的特性可应用到的场景。
为了支撑以上场景,这里也简单列举一下memcg可以提供的功能特性:
a. 统计anonymous pages, file caches, swap caches的使用并限制它们的使用;
b. 所有page都链接在per-memcg的LRU链表中,将不再存在global的LRU;
c. 可以选择统计和限制memory+swap的内存;
d. 对hierarchical的支持;
e. Soft limit;
f. 可以选择在移动一个进程的时候,同时移动对该进程的page统计计数;
g. 内存使用量的阈值超限通知机制;
h. 可以选择关闭oom-killer,并支持oom的通知机制;
i. Root cgroup不存在任何限制;
2 总体设计
2.1 Memcg
Memcg在cgroup体系中提供memory隔离的功能,它跟cgroup中其他子系统一样可以由admin创建,形成一个树形结构,可以将进程加入到这些memcg中管理。
Memcg设计的核心是一个叫做res_counter的结构体,该结构体跟踪记录当前的内存使用和与该memcg关联的一组进程的内存使用限制值,每个memcg都有一个与之相关的res_counter结构。
而mem_cgroup结构体中通常会有两个res_counter结构,这是因为为了实现memory隔离,每个memcg主要要有两个维度的限制:
a. Res – 物理内存;
b. Memsw – memory + swap,即物理内存 + swap内存;
其中,memsw肯定是大于等于memory的。
另外,从res_counter结构中可以看出,每个维度又有三个指标:
a. Usage – 组内进程已经使用的内存;
b. Soft_limit – 软限制,非强制内存上限,usage超过这个上限后,组内进程使用的内存可能会加快步伐进行回收;
c. Hard_limit – 硬限制,强制内存上限,usage不能超过这个上限,如果试图超过,则会触发同步的内存回收,或者触发OOM(详见OOM章节)。
其中,soft_limit和hard_limit都是admin在memcg的配置文件中进行配置的(soft_limit必须要小于hard_limit才能发挥作用),hard_limit是真正的内存限制,soft_limit只是为了实现更好的内存使用效果而做的辅助,而usage则是内核实时统计该组进程内存的使用值。
对于统计功能的实现,可以用一个简单的图表示:
概括为三点:
a. 统计针对每一个cgroup进行;
b. 每个cgroup中的进程,它的mm_struct知道自己属于哪个cgroup;
c. 每个page对应一个page_cgroup,而page_cgroup知道自己属于哪个memcg;
而整个统计和限制的实现过程可简单描述为:
某进程在需要统计的地方调用mem_cgroup_charge()来进行必要的结构体设置(增加计数等),判断增加计数后进程所在的cgroup的内存使用是否超过限制,如果超过了,则触发reclaim机制进行内存回收,如果回收后依然超过限制,则触发oom或阻塞机制等待;如果增加计数后没有超过限制,则更新相应page对应的page_cgroup,完成统计计数的修改,并将相应的page放到对应的LRU中进行管理。
该过程中涉及的各种实现细节,将在后面的章节进行分解描述。
2.2 Page & swap
讨论memcg,必然涉及对内存的管理和统计,而这些操作都是针对page来做的,所以一些针对page的设计需要先理清楚。
首先,在memcg的内存统计逻辑中,有几个基本思想:
a. 一个page最多只会被charge一次,并且一般就charge在第一次使用这个page的那个进程所在的memcg上。
b. 如果有多个memcg的进程引用了同一个page,该page也只会被统计在一个memcg中。
c. Unchage往往跟page的释放相对应,所以可能存在某个进程不再使用某个page,但是对该page的统计还是记录在进程所在的memcg中,因为可能还有其他memcg中的进程在使用这个page,只要page无法释放,memcg就无法unchage。
那么对于usage的统计来说,当进程使用到新的page时,怎么知道这个page有没有chage过,是否该chage相应的memcg呢?而当进程释放page时,又需要知道这个page是由哪个memcg chage的,以便给它uncharge呢?
内核的做法是,给page安排一个指向memcg的指针,非NULL的指针表示这个page已经charge过了,而page释放时也可以通过该指针得知应该uncharge哪个memcg。不过实际上这个指向memcg的指针并不存在于page结构中,而是在对应的page_cgroup结构中:
Page_cgroup:每个page对应一个,跟page结构体一样,在系统启动的时候或内存热插入的时候分配,在内存热拔除的时候释放。
然后,关于swap呢?page的内容可能被swap-out到交换区,从而释放page。
可以想象,这将导致对应memcg的res计数得到uncharge,memsw计数保持不变。而当这个swap entry被释放时,memsw计数才能uncharge。
所以,swap entry也应该有一个类似于page_cgroup->mem_cgroup的指针,能够找到它是被统计到哪个memcg中的。
类似的,swap entry会有一个与之对应的swap_cgroup结构:
Swap_cgroup:每个对应一个swp_entry,在swapon()的时候分配,swapoff()的时候释放。
其中的id是对应memcg在cgroup体系中的id,通过它可以找到对应的memcg。
而在swap-in的时候,这时会分配新的page,然后重新charge相应的memcg的res计数。这个要被charge的memcg怎么取得呢?其实并不是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则被回收掉。
2.3 Hierarchy
2.3.1 简介
Hierarchy是cgroup对子系统层级支持提出的概念,一个cgroup文件系统挂载点形成一个hierarchy,在该挂载点下创建的cgroup及其子cgroup都属于同一个hierarchy,该hierarchy可以附加多个子系统。对于hierarchy,有几个基本规则:
a. 规则1:一个或多个Subsystem只能附加到一个hierarchy中;
如下图所示:CPUSubsystem无法附加到2个不同hierarchy。
b. 规则2:一个hierarchy可以附加一个或多个subsystem;
如下图所示,cpu和memory subsystem可以同时附加到hierarchy中。
c. 规则3:在系统创建新hierarchy时,系统中所有任务都是这个hierarchy的默认cgroup的初始成员。对于单一hierarchy来说,系统中每个任务都可以是该hierarchy的唯一一个cgroup的成员。单一任务可以在多个cgroup中,每个cgroup都在不同的hierarchy,如果任务成为同一hierarchy中第二个cgroup的成员,将会从该层级中第一个cgroup中删除,一个任务永远不会同时位于同一hierarchy的不同cgroup中。
如下图所示:进程httpd可以在hierarchyA和hierarchyB中同时存在,但是不能在hierarchyA中存在多个。
2.3.2 Memcg的hierarchy
对于memcg,作为一个cgroup的subsystem,它遵循hierarchy的所有规则,另外,对于hierarchy中cgroup的层级对memcg管理规则的影响,主要分两方面:
1、 如果不启用hierarchy,即mem_cgroup->use_hierarchy =false,则所有的memcg之间都是互相独立,互不影响的,即使是父子cgroup之间,也跟两个单独创建的cgroup一样。
2、 如果启用hierarchy,即mem_cgroup->use_hierarchy =true,则memcg的统计需要考虑hierarchy中的层级关系,其影响因素主要有:
a. Charge/uncharge
如果子cgroup中charge/uncharge了一个page,则其父cgroup和所有祖先cgroup都要charge/uncharge该page。
b. Reclaim
因为父cgroup的统计中包含了所有子cgroup中charge的page,所以在回收父cgroup中使用的内存时,也可以回收子cgroup中进程使用的内存。
c. Oom
因为父cgroup的统计中包含了所有子cgroup中charge的page,所以如果父cgroup需要出发oom,则oom可以考虑杀死子cgroup中的进程,达到释放内存的效果。
3 技术点分解
3.1 Charge/Uncharge
3.1.1 Page cache & anon page
1. Page cache
上面说了memcg管理和统计内存,都是以page为最小单位的,那么内核中使用page的地方那么多,我们怎么去一一统计呢?
实际上,对于用户态应用程序使用的内存,在内核中一般只需要统计两类内存:
a. Page cache
b. Anon page
(另外,memcg还会统计kmem,即kernel memory,这个后面单独讲)
Page cache是内核对磁盘文件内容在内存中做的缓存,比如在我们做read操作时,会先去读page cache中的内容,如果不存在,再通过IO去磁盘上读,并把读到的内容写到page cache中;相应的,在write操作时(非direct IO),我们也是先操作page cache中的内容,再异步的从page cache中刷回磁盘。
Page cache的计数原则是:谁把page请进了page cache,对应的memcg就为此而charge。主要有这么几种情况:
1、read/write系统调用;
2、mmap做文件映射之后,在对应区域进行内存读写;
3、伴随1和2两种情况产生的预读;
反之,当page被释放(一般就在它离开pagecache之时),对应的memcg得以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打交道的,所以memcg也不会因此而charge。(当然,read/write需要一块buffer,这个是要charge好的。)
对于page cache的swap情况。往往有种错误的认识是:pagecache是对磁盘文件的缓存,它只会被写回到磁盘,而不会被swap出去。但这里存在一些例外,这主要涉及tmpfs和shm,它们表面上看跟普通文件映射没什么两样,每个文件(或shmid)都有着自己的page cache,并且都可以按照文件的那一套逻辑来操作。但它们却是完全基于内存的,并没有外设作为存储介质,所以当需要回收page的时候,只能swap。
swap-out时,在page被释放时uncharge对应memcg的res计数,memsw计数不变:
a. Page在离开page cache后并不会马上释放,而是先被移动到swap cache、然后swap到交换区、最后才能释放;
b. 交换区是有大小限制的,如果分配swap entry不成功,则page不能被回收,依然放在pagecache中;
c. 直到page被释放,才uncharge;
swap-in时,在page重新回到page cache时charge:
a. Page先被读入(或预读)swap cache,此时并没有charge操作;
b. 随后,需要swap-in的page会从swap cache移动到page cache,此时对应的memcg会做charge;
c. 而其他被预读进swap cache的page,并不会引起charge,也不会被移动到page cache,直到它真正需要swap-in时;
NOTICE:swap cache与page cache的不同。
两者都可能会有预读,但是swap cache里面的page只有当真正要使用的时候才会charge,而page cache只要读进cache就charge。因为文件预读是为操作它的进程服务的,而swap预读则未必,交换区里的数据可能是离散的,属于不同的进程。
2. Anon page
另一类重要的内存时anon page,即匿名页,简单的说就是磁盘上没有后备文件的内存,比如用户态程序通过malloc申请的内存页都是anonpage。另外,查看内存信息时,常看到的res,即常驻内存,指的也是这类anonpage。
anon的计数原则是:谁分配了page,谁就为此而charge。主要有这么几种情况:
1、写一个未建立映射的属于匿名vma的虚拟内存时,page被分配,并建立映射;
2、写一个待COW的page时,新page被分配,并重新建立映射。
这些待COW的page可能产生于如下场景:
a. 读一个未建立映射的属于匿名vma的虚拟内存时,此时的缺页异常不会分配page,而是将相应地址临时只读的映射到一个全0的特殊page,等待COW,这是对读操作的优化,只有写操作才会分配内存;
b. fork后,父子进程会共享原来的anon page,并且映射被更改为只读,等待COW;
c. private文件映射的page是以只读方式映射到page cache中的page,等待COW;(比较有趣的情况,新的page是anon的,而对应的vma还是映射到文件的。)
反之,当page被释放(一般在对它的映射完全撤销时),对应的memcg得以uncharge。主要有这么几种情况:
1、进程munmap掉一段虚拟内存,则对应的已经映射的page会被减引用,可能导致引用减为0而释放;(比如主动munmap、exit退出程序、等。)
NOTICE:如果父子进程不在同一个memcg,则对于fork后那些尚未COW的anon page来说,很可能是charge在父进程所对应的memcg上的。父进程就算撤销了映射,计数依然会算在它头上(直到page被释放)。而如果是因为父进程的写操作引发了COW,则新分配的page和老的page都要算在父进程头上。不过子进程默认是跟父进程在同一个memcg的,除非刻意去移动它。
Anon page可能被page回收算法swap掉,也会导致对应memcg的res计数uncharge。
swap-out时,在page的最后一个映射被撤销时uncharge:
a. swap-out时,anon page会先放放置在swap cache上,然后对每一个映射它的进程进行unmap(前提是分配swap entry成功,否则不会swap-out);
b. 在最后一个映射被撤销时进行uncharge;
c. 映射撤销后,这个page可能还会呆在swapcache上,等待写回交换区(不过写不写回已经不影响memcg的计数了);
swap-in时,在page的第一个映射建立时charge:
a. 对swap page的缺页异常,以及由此触发的预读,将导致新page被分配,并放到swapcache,再从交换区读入数据;
b. 新page被放到swap cache并不会导致对应memcg的charge;
c. 等这个新page第一次被映射的时候,对应memcg才会charge;
NOTICE:对于共享的anon page,charge在第一次映射它的memcg上。如果swap-out,再被其他memcg的进程swap-in,则还是计在原来的memcg上。因为swap-out后,原memcg的memsw计数是没有改变的,所以也不能因为swap-in而改变。
anon page被多个进程共享主要是fork()时父子进程共享这一种情况。
总的来说:
page cache里的page,charge/uncharge是以page加入/脱离page cache为准的;
anon page,charge/uncharge是以page的分配/释放为准的;
swap page,charge/uncharge是以page被使用/未使用为准的。
3.1.2 Charge
一个page/swp_entry可能在以下情况下被charge:
a. mem_cgroup_newpage_charge()
在发生新页面的缺页中断或Copy-On-Write的时候。
b. mem_cgroup_try_charge_swapin()
在do_swap_page()函数(在swap entry上发生缺页异常)和swapoff系统调用中调用(swapoff->try_to_unuse()->unuse_mm()->unuse_vma()->unuse_pud_range()->unuse_pmd_range()->unuse_pte_range()->unuse_pte()->mem_cgroup_try_charge_swapin()),且该函数会伴随着charge-commit-cancel机制。
c. mem_cgroup_cache_charge()
在add_to_page_cache()函数中被调用,即加入page cache的时候。另外,还有shmem在swapin的时候会调用,一般来说,page cache是不会swap的,但是shmem却是例外,它既有普通文件系统的特性,可以存放在page cache中,又跟anon page一样只存在在内存中,所以reclaim的时候只能swap出去。
d. mem_cgroup_prepare_migration()
在迁移前被调用,该函数会伴随着charge-commit-cancel机制。
跟上一节描述的类似,charge主要在page cache和anon page的几个使用场景发生,另外,还有一个迁移的情况。
3.1.3 Uncharge
一个page/swp_entry可能在以下情况下被uncharge:
a. mem_cgroup_uncharge_page()
在anon page被完全unmap的时候。比如mapcount变成0。如果page是在swap cache中,则uncharge操作推迟到mem_cgroup_uncharge_swapcache()中执行。
b. mem_cgroup_uncharge_cache_page()
当page从page cache的radix-tree上删除时被调用,page cache小节已经说明了page从page cache中删除的几种可能情况。
c. mem_cgroup_uncharge_swapcache()
在swapcache_free()函数中被调用。
d. mem_cgroup_uncharge_swap()
当swp_entry的引用计数减为0时被调用(在swap_entry_free()函数中调用),主要清除swap cgroup中的id记录,同时修改memsw的计数。
e. mem_cgroup_end_migration(old,new)
如果迁移成功,则old会被uncharge,对new的charge会被commit;如果迁移失败,则对old的charge会被commit。
3.1.4 Charge-commit-cancel
在很多时候,我们在charge的时候不知道是否可以charge成功,或者charge是否是合法的,比如charge可能导致超过hard limit,可能发生内存不足,可能因为竞争而charge失败等。为了处理这些情况,内核对charge引入了charge-commit-cancel机制,提供了三类函数:
Mem_cgroup_try_charge_XXX
Mem_cgroup_commit_charge_XXX
Mem_cgroup_cancel_charge_XXX
它们有时候被单独使用,有时候封装起来使用。
在try_charge的时候,不会设置flag来说明“这个page已经被charge过了”,而只是做 usage += PAGE_SIZE。
在commit的时候,函数会检查这个page是否应该或可以被charge,如果可以charge,就补充设置一下flag说明该page已经被charge过了,否则,就取消charge(usage -= PAGE_SIZE).
在cancel的时候,只是简单的做usage -= PAGE_SIZE。
3.2 Reclaim
3.2.1 概述
一旦统计发现内存使用超过限额,则会触发memcg的内存回收机制。值得说明的是,经过多次代码改动和memcg的推动,现在memcg的内存回收代码已经完全与内核本身的内存回收代码融合了(尽管还不够完美),但这也给我们的代码分析增加了难度。Memcg中大量使用的各种巧妙但复杂的数据结构是我们理清整个memcg内存回收机制的主要障碍,下面我们将对他们一一进行分析。
3.2.2 PFRA介绍
在分析memcg的内存回收之前,有必要对内核本身的内存回收机制PRFA(page frame reclaiming algorithm)有个简单的理解。
首先看看PFRA在整个内核对内存的使用中的位置:
可见PFRA几乎会覆盖所有内核中可能使用的内存。那么PFRA又包括哪些操作以及他们执行的时机又是如何呢?
这里借用《深入理解Linux内核(第三版)》中的一张插图,虽然现在的代码实现跟图中所示已有一些区别,但基本的框架和实现思路没有改变。即页框回收算法的执行有三种基本情形:
a. 内存紧缺回收
b. 睡眠回收
c. 周期回收
我们将会从其中最主要的几个函数出发讨论现有的memory cgroup及其涉及和影响到的内存回收机制。
3.2.3 Memcg的reclaim流程
在memory cgroup中,整个reclaim的流程如下:
即在三种情况下触发reclaim:
a. Do_charge时发现超过limit限制。
b. 修改limit设置。
c. 修改memsw_limit设置。
其中在代码流程中,从函数do_try_to_free_pages开始,就是内核全局的reclaim代码了,不同的是,在上图中,虚线灰底的路径是只有在全局reclaim的时候才会走到,其他的部分则是memory cgroup内存回收的时候走的路径。(在使用memory cgroup之后,reclaim中会经常看到两个概念:global reclaim 和 target reclaim,即全局回收和局部/目标回收,前者的对象是所有的内存,后者是针对单个cgroup,但全局回收也是以单个memcg为单位的)
从函数名可以看出,在全局回收时,函数名也有mem_cgroup_之类的字段,说明memory cgroup的实现已经完全嵌入到内核的内存回收代码中,下面会分析memory cgroup是如何对全局内存回收产生影响的。
3.2.4 Soft_limit
如果你之前已经对内核的PFRA机制有一定的了解,那么不得不提到soft_limit,这是memory cgroup对内核reclaim机制带来影响的主要技术点。
Soft_limit是memory cgroup的一个控制字段,可以通过修改 /cgroup/容器名/memory.soft_limit_in_bytes 字段来设置该值(或在启动容器时设置在配置文件中),相对于hard_limit,soft_limit一般会小于hard_limit,超过soft_limit后不会立刻触发reclaim,而是作为reclaim的依据之一,比如在回收内存时对超过soft_limit较多的cgroup中的内存优先回收,且回收至soft_limit之下。
1. Lruvec
那么内核是如何实现并维护soft_limit的?memory cgroup又是如何结合到内核的内存回收中的?我们首先看一下内存回收中最终调用的函数shrink_lruvec:
其实说shrink_lruvec是“最终调用的函数”是不准确的,在页面回收中,shrink_lruvec后面还有很多事情要做,还要调用shrink_list – shrink_inactive_list – shrink_page_list,然后调用try_to_unmap解除映射或pageout将脏页写回磁盘,然后调free_hot_cold_page_list回收页面内存。这里还省略了很多细节,不过这些太过底层,是内核内存回收的底层固有机制,跟mem_cgroup无关,故不在本文的讨论范围内,在这里我们只需要关心如何获取到lruvec,然后调用shrink_lruvec即可完成内存回收。
看看lruvec的定义:
就是一个lru链表的集合,可能包括active或inactive等。
再看我们如何获取这个lruvec的代码:
如果在内核编译选项中打开了CONFIG_MEMCG,则使用第一个函数实现,否则使用第二个,所以一旦我们在内核编译中开启了CONFIG_MEMCG(应该默认就是开启的),则不管我们是否挂载cgroup文件系统来使用cgroup,内核代码中走的都是有mem_cgroup的流程。另外,我们常常看到的“lru链表”的概念,在加入mem_cgroup后也发生了一些变化,page结构体中只有一个lru字段是用来将该page加入lru链表中的,所以一旦我们使用了CONFIG_MEMCG,则所有的page都会挂在某个mem_cgroup的某个mem_cgroup_per_zone对应的lru链表上,也就不存在全局lru链表的概念了。
我们只关心开启CONFIG_MEMCG选项的情况,再看mem_cgroup_zoneinfo函数:
即每个mem_cgroup中有个类型为mem_cgroup_lru_info的成员info,通过它以及node_id和zone_id,即可找到对应的mem_cgroup_per_zone,从而得到lruvec。其中该info成员的原型为:
到此我们已经涉及到了几个相关的数据结构,mem_cgroup_per_node、mem_cgroup_per_zone,看看他们的定义:
可见每个mem_cgroup_per_zone对应一个lruvec,也对应一个mem_cgroup,同时一个mem_cgroup_per_node对应多个mem_cgroup_per_zone。这个mem_cgroup_per_zone非常重要,在内核相关代码中到处都能看到它的身影,简单的说,一个mem_cgroup_per_zone维护了一个mem_cgroup在某个zone上使用的内存,它跟mem_cgroup是多对一的关系。
实际上,在内核开启MEMCG后,对内存回收的单位lruvec就来自于各个mem_cgroup了(实际上是mem_cgroup对应的某个mem_cgroup_per_zone),因为内存回收的接口,其参数都是zone,而对于物理内存,首先分内存节点,即node,然后每个内存节点上有多个zone,而一个zone上的内存可能被多个mem_cgroup使用,但反过来,一个mem_cgroup可能使用多个zone,甚至多个node上的内存。所以mem_cgroup结构体中的info成员可以通过node_id和zone_id找到对应的mem_cgroup_per_zone。
2. Soft_limit_tree
重新回到soft_limit的话题,从reclaim的流程图中可知,在函数shrink_zones中,在global_reclaim的时候,会调用mem_cgroup_soft_limit_reclaim函数。该函数的目的是回收该zone上超过soft_limit最多的mem_cgroup在该zone上mem_cgroup_per_zone对应的lru链表。那么它是如何来找到该mem_cgroup_per_zone的呢?这就是mem_cgroup机制新加入的一个数据结构soft_limit_tree,通过分析soft_limit_tree的原型数据结构mem_cgroup_tree及其成员和其他相关数据结构,我们不难得出这样的数据结构关系图:
即soft_limit_tree是整个树的根节点,根据node的数量,有多个mem_cgroup_tree_per_node子节点,然后根据zone的数量,每个mem_cgroup_tree_per_node又有多个mem_cgroup_tree_per_zone的子节点,每个mem_cgroup_tree_per_zone则引出了一颗由mem_cgroup_per_zone组成的树。结合上面回收内存的逻辑,我们在调用shrink_zones时,会遍历一个zonelist上的所有zone,而每个zone在soft_limit_tree上就对应一个mem_cgroup_tree_per_zone,从这个zone,就可以找到所有相关的mem_cgroup_per_zone,进行相应的回收操作。
那么内核是如何来维护这颗soft_limit_tree的呢?
在创建mem_cgroup的时候调用了两个函数:alloc_mem_cgroup_per_zone_info和mem_cgroup_soft_limit_tree_init,我们再看看这两个函数的实现:
从上述代码可知,在创建一个cgroup的时候,内核就对该cgroup,在每一个node的每一个zone上分配好了mem_cgroup_per_zone结构,并初始化,不过那个时候他们并没有在soft_limit_tree上(on_tree = false)。也就是在这个时候,初始化了lruvec小节中提到的mem_cgroup结构体中的info字段。另外,初始化了全局变量soft_limit_tree中除了mem_cgroup_per_zone之外的其他主干部分。
根据下面的代码流程图:
我们在三种情况下会更新soft_limit_tree,即charge、uncharge和move的时候(move的时候也会带来charge和uncharge),所以在charge和uncharge时,检查该page所在的mem_cgroup的soft_limit是否超限,如果超了,则将对应的mem_cgroup_per_zone加入到soft_limit_tree中,如果没超而对应的mem_cgroup_per_zone又在soft_limit_tree上,则将其从树上摘下。达到动态更新的目的。
值得注意的是,mem_cgroup_per_zone上记录的usage_in_excess是其对应的cgroup所超过soft_limit的值,而该cgroup会对应很多个mem_cgroup_per_zone,所以在mem_cgroup_soft_limit_reclaim函数选取到某个zone上超过soft_limit最多的mem_cgroup对应的mem_cgroup_per_zone,并回收它上面的lruvec,它不一定能够达到最好的回收效果,因为这个超过soft_limit最多的mem_cgroup其大多数的内存消耗可能都在其他node或其他zone上,这里回收的lruvec可能只有很少的page。但是因为全局回收时往往会遍历所有的node,所有的zone,所以按照这个策略,结果应该总是会倾向于回收超过soft_limit最多的mem_cgroup里的内存的。
分析至此,我们可以看出上面加入了那么多的数据结构和处理函数,目的都是为了实现soft_limit的功能,而soft_limit本身的目的很简单,就是在内存回收时识别出最应该被回收的mem_cgroup,并告诉内核回收到什么程度。所以现在的实现方式并不能算优美。