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结构。

Cgroup-memory子系统分析(1)

 

而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则是内核实时统计该组进程内存的使用值。

 

对于统计功能的实现,可以用一个简单的图表示:

Cgroup-memory子系统分析(1)

概括为三点:

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结构体一样,在系统启动的时候或内存热插入的时候分配,在内存热拔除的时候释放。

Cgroup-memory子系统分析(1)

 

然后,关于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()的时候释放。

Cgroup-memory子系统分析(1)

其中的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。

Cgroup-memory子系统分析(1)

b.    规则2:一个hierarchy可以附加一个或多个subsystem;

如下图所示,cpu和memory subsystem可以同时附加到hierarchy中。

Cgroup-memory子系统分析(1)

c.     规则3:在系统创建新hierarchy时,系统中所有任务都是这个hierarchy的默认cgroup的初始成员。对于单一hierarchy来说,系统中每个任务都可以是该hierarchy的唯一一个cgroup的成员。单一任务可以在多个cgroup中,每个cgroup都在不同的hierarchy,如果任务成为同一hierarchy中第二个cgroup的成员,将会从该层级中第一个cgroup中删除,一个任务永远不会同时位于同一hierarchy的不同cgroup中。

如下图所示:进程httpd可以在hierarchyA和hierarchyB中同时存在,但是不能在hierarchyA中存在多个。

Cgroup-memory子系统分析(1)

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在整个内核对内存的使用中的位置:

Cgroup-memory子系统分析(1)

可见PFRA几乎会覆盖所有内核中可能使用的内存。那么PFRA又包括哪些操作以及他们执行的时机又是如何呢?

Cgroup-memory子系统分析(1)

这里借用《深入理解Linux内核(第三版)》中的一张插图,虽然现在的代码实现跟图中所示已有一些区别,但基本的框架和实现思路没有改变。即页框回收算法的执行有三种基本情形:

a.     内存紧缺回收

b.    睡眠回收

c.     周期回收

我们将会从其中最主要的几个函数出发讨论现有的memory cgroup及其涉及和影响到的内存回收机制。

3.2.3      Memcg的reclaim流程

在memory cgroup中,整个reclaim的流程如下:

Cgroup-memory子系统分析(1)

即在三种情况下触发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:

Cgroup-memory子系统分析(1)

其实说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的定义:

Cgroup-memory子系统分析(1)

就是一个lru链表的集合,可能包括active或inactive等。

再看我们如何获取这个lruvec的代码:

Cgroup-memory子系统分析(1)Cgroup-memory子系统分析(1)

如果在内核编译选项中打开了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函数:

Cgroup-memory子系统分析(1)

即每个mem_cgroup中有个类型为mem_cgroup_lru_info的成员info,通过它以及node_id和zone_id,即可找到对应的mem_cgroup_per_zone,从而得到lruvec。其中该info成员的原型为:

Cgroup-memory子系统分析(1)

到此我们已经涉及到了几个相关的数据结构,mem_cgroup_per_node、mem_cgroup_per_zone,看看他们的定义:

Cgroup-memory子系统分析(1)

可见每个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及其成员和其他相关数据结构,我们不难得出这样的数据结构关系图:

Cgroup-memory子系统分析(1)

        即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的呢?

Cgroup-memory子系统分析(1)

在创建mem_cgroup的时候调用了两个函数:alloc_mem_cgroup_per_zone_info和mem_cgroup_soft_limit_tree_init,我们再看看这两个函数的实现:

Cgroup-memory子系统分析(1)

Cgroup-memory子系统分析(1)

从上述代码可知,在创建一个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之外的其他主干部分。

根据下面的代码流程图:

Cgroup-memory子系统分析(1)

        我们在三种情况下会更新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,并告诉内核回收到什么程度。所以现在的实现方式并不能算优美。



相关文章: