[toc]
背景
OOM 问题一直是Linux开发中最让人头疼的原因,正常来说最有可能引起OOM问题的原因就是内存泄露,但是这种问题的排查有时候是非常困难的有时候甚至难以复现,而问题出现的现场能提供给我的信息,有的时候也不足以帮助我们分析出引起内存泄漏的原因。所以需要深入理解Linux的物理内存管理机制,以及内存分配策略从而找到一种一劳永逸的内存泄露定位方法。
内核如何描述内存页
上图展示的是在 NUMA 架构下,NUMA 节点与物理内存区域 zone 以及物理内存页 page 之间的层次关系。
物理内存被划分成了一个一个的内存节点(NUMA 节点),在每个 NUMA 节点内部又将其所管理的物理内存按照功能不同划分成了不同的内存区域 zone ,每个内存区域 zone 管理一片用于具体功能的物理内存页 page,而内核会为每一个内存区域分配一个伙伴系统用于管理该内存区域下物理内存页 page 的分配和释放。
物理内存在内核中管理的层级关系为:
Node -> Zone -> page
内核是通过 struct page 结构来描述物理内存页,这个结构是内核中最为复杂的一个结构体,因为它是物理内存管理的最小单位,被频繁应用在内核中的各种复杂机制下。
假设我们有 4G 大小的物理内存,每个物理内存页大小为 4K,那么这 4G 的物理内存会被内核划分为 1M 个物理内存页,内核使用一个 struct page 的结构体来描述物理内存页,而每个 struct page 结构体占用内存大小为 40 字节,那么内核就需要用额外的 40 * 1M = 40M 的内存大小来描述物理内存页。
对于 4G 物理内存而言,这额外的 40M 内存占比相对较小,这个代价勉强可以接受,但是对内存锱铢必较的内核来说,还是会尽最大努力想尽一切办法来控制 struct page 结构体的大小。
因为对于 4G 的物理内存来说,内核就需要使用 1M 个物理页面来管理,1M 个物理页的数量已经是非常庞大的了,因此在后续的内核迭代中,对于 struct page 结构的任何微小改动,都可能导致用于管理物理内存页的 struct page 实例所需要的内存暴涨。
回想一下我们经历过的很多复杂业务系统,由于业务逻辑已经非常复杂,在加上业务版本日积月累的迭代,整个业务系统已经变得异常复杂,在这种类型的业务系统中,我们经常会使用一个非常庞大的类来包装全量的业务响应信息用以应对各种复杂的场景,但是这个类已经包含了太多太多的业务字段了,而且这些业务字段在有的场景中会用到,在有的场景中又不会用到,后面还可能继续临时增加很多字段。系统的维护就这样变得越来越困难。
相比上面业务系统开发中随意地增加改动类中的字段,在内核中肯定是不会允许这样的行为发生的。struct page 结构是内核中访问最为频繁的一个结构体,就好比是 Linux 世界里最繁华的地段,在这个最繁华的地段租间房子,那租金可谓是相当的高,同样的道理,内核在 struct page 结构体中增加一个字段的代价也是非常之大,该结构体中每个字段中的每个比特,内核用的都是淋漓尽致。
但是 struct page 结构同样会面临很多复杂的场景,结构体中的某些字段在某些场景下有用,而在另外的场景下却没有用,而内核又不可能像业务系统开发那样随意地为 struct page 结构增加字段,那么内核该如何应对这种情况呢?
下面我们即将会看到 struct page 结构体里包含了大量的 union 结构,而 union 结构在 C 语言中被用于同一块内存根据不同场景保存不同类型数据的一种方式。内核之所以在 struct page 结构中使用 union,是因为一个物理内存页面在内核中的使用场景和使用方式是多种多样的。在这多种场景下,利用 union 尽最大可能使 struct page 的内存占用保持在一个较低的水平。
struct page 结构可谓是内核中最为繁杂的一个结构体,应用在内核中的各种功能场景下,在本小节中一一解释清楚各个字段的含义是不现实的。
总体来说struct page功能由以下几部分构成:
struct page {
// 存储 page 的定位信息以及相关标志位
unsigned long flags;
union {
struct { /* Page cache and anonymous pages */
// 用来指向物理页 page 被放置在了哪个 lru 链表上
struct list_head lru;
// 如果 page 为文件页的话,低位为0,指向 page 所在的 page cache
// 如果 page 为匿名页的话,低位为1,指向其对应虚拟地址空间的匿名映射区 anon_vma
struct address_space *mapping;
// 如果 page 为文件页的话,index 为 page 在 page cache 中的索引
// 如果 page 为匿名页的话,表示匿名页在对应进程虚拟内存区域 VMA 中的偏移
pgoff_t index;
// 在不同场景下,private 指向的场景信息不同
unsigned long private;
};
struct { /* slab, slob and slub */
union {
// 用于指定当前 page 位于 slab 中的哪个具体管理链表上。
struct list_head slab_list;
struct {
// 当 page 位于 slab 结构中的某个管理链表上时,next 指针用于指向链表中的下一个 page
struct page *next;
#ifdef CONFIG_64BIT
// 表示 slab 中总共拥有的 page 个数
int pages;
// 表示 slab 中拥有的特定类型的对象个数
int pobjects;
#else
short int pages;
short int pobjects;
#endif
};
};
// 用于指向当前 page 所属的 slab 管理结构
struct kmem_cache *slab_cache;
// 指向 page 中的第一个未分配出去的空闲对象
void *freelist;
union {
// 指向 page 中的第一个对象
void *s_mem;
struct { /* SLUB */
// 表示 slab 中已经被分配出去的对象个数
unsigned inuse:16;
// slab 中所有的对象个数
unsigned objects:15;
// 当前内存页 page 被 slab 放置在 CPU 本地缓存列表中,frozen = 1,否则 frozen = 0
unsigned frozen:1;
};
};
};
struct { /* 复合页 compound page 相关*/
// 复合页的尾页指向首页
unsigned long compound_head;
// 用于释放复合页的析构函数,保存在首页中
unsigned char compound_dtor;
// 该复合页有多少个 page 组成
unsigned char compound_order;
// 该复合页被多少个进程使用,内存页反向映射的概念,首页中保存
atomic_t compound_mapcount;
};
// 表示 slab 中需要释放回收的对象链表
struct rcu_head rcu_head;
};
union { /* This union is 4 bytes in size. */
// 表示该 page 映射了多少个进程的虚拟内存空间,一个 page 可以被多个进程映射
atomic_t _mapcount;
};
// 内核中引用该物理页的次数,表示该物理页的活跃程度。
atomic_t _refcount;
#if defined(WANT_PAGE_VIRTUAL)
void *virtual; // 内存页对应的虚拟内存地址
#endif /* WANT_PAGE_VIRTUAL */
} _struct_page_alignment;
下面介绍下 struct page 结构在不同场景下的使用方式,从第一个union我们就可以看出一个page的最终几种归宿,无非就是作为文件的缓存,或者分配给系统使用的匿名页,或者分配给slab系统。
下面介绍第一个union中的第一个struct成员,其被用作匿名页和文件页中:
内核中的物理内存页有两种类型,分别用于不同的场景:
- 一种是匿名页,匿名页背后并没有一个磁盘中的文件作为数据来源,匿名页中的数据都是通过进程运行过程中产生的,匿名页直接和进程虚拟地址空间建立映射供进程使用。
- 另外一种是文件页,文件页中的数据来自于磁盘中的文件,文件页需要先关联一个磁盘中的文件,然后再和进程虚拟地址空间建立映射供进程使用,使得进程可以通过操作虚拟内存实现对文件的操作,这就是我们常说的内存文件映射。
struct page {
// 如果 page 为文件页的话,低位为0,指向 page 所在的 page cache
// 如果 page 为匿名页的话,低位为1,指向其对应虚拟地址空间的匿名映射区 anon_vma
struct address_space *mapping;
// 如果 page 为文件页的话,index 为 page 在 page cache 中的索引
// 如果 page 为匿名页的话,表示匿名页在对应进程虚拟内存区域 VMA 中的偏移
pgoff_t index;
}
我们首先来介绍下 struct page 结构中的 struct address_space *mapping 字段
这个字段总体来说有两个作用,一个是作为文件页,指向页高速缓存,另一个是如果作为匿名页将用作物理内存到虚拟内存的反向映射。
我们通常所说的内存映射是正向映射,即从虚拟内存到物理内存的映射。而反向映射则是从物理内存到虚拟内存的映射,用于当某个物理内存页需要进行回收或迁移时,此时需要去找到这个物理页被映射到了哪些进程的虚拟地址空间中,并断开它们之间的映射。
在没有反向映射的机制前,需要去遍历所有进程的虚拟地址空间中的映射页表,这个效率显然是很低下的。有了反向映射机制之后内核就可以直接找到该物理内存页到所有进程映射的虚拟地址空间 VMA ,并从 VMA 使用的进程页表中取消映射,
第二个struct成员 用作slab内存分配系统
内核中对内存页的分配使用有两种方式,一种是一页一页的分配使用,这种以页为单位的分配方式内核会向相应内存区域 zone 里的伙伴系统申请以及释放。
另一种方式就是只分配小块的内存,不需要一下分配一页的内存,比如前边章节中提到的 struct page ,anon_vma_chain ,anon_vma ,vm_area_struct 结构实例的分配,这些结构通常就是几十个字节大小,并不需要按页来分配。
为了满足类似这种小内存分配的需要,Linux 内核使用 slab allocator 分配器来分配,slab 就好比一个对象池,内核中的数据结构对象都对应于一个 slab 对象池,用于分配这些固定类型对象所需要的内存。
它的基本原理是从伙伴系统中申请一整页内存,然后划分成多个大小相等的小块内存被 slab 所管理。这样一来 slab 就和物理内存页 page 发生了关联,由于 slab 管理的单元是物理内存页 page 内进一步划分出来的小块内存,所以当 page 被分配给相应 slab 结构之后,struct page 里也会存放 slab 相关的一些管理数据。
struct page {
struct { /* slab, slob and slub */
union {
struct list_head slab_list;
struct { /* Partial pages */
struct page *next;
#ifdef CONFIG_64BIT
int pages; /* Nr of pages left */
int pobjects; /* Approximate count */
#else
short int pages;
short int pobjects;
#endif
};
};
struct kmem_cache *slab_cache; /* not slob */
/* Double-word boundary */
void *freelist; /* first free object */
union {
void *s_mem; /* slab: first object */
struct { /* SLUB */
unsigned inuse:16;
unsigned objects:15;
unsigned frozen:1;
};
};
};
}
- struct list_head slab_list :slab 的管理结构中有众多用于管理 page 的链表,比如:完全空闲的 page 链表,完全分配的 page 链表,部分分配的 page 链表,slab_list 用于指定当前 page 位于 slab 中的哪个具体链表上。
- struct page *next : 当 page 位于 slab 结构中的某个管理链表上时,next 指针用于指向链表中的下一个 page。
- int pages : 表示 slab 中总共拥有的 page 个数。
- int pobjects : 表示 slab 中拥有的特定类型的对象个数。
- struct kmem_cache *slab_cache : 用于指向当前 page 所属的 slab 管理结构,通过 slab_cache 将 page 和 slab 关联起来。
- void *freelist : 指向 page 中的第一个未分配出去的空闲对象,前面介绍过,slab 向伙伴系统申请一个或者多个 page,并将一整页 page 划分出多个大小相等的内存块,用于存储特定类型的对象。
- void *s_mem : 指向 page 中的第一个对象。
- unsigned inuse : 表示 slab 中已经被分配出去的对象个数,当该值为 0 时,表示 slab 中所管理的对象全都是空闲的,当所有的空闲对象达到一定数目,该 slab 就会被伙伴系统回收掉。
- unsigned objects : slab 中所有的对象个数。
- unsigned frozen : 当前内存页 page 被 slab 放置在 CPU 本地缓存列表中,frozen = 1,否则 frozen = 0 。
第三个 struct是给复合页使用的。复合页(Compound Page)就是将物理上连续的两个或多个页看成一个独立的大页,它可以用来创建hugetlbfs中使用的大页(hugepage),也可以用来创建透明大页(transparent huge page)子系统。但是它不能用在页缓存(page cache)中,这是因为页缓存中管理的都是单个页。
页表体系
Linux通过页表体系搭建虚拟内存到物理内存的桥梁。是理解Linux内存管理框架的基础。
虚拟内存其实是 CPU 和操作系统使用的一个障眼法,联手给进程编织了一个假象,让进程误以为自己独占了全部的内存空间:
- 在 32 位系统中,进程以为自己独占了 3G 的内存空间。
- 在 64 位系统中,进程以为自己独占了 128T 的内存空间。
这么做的好处是,操作系统为每个进程营造出一片独立的虚拟地址空间,使得进程与进程之间相互隔离,互不干扰的,解决了多进程同时运行时产生的内存地址冲突问题。
同时虚拟内存还提供了系统安全方面的保障,会对进程访问内存的行为进行相关的安全权限检查,保障了系统的稳定性和安全性。比如:
- 有些物理内存页只允许内核来访问,进程在用户态的时候是无法访问的。
- 虚拟内存中保存了访问其映射的物理内存相关的权限,进程只能执行规定权限范围内的访存操作。比如,上面虚拟内存空间里代码段的权限是可读,可执行,但是不可写。数据段具有可读可写的权限但是不可执行。堆则具有可读可写,可执行的权限,栈一般是可读可写的权限,一般很少有可执行权限。而文件映射与匿名映射区存放了共享链接库,所以也需要可执行的权限。
但是当程序运行起来之后,程序中所需要的数据本质上还是保存在物理内存中的,无论操作系统对虚拟内存设计的多么精彩,最终虚拟内存空间中每一个虚拟内存地址都是要映射到物理内存空间的中某一个特定物理内存地址上的。
进程虚拟内存空间中的每一个字节都有与其对应的虚拟内存地址,同样物理内存空间中每一个字节都有与其对应的物理内存地址。
虚拟内存如何映射物理内存
内核将整个物理内存空间划分为一页一页大小相同的的内存块,每个内存块大小为 4K,称为一个物理内存页。
一页大小的内存块在内核中用 struct page 结构体来进行管理,struct page 中封装了每页内存块的状态信息,比如:组织结构,使用信息,统计信息,以及与其他内核结构的关联映射信息等。
内核会为每个物理内存页 page 进行统一编号。这个编号称之为 PFN(Page Frame Number),PFN 与 struct page 是一一对应的关系并且 全局唯一 。
然后内核会将划分出来的这些一页一页的内存块统一组织在一个全局数组 mem_map 中管理。后续虚拟内存与物理内存的映射以及调度均是以页为单位进行的。
既然物理内存是以页为单位进行管理,而虚拟内存最终是要映射到物理内存上的,所以在虚拟内存空间中也有与之相对应的虚拟页这个概念,内存的映射是以页为单位进行的。
如上图所示,在内存映射的场景中,虚拟内存页的类型总共分为以下三种:
- 第一种就是图中灰色方框里标注的 未分配页面 ,进程的虚拟内存空间是非常庞大的,远远的超过物理内存空间,但这并不意味着进程可以直接随意使用虚拟内存,事实上进程对虚拟内存的使用也是需要向内核申请的。进程虚拟内存空间中的虚拟内存页在未被进程申请之前的状态就是未分配页面。
- 第二种就是图中紫色方框里标注的 已分配未映射页面 ,我们在进程中可以通过动态链接库 glic 中的 malloc 接口或者直接通过系统调用 mmap 向内核申请虚拟内存,申请到的虚拟内存页此时就变为了已分配的页面。但此时的虚拟内存页只是虚拟内存,其背后并没有与物理内存映射起来,所以称为已分配未映射页面。
- 第三种是图中绿色方框里标注的 正常页面 ,当进程开始读写这些已分配未映射的虚拟内存页时,在 CPU 中用于地址翻译的硬件 MMU 会产生一个缺页中断,随后内核会为其分配相应的物理内存页面,并将虚拟内存页与物理内存页映射起来。此时这些已分配未映射的虚拟内存页就变为了 正常页面 。从此以后,进程就可以正常读写这些虚拟内存页了。
MMU 负责将虚拟内存地址翻译为物理内存地址,后面会详细介绍这个地址翻译过程。
明白了这些之后,我们再来看上面这副内存映射图,从图中我们可以读出以下几种信息:
- 每个进程独占全部的虚拟内存空间,比如上图中,进程 1 的虚拟内存空间(蓝色部分)和进程 2 的虚拟内存空间(黄色部分)它们都拥有属于各自的虚拟内存页1 到虚拟内存页 7 这段范围的虚拟内存。也就是说进程1 和进程 2 看到的虚拟内存空间地址范围**都是一样**的。
- 每个进程的虚拟内存空间都是相互隔离,互不干扰的,进程可以在属于自己的虚拟内存空间里随意折腾。比如上图中,进程 1 里的虚拟内存页 1 是一个未分配页面,而进程 2 里的虚拟内存页 1 却是一个正常页面,被内核映射到物理内存页 2 中。也就是说虽然每个进程拥有的虚拟内存地址空间范围是一样的,但是各自虚拟内存空间中的虚拟页可能映射的物理页不一样,使用的方式和用途也不一样。
- 进程所看到的连续虚拟内存,在物理内存中有可能是不连续的,比如上图中,进程 1 里的虚拟页 4 和 虚拟页 5,它们在进程 1 的虚拟内存空间中是连续的,但是它们背后映射的物理内存页却是不连续的。虚拟内存页 4 被映射到了物理内存页 1 中,虚拟内存页 5 被映射到了物理内存页 4 中。
- 物理内存空间中蓝色部分是进程 1 正在使用的内存(物理页 1,物理页 4,物理页 7),黄色部分是进程 2 正在使用的内存(物理页 2,物理页 3,物理页 6)。这些复杂且琐碎的内存映射细节统统由内存管理子系统进行管理,从而极大的解放了程序员的心智负担。
现在让我们把视角从进程的虚拟内存空间切换到内核中的内存管理系统中,来看一下内核是如何管理这些内存映射关系的。
谈到映射,我们自然会想到 Map 这个数据结构,那么虚拟内存与物理内存之间的映射关系如果用 Map 来表达的话,就是如下形式:
Map<虚拟内存,物理内存>
如果我们给上面那副图加上 Map 映射关系的话,就演变成了这样:
Map<虚拟内存,物理内存>
的映射关系在内核中是被一个叫做页表的东西来管理的,页表除了管理虚拟内存与物理内存之间的映射关系之外,还会有一些访问权限的管理,来控制进程对物理内存的访问权限。
由于进程是独占虚拟内存空间的,而且不同进程之间的虚拟内存空间是相互隔离的,所以每个进程也都会有属于自己的页表,来专门管理各自虚拟内存空间中的映射关系以及各自访问物理内存的权限。
好了,现在我们已经大概清楚了虚拟内存与物理内存映射的一个总体框架了,当我们有了一个全局视角之后,下面我们就来深入到细节中,来看看内核究竟如何通过一张页表来管理这些内存映射关系以及访问权限的。
内核如何通过页表来管理内存映射关系
我们都知道内核对物理内存的管理是按照页为基本单位进行的,进程运行起来所需要的数据也是存储在一个一个的物理页中,既然物理内存页可以存储进程的普通数据,那么它也一定可以存储进程虚拟内存与物理内存之间的映射关系。
事实上,内核也是这么干的,内核会从物理内存空间中拿出一个物理内存页来专门存储进程里的这些内存映射关系,而这种物理内存页我们将其称之为页表,从这里可以看出页表的本质其实就是一个物理内存页。
而内核会在页表中划分出来一个个大小相等的小内存块,这些小内存块我们称之为页表项 PTE(Page Table Entry),正是这个 PTE 保存了进程虚拟内存空间中的虚拟页与物理内存页的映射关系,以及控制物理内存访问的相关权限位。
在 32 位系统中页表中的 PTE 占用 4 个字节,64 位系统中页表的 PTE 占用 8 个字节。
因为内存映射的粒度是按照页为单位进行的,所以进程虚拟内存空间中的每个虚拟页在页表中都会有一个 PTE 与之对应,而虚拟页背后映射的物理内存页的起始地址就保存在 PTE 中。
而进程虚拟内存空间中的每一个字节都有一个虚拟内存地址来表示,格式为:页表内偏移 + 物理内存页内偏移
因为上文已经说了,进程虚拟内存空间中的每一个虚拟页在页表中都会有一个 PTE 与之对应,专门用来存储该虚拟页背后映射的物理内存页的起始地址。
上述虚拟内存地址格式中的 页表内偏移
就是专门用来定位虚拟内存页在页表中的 PTE 的,因为页表本质其实还是一个物理内存页,而一个物理内存页里边的内存肯定都是连续的,每个 PTE 的尺寸又是相同的,所以我们可以把页表看做一个数组,PTE 看做数组里的元素,在一个数组里定位元素,我们直接通过元素的索引 index 就可以定位了。这个索引 index 就是 页表内偏移
。
这样一来,给定一个虚拟内存地址,内核会先从这个虚拟内存地址中提取出 页表内偏移
,然后根据 页表起始地址 + 页表内偏移 * sizeof(PTE)
就能获取到该虚拟内存地址所在虚拟页在页表中对应的 PTE 了。
这里大家可能会有一个疑问,页表内偏移我们可以从虚拟内存地址中获取,那这个页表起始地址我们该从哪里获取呢 ?
进程的虚拟内存空间在内核中是用 struct mm_struct 结构来描述的,每个进程都有自己独立的虚拟内存空间,而进程的虚拟内存到物理内存的映射也是独立的,为了保证每个进程里内存映射的独立进行,所以每个进程都会有独立的页表,而页表的起始地址就存放在 struct mm_struct 结构中的 pgd 属性中。
事实上,mm_struct->pgd 存放的是进程的顶级页表的起始地址,而为了让大家清晰的理解整个内存映射的过程,所以笔者在本小节中只讨论单级页表的情形,在这里单级页表的语义就是顶级页表。
struct mm_struct {
// 当前进程顶级页表的起始地址
pgd_t * pgd;
}
而进程的顶级页表起始地址 pgd 又是在什么时候被内核设置进去的呢?
很显然这个设置的时机是在进程被创建出来的时候,当我们使用 fork 系统调用创建进程的时候,内核在 _do_fork 函数中会通过 copy_process 将父进程的所有资源拷贝到子进程中,这其中也包括父进程的虚拟内存空间。
long _do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr,
unsigned long tls)
{
......... 省略 ..........
struct pid *pid;
struct task_struct *p;
......... 省略 ..........
// 拷贝父进程的所有资源
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
......... 省略 ..........
}
copy_process 函数开始拷贝父进程中的所有资源到子进程中:
static __latent_entropy struct task_struct *copy_process(
unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *child_tidptr,
struct pid *pid,
int trace,
unsigned long tls,
int node)
{
struct task_struct *p;
// 为进程创建 task_struct 结构
p = dup_task_struct(current, node);
....... 初始化子进程 ...........
....... 开始拷贝父进程资源 .......
// 拷贝父进程的虚拟内存空间以及页表
retval = copy_mm(clone_flags, p);
......... 省略拷贝父进程的其他资源 .........
// 分配 CPU
retval = sched_fork(clone_flags, p);
// 分配 pid
pid = alloc_pid(p->nsproxy->pid_ns_for_children);
........... 省略 .........
}
copy_mm 函数负责处理子进程虚拟内存空间的初始化工作,它会调用 dup_mm 函数,最终在 dup_mm 函数中将父进程虚拟内存空间的所有内容包括父进程的相关页表全部拷贝到子进程中,其中就包括了为子进程分配顶级页表起始地址 pgd。
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
...... 省略 ........
mm = dup_mm(tsk, current->mm);
...... 省略 ........
}
/**
* Allocates a new mm structure and duplicates the provided @oldmm structure
* content into it.
*/
static struct mm_struct *dup_mm(struct task_struct *tsk,
struct mm_struct *oldmm)
{
// 子进程虚拟内存空间,此时还是空的
struct mm_struct *mm;
int err;
// 为子进程申请 mm_struct 结构
mm = allocate_mm();
if (!mm)
goto fail_nomem;
// 将父进程 mm_struct 结构里的内容全部拷贝到子进程 mm_struct 结构中
memcpy(mm, oldmm, sizeof(*mm));
// 为子进程分配顶级页表起始地址并赋值给 mm_struct->pgd
if (!mm_init(mm, tsk, mm->user_ns))
goto fail_nomem;
// 拷贝父进程的虚拟内存空间中的内容以及页表到子进程中
err = dup_mmap(mm, oldmm);
if (err)
goto free_pt;
return mm;
}
最后内核会在 mm_init 函数中调用 mm_alloc_pgd,并在 mm_alloc_pgd 函数中通过调用 pgd_alloc 为子进程分配其独立的顶级页表起始地址,赋值给子进程 struct mm_struct 结构中的 pgd 属性。
static struct mm_struct *mm_init(struct mm_struct *mm, struct task_struct *p,
struct user_namespace *user_ns)
{
.... 初始化子进程的 mm_struct 结构 ......
// 为子进程分配顶级页表起始地址 pgd
if (mm_alloc_pgd(mm))
goto fail_nopgd;
}
static inline int mm_alloc_pgd(struct mm_struct *mm)
{
// 内核为子进程分配好其顶级页表起始地址之后
// 赋值给子进程 mm_struct 结构中的 pgd 属性
mm->pgd = pgd_alloc(mm);
if (unlikely(!mm->pgd))
return -ENOMEM;
return 0;
}
到现在为止,一个进程就算是被完整的创建出来了,它拥有了自己独立的页表(页表内容和父进程一模一样),同时也拥有了属于自己的顶级页表起始地址 pgd,但是这里大家需要特别注意一点的就是进程的 struct mm_struct 结构中的这个 pgd 现在还只是顶级页表的虚拟内存地址,还无法被 CPU 直接使用。
当这个进程被调度到某个 CPU 之上时,内核就会调用 context_switch 来对进程上下文进行切换,切换的内容主要包括:
- 进程虚拟内存空间的切换。
- 寄存器以及进程栈的切换。
/*
* context_switch - switch to the new MM and the new thread's register state.
*/
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next, struct rq_flags *rf)
{
........ 省略 ,,,,,,,,,,
if (!next->mm) { // to kernel
........ 内核线程的切换 ,,,,,,,,,,
} else { // to user
........ 用户进程的切换 ,,,,,,,,,,
membarrier_switch_mm(rq, prev->active_mm, next->mm);
// 切换进程虚拟内存空间
switch_mm_irqs_off(prev->active_mm, next->mm, next);
}
// 切换 CPU 上下文和进程栈
switch_to(prev, next, prev);
barrier();
return finish_task_switch(prev);
}
和本小节主题相关的是 switch_mm_irqs_off 函数,它主要负责对进程虚拟内存空间进行切换,其中就包括了调用 load_new_mm_cr3 函数将进程顶级页表起始地址 mm_struct-> pgd 中的虚拟内存地址通过 __sme_pa 宏
转换为物理内存地址,并将 pgd 的物理内存地址加载到 cr3 寄存器中。
void switch_mm_irqs_off(struct mm_struct *prev, struct mm_struct *next,
struct task_struct *tsk)
{
// 通过 __sme_pa 将 pgd 的虚拟内存地址转换为物理内存地址
// 并加载到 cr3 寄存器中
load_new_mm_cr3(next->pgd, new_asid, true);
}
cr3 寄存器中存放的是当前进程顶级页表 pgd 的物理内存地址,不能是虚拟内存地址。
进程的上下文在内核中完成切换之后,现在 cr3 寄存器中保存的就是当前进程顶级页表的起始物理内存地址了,当 CPU 通过下图所示的虚拟内存地址访问进程的虚拟内存时,CPU 首先会从 cr3 寄存器中获取到当前进程的顶级页表起始地址,然后从虚拟内存地址中提取出虚拟内存页对应 PTE 在页表内的偏移,通过 页表起始地址 + 页表内偏移 * sizeof(PTE)
这个公式定位到虚拟内存页在页表中所对应的 PTE。这里所说的CR3寄存器只适用于X86架构,对于ARM也有类似的MMU寄存器C2。
而虚拟内存页背后所映射的物理内存页的起始地址就保存在该 PTE 中,随后 CPU 继续从上图所示的虚拟内存地址中提取后半部分——物理内存页内偏移,并通过 物理内存页起始地址 + 物理内存页内偏移
就定位到了该物理内存页中一个具体的物理字节上。
好了,现在我们已经梳理清楚了内核如何通过页表来完成进程的虚拟内存与物理内存之间的映射关系了,并在这个基础上,我们又近一步了解了 CPU 如何通过虚拟内存访问其背后映射的物理内存的整个过程。
但是这里还要和大家特别强调的一点的是:当用户进程被 CPU 调度起来,访问进程虚拟内存的时候,上述的虚拟内存地址与物理内存地址转换的过程都是 在用户态进行的 ,正常的内存访问无需进入内核态。
除非 CPU 访问的虚拟内存页面类型是:
- 未分配页面。
- 已分配未映射页面。
- 以映射,但是由于内存紧张的原因,该虚拟内存页映射的物理内存页被置换到磁盘上了。
以上三种虚拟内存页有一个共同的特征就是它们背后的物理内存页均不在内存中,要么是没有映射,要么是被置换到磁盘上。当 CPU 访问这些虚拟内存页面的时候,就会产生缺页中断,随后进入内核态为其分配物理内存页面,填充物理内存页面中的内容,最后在页表中建立映射关系。之后的内存访问均是在用户态中进行。
CPU 无论是在用户态还是在内核态,访问的均是虚拟内存地址,不管是用户空间的虚拟内存地址还是内核空间的虚拟内存地址最终都是要与物理内存进行映射的,而通过前边的介绍我们也知道了,虚拟内存与物理内存的映射关系是通过页表来管理的。
所以页表也就分为了两个部分:
- 进程用户态页表:主要负责管理进程用户态虚拟内存空间到物理内存的映射关系。
- 内核态页表:主要负责管理内核态虚拟内存空间到物理内存的映射关系,这一部分主要供内核使用。
和进程用户态虚拟内存空间一样,内核态虚拟内存空间也有一个 struct mm_struct 结构来描述:
struct mm_struct init_mm = {
.mm_rb = RB_ROOT,
.pgd = swapper_pg_dir,
.mm_users = ATOMIC_INIT(2),
.mm_count = ATOMIC_INIT(1),
.mmap_sem = __RWSEM_INITIALIZER(init_mm.mmap_sem),
.page_table_lock = __SPIN_LOCK_UNLOCKED(init_mm.page_table_lock),
.mmlist = LIST_HEAD_INIT(init_mm.mmlist),
.user_ns = &init_user_ns,
INIT_MM_CONTEXT(init_mm)
};
从这里我们可以看到内核空间的顶级页表起始地址 pgd 叫做 swapper_pg_dir,定义在文件 arch/x86/include/asm/pgtable_64.h
中:
#define swapper_pg_dir init_top_pgt
内核的页表在系统初始化的时候被一段汇编代码 archx86kernelhead_64.S
所创建。后续内核虚拟内存空间的创建以及内核页表的初始化工作是在系统启动函数 start_kernel 中调用 setup_arch 完成。
asmlinkage __visible void __init start_kernel(void)
{
........ 省略 ........
// 创建内核虚拟内存空间,初始化内核页表
setup_arch(&command_line);
........ 省略 ........
}
void __init setup_arch(char **cmdline_p)
{
// 初始化内核页表
clone_pgd_range(swapper_pg_dir + KERNEL_PGD_BOUNDARY,
initial_page_table + KERNEL_PGD_BOUNDARY,
KERNEL_PGD_PTRS);
// 将内核顶级页表起始地址转换为物理地址,并加载到 cr3 寄存器中
load_cr3(swapper_pg_dir);
// 刷新 TLB 页表缓存
__flush_tlb_all();
}
这里我们又看到了熟悉的 cr3 寄存器,无论是进程页表也好还是内核页表也好,再被 CPU 访问之前都必须先加载到 cr3 寄存器中。
现在内核页表已经被创建和初始化好了,但是对于处于内核态的进程以及内核线程来说并不能直接访问这个内核页表,它们只能访问内核页表的 copy 副本,进程的页表分为两个部分,一个是进程用户态页表,另一个就是内核页表的 copy 部分。
前边我们介绍 fork 系统调用在创建子进程的时候,会拷贝父进程的所有资源,当拷贝父进程的虚拟内存空间的时候,内核会通过 pgd_alloc 函数为子进程创建顶级页表 pgd,其实这里还有一项重要的工作,笔者在前边没有讲,那就是在 pgd_alloc 函数中还会调用 pgd_ctor,这个 pgd_ctor 函数的主要工作就是将内核页表拷贝到进程页表中。
static inline int mm_alloc_pgd(struct mm_struct *mm)
{
// 内核为子进程分配好其顶级页表起始地址之后
// 赋值给子进程 mm_struct 结构中的 pgd 属性
mm->pgd = pgd_alloc(mm);
if (unlikely(!mm->pgd))
return -ENOMEM;
return 0;
}
pgd_t *pgd_alloc(struct mm_struct *mm)
{
pgd_t *pgd;
// 为子进程分配顶级页表
pgd = _pgd_alloc();
if (pgd == NULL)
goto out;
mm->pgd = pgd;
...... 根据配置,与初始化子进程页表 .....
// 拷贝内核页表到子进程中
pgd_ctor(mm, pgd);
....... 省略 ........
}
当进程通过系统调用切入到内核态之后,就会使用内核页表的这部分 copy 副本,来访问内核空间虚拟内存映射的物理内存。当进程页表中内核部分的拷贝副本与主内核页表不同步时,进程在内核态就会发生缺页中断,随后会同步主内核页表到进程页表中,这里又是延时拷贝在内核中的一处应用。
内核线程有一点和普通的进程不同,内核线程只能运行在内核态,而在内核态中,所有进程看到的虚拟内存空间全部都是一样的,所以对于内核线程来说并不需要为其单独的定义 mm_struct 结构来描述内核虚拟内存空间,内核线程的 struct task_struct 结构中的 mm 属性指向 null,内核线程之间调度是不涉及地址空间切换的,从而避免了无用的 TLB 缓存以及 CPU 高速缓存的刷新。
struct task_struct {
// 对于内核线程来说,它并没有自己的地址空间
// 因为它始终工作在内核空间中,所有进程看到的都是一样的
struct mm_struct *mm;
}
但是内核线程依然需要访问内核空间中的虚拟内存,也就是说内核线程仍然需要内核页表,但是它又没有自己的地址空间,那该怎么办呢?
内核这里做了一个非常巧妙的处理,当一个内核线程被调度时,它会发现自己的虚拟地址空间为 null,虽然它不会访问用户态的内存,但是它会访问内核内存,聪明的内核会将调度之前的上一个用户态进程的虚拟内存空间 mm_struct 直接赋值给内核线程 task_struct->active_mm 中 。
struct task_struct {
// 内核线程的 active_mm 指向前一个进程的地址空间
// 普通进程的 active_mm 指向 null
struct mm_struct *active_mm;
}
因为内核线程不会访问用户空间的内存,它仅仅只会访问内核空间的内存,所以直接复用上一个用户态进程页表的内核部分就可以避免为内核线程分配 mm_struct 和相关页表的开销,以及避免内核线程之间调度时地址空间的切换开销。
通过一张单级页表的例子,分别从进程用户态和内核态的角度阐述了页表是如何表达虚拟内存与物理内存之间的映射关系的。在我们清楚了页表这个概念之后,下面准备继续带大家去看一下页表的演化过程,那么在这前,我们先来分析下单级页表有哪些不足,近而导致进程的页表体系需要向前演进。
从单级页表到多级页表
经过上小节内容的介绍我们知道,页表的本质其实就是一个物理内存页,一张页表 4K 大小,下面我们以 32 位系统来举例说明,在 32 位系统中,页表中的一个 PTE 占用 4B 大小,所以一张页表可以容纳 1024 个 PTE。
在进程中虚拟内存与物理内存的映射是以页为单位的,进程虚拟内存空间中的一个虚拟内存页映射物理内存空间的一个物理内存页,这种映射关系以及访存权限都保存在 PTE 中,所以进程中的一个虚拟内存页对应页表中的一个 PTE,一个 PTE 能够映射 4K 的物理内存(一个物理内存页)。
一张页表里边可以容纳 1024 个 PTE,一个 PTE 可以映射 4K 的物理内存,那么一张页表就可以映射 1024 * 4K = 4M
大小的物理内存 ,而页表本质上是一个物理内存页(4K大小),所以内核需要用额外的 4K 大小的物理内存去映射 4M 的物理内存。
假设我们现在系统中有 4G 的物理内存,一张页表能够映射 4M 大小的物理内存,而为了映射这 4G 的物理内存,我们需要 1024 张页表,一张页表占用 4K 物理内存,所以为了映射 4G 的物理内存,我们额外需要 4M 的物理内存(1024张页表)来映射。
更要命的是这 4M 物理内存(1024张页表)还必须是连续的,因为页表是单级的,而页表相当于是 PTE 的数组,进程虚拟内存空间中的一个虚拟内存页对应一个 PTE,而 PTE 在页表这个数组中的索引 index 就保存在虚拟内存地址中,内核通过页表的起始地址加上这个索引 index 才能定位到虚拟内存页对应的 PTE,近而通过 PTE 定位到映射的物理内存页。
如果这 4M 物理内存(1024张页表)不是连续的,那么我们就无法通过访问数组的方式定位 PTE 了。而系统经过长时间运行之后,由于内存碎片的原因,是很难找到这么大一片连续的物理内存的。
大家需要注意的是,这 4M 的连续物理内存还只是一个进程所需要的,因为进程的虚拟内存空间都是独立的,页表也是独立的,一个进程就需要额外的 4M 连续物理内存(1024张页表)来支持进程内独立的内存映射关系。假如在系统中跑上 100 个进程,那总共就需要额外的 400M 连续的物理内存。这对于一个只有 4G 物理内存,单级页表的系统来说,无疑是巨大的开销和浪费。
在进程启动的时候就为它分配 4M 的页表这确实是比较大的开销,这一点是没错的,但是为什么说是一种浪费呢?
如果进程一启动就立马会访问全部的 4G 物理内存,那么的确需要在一开始就为进程分配 4M 的连续物理内存来存放页表,那这一点开销无论多么大都是必须的,不能省的,否则进程将无法运行。
但程序的局部性原理告诉我们,进程在运行之后,对于内存的访问不会一下子就要访问全部的内存,相反进程对于内存的访问会表现出明显的倾向性,更加倾向于访问最近访问过的数据以及热点数据附近的数据。
所以无论一个进程在实际运行过程中总共需要占用的内存资源有多大,根据程序局部性原理,在某一段时间内,进程真正需要的物理内存其实是很少的一部分,我们只需要为每个进程分配很少的物理内存就可以保证进程的正常执行运转。
既然在某一个特定的时刻,进程只需要很少的物理内存就可以正常运转,那么进程虚拟内存与物理内存之间的映射关系相应也会很少,根本就不需要 4M 的物理内存来保存映射关系。
我们完全可以在进程初始状态下,创建一个最小集的页表,当进程实际确实需要的时候,我们再来创建相应具体的页表,这又是延时分配思想在内核中的另一处应用。
以 4G 的物理内存为例,根据局部性原理我们知道,进程在启动之后的任意时刻都不可能一下子就要访问全部的 4G 物理内存,但是我们需要给进程提供寻址 4G 物理内存的能力,也就是说你先别管我访问不访问,反正 4G 物理内存的寻址能力我是需要的。
所以在单级页表的情况下,我们必须要为进程额外分配 4M 的连续物理内存来存放 1024 张页表,不管进程访问不访问,这 4M 的开销是不能省的。
那么现在我们在拿出一个 4K 的物理内存页作为页表,然后将这个页表放在单级页表的前面,组成一个二级页表的体系,情况会变成什么样呢?
之前和大家强调过,页表的本质是一个物理内存页,页表是 PTE 的数组,而 PTE 的本质是指向其映射的一个物理内存页,既然 PTE 可以指向一个普通的物理内存页,那么它也可以指向一个页表。
根据这个思路,二级页表中的一个 PTE 本质上指向的还是一个物理内存页,只不过这个物理内存页比较特殊,它是一张页表(一级页表),一级页表是用来映射真正的物理内存的,一张一级页表可以映射 4M 物理内存。
这也就是说二级页表中的一个 PTE 就可以映射 4M 物理内存,同样的道理,二级页表中也包含了 1024 个 PTE,所以一张二级页表就可以映射 4G 的物理内存。
虽说二级页表和一级页表本质上都是一样的,它们都是一个物理内存页,但是我们习惯上将二级页表叫做页目录表,用来做一级页表的索引,就好像书中的目录一样,二级页表中的 PTE 我们习惯上称为做页目录项 (Page Directory Entry, PDE)。
因为一张页目录表就可以映射 4G 的物理内存了,所以在二级页表的情况下,我们只需要在进程启动的时候额外为它分配 4K 的连续物理内存就可以了,这相比单级页表下,需要为每个进程额外分配 4M 的连续物理内存节省了非常多宝贵的内存资源。
但进程运行起来肯定会访问内存对吧,要访问内存就需要有映射,在运行过程中光有一张页目录表肯定是不够的,根据程序局部性原理,进程在运行中的任意时刻,只会访问很小一块的内存,比如这时进程需要访问 4K 的物理内存(一个物理内存页),在二级页表情况下,内核会本着你访问多少,我映射多少的原则来进行内存映射,下面我们来一起看看二级页表下的映射过程并与一级页表对比下内存消耗。
当前系统中,进程只有一张页目录表,页目录表里的 PDE 没有映射任何东西,这时进程需要访问一个物理内存页,而对物理内存页的映射任务主要是在一级页表的 PTE 中,所以现在首要的任务就是建立一张一级页表出来,并用页目录表索引起来。
在二级页表的情况下,内核只需要一张 4K 的页目录表和一张 4K 的一级页表总共 8K 的内存就可以支持进程访问一个 4K 物理页面了,而根据程序的空间局部性原理,在不久的将来,进程只会访问与该物理内存页临近的页面,所以事实上,即使进程访问 4M 的内存,依然只需要一张 4K 的页目录表和一张 4K 的一级页表就可以满足了。
当进程需要访问下一个 4M 的物理内存时,这时候第一个一级页表已经映射满了,那就需要再创建第二张页表用来映射下一个 4M 的物理内存,当然了,第二张页表依然需要索引在页目录表的 PDE 中。
这时候内核就需要一张页目录表和两张一级页表共 12K 额外的物理内存来映射,这依然比单级页表的 4M 连续物理内存开销小很多。
同理,随着进程一个 4M 接着一个 4M 物理内存的访问,在极端的情况下整个页目录表都被映射满了,这时候内核就需要 4K(页目录表)+ 4M(1024张一级页表)的额外内存来保存映射关系了,这种情况下看起来会比单级页表下的 4M 内存开销大了那么一点点,但这种属于极端情况,非常少见,极大部分情况下还是比单级页表开销少很多很多的。
而且在二级页表体系下,上面极端情况中的这 1024 张一级页表不需要是连续的,因为我们只需要保证顶级页表(这里指页目录表)是连续的就可以了,通过页目录表中的 PDE 可以唯一索引到一张一级页表的起始物理内存地址,而页表内肯定是连续的 4K 物理内存,所以依然可以通过数组的方式索引到一级页表中的 PTE,近而找到其映射的物理内存页面。
除此之外二级页表体系还有一个优势,就是当内存紧张的时候,那些不经常使用的一级页表可以被 swap out 到磁盘中,当进程再次访问到该页表映射的物理内存时,内核在将页表从磁盘中 swap in 到内存中。当然了,顶级页表(这里指页目录表)必须是常驻内存的,不允许 swap 。
既然页表的本质是一个物理内存页,那么同理,进程经常访问的那些页表也会被缓存到 CPU 高速缓存中加速下一次的访问速度。
从单级页表演进到二级页表之后,虚拟内存寻址的底层逻辑还是一样的,只不过现在的顶级页表变成了页目录表(Page Directory), cr3 寄存器现在存放的是页目录表的起始物理内存地址。
通过虚拟内存地址定位 PTE 的步骤由原来的一步变成了现在的两步,因为我们多加了一级页目录表,所以现在需要首先定位页目录表中的 PDE,然后通过 PDE 定位到具体的页表,近而找到页表中的 PTE。
所以在二级页表体系下的虚拟内存地址的格式也就发生了变化,单级页表下虚拟内存地址中只需要保存页表中的 PTE 偏移即可,二级页表下虚拟内存地址还需要多保存一份页目录表中 PDE 的偏移。
二级页表应用在 32 位系统中,相应的虚拟内存地址由 32 位 bit 组成,在 32 位系统中页目录表中的 PDE 和页表中的 PTE 均占用 4 个字节。而前边我们也介绍过了,页目录表和页表的本质其实就是一个物理内存页,它们分别占用 4K 大小。
因此一张页目录表中有 1024 个 PDE,要寻址这 1024 个 PDE 用 10 个 bit 就可以了,所以在上图中的虚拟内存地址中的 页目录表中 PDE 偏移
部分占用 10 个 bit 位。
同样的道理,一张页表中有 1024 个 PTE, 要寻址这个 1024 个 PTE 也是需要 10 个 bit,上图中虚拟内存地址中的 一级页表中 PTE 偏移
部分也需要占用 10 个 bit 位。
这样一来我们就可以通过虚拟内存地址中的前 10 个 bit 定位到页目录表中的 PDE ,而 PDE 指向的是一级页表的起始物理内存地址,然后我们通过接下来的 10 个 bit 就可以定位到页表中的 PTE,而 PTE 指向的是虚拟内存页最终映射的物理内存页的起始地址。
现在我们找到物理内存页了,那么如何在物理内存页中找到我们要访问的字节呢 ?这就需要上图虚拟内存地址中的最后一部分 物理内存页内偏移
了,因为一个物理内存页占用 4K 大小,我们用 12 位 bit 就可以寻址内存页中的任意字节了。
这样加起来,刚好可以组成一个 32 位的虚拟内存地址,在我们清楚了二级页表下的虚拟内存地址格式之后,接下来我们就来看下二级页表体系下的寻址过程
- 当 CPU 访问进程虚拟内存空间中的一个地址时,会先从 cr3 寄存器中拿出页目录表的起始物理内存地址,然后从虚拟内存地址中解析出前 10 bit 的内容作为页目录表中 PDE 的偏移,通过公式
页目录表起始地址 + 页目录表内偏移 * sizeof(PDE)
就可以定位到该虚拟内存页在页目录表中的 PDE 了。 - PDE 中保存了其指向的一级页表的起始物理内存地址,我们在从虚拟内存地址中解析出下一个 10 bit 作为页表中 PTE 的偏移,然后通过公式
页表起始地址 + 页表内偏移 * sizeof(PTE)
就能定位到虚拟内存页在一级页表中的 PTE 了。 - PTE 中保存了最终映射的物理内存页的起始地址,最后我们从虚拟内存地址中解析出最后 12 个 bit,最终定位到虚拟内存地址对应的物理字节上。
32 位页目录项 PTE 定义
在进程的虚拟内存空间中,每一个虚拟内存页在页表中都有一个 PTE 与之对应,在 32 位系统中,每个 PTE 占用 4 个字节大小,其中保存了虚拟内存页背后映射的物理内存页的起始地址,以及进程访问物理内存的一些权限标识位。
PTE 在内核中是用 unsigned long
类型描述的,在 32 位系统中占用 4 个字节:
typedef unsigned long pteval_t;
typedef struct { pteval_t pte; } pte_t;
下面是 PTE 中 32 bit (4 字节) 的布局格式:
由于内核将整个物理内存划分为一页一页的单位,每个物理内存页大小为 4K,所以物理内存页的起始地址都是按照 4K 对齐的,也就导致物理内存页的起始地址的后 12 位全部是 0,我们只需要在 PTE 中存储物理内存地址的高 20 位就可以了,剩下的低 12 位可以用来标记一些权限位。
32 位页目录项 PDE 定义
同 PTE 一样,PDE 在 32 位系统中也是用 unsigned long
类型来描述的,同样也是占用 4 个字节大小。
typedef unsigned long pgdval_t;
PDE 是用来指向一级页表的起始物理内存地址的,而页表的本质是一个物理内存页(4K 大小),因此页表的起始内存地址也是按照 4K 对齐的,后 12 位全部为 0 ,我们可以继续用 PDE 的低 12 位来标记页目录项的权限位:
64位系统的4级页表
在 32 位系统中,内核主要采用二级页表体系来进行虚拟内存寻址,但是到了 64 位系统中,二级页表明显就不够用了,因为二级页表最多只能映射 4G 的物理内存空间,而 64 位系统中,进程的虚拟寻址空间是巨大的,进程的用户态需要寻址 128T 的虚拟内存空间,内核态也有 128T 的虚拟内存空间。
为了能够寻址这么大的虚拟内存空间,内核在 64 位系统中引入了四级页表体系,当我们清楚了二级页表的虚拟寻址过程,四级页表就很简单了,不就是多引入了两级页目录么,前面小节介绍的多级页表的本质还是不变的。
但是在内核中一般不这么叫,内核中称上图中的四级页表为全局页目录 PGD(Page Global Directory),PGD 中的页目录项叫做 pgd_t,PGD 是四级页表体系下的顶级页表,保存在进程 struct mm_struct 结构中的 pgd 属性中,在进程调度上下文切换的时候,由内核通过 load_new_mm_cr3 方法将 pgd 中保存的顶级页表虚拟内存地址转换物理内存地址,随后加载到 cr3 寄存器中,从而完成进程虚拟内存空间的切换。
上图中的三级页表在内核中称之为上层页目录 PUD(Page Upper Directory),PUD 中的页目录项叫做 pud_t 。
二级页表在这里也改了一个名字叫做中间页目录 PMD(Page Middle Directory),PMD 中的页目录项叫做 pmd_t,最底层的用来直接映射物理内存页面的一级页表,名字不变还叫做页表(Page Table)
由于在四级页表体系下,又多引入了两层页目录(PGD,PUD),所以导致其通过虚拟内存地址定位 PTE 的步骤又增加了两步,首先需要定位顶级页表 PGD 中的页目录项 pgd_t,pgd_t 指向的 PUD 的起始内存地址,然后在定位 PUD 中的页目录项 pud_t,后面的流程就和二级页表一样了。
32 位系统中的页目录表,页表和 64 位系统中的页目录表,页表在内核中都是使用一个普通 4K 的物理内存页存储映射关系的,不同的是 64 位系统中的页表中的 PTE 以及页目录表(PGD,PUD,PMD)中的 PDE 都是占用 8 个字节,在内核中都是使用 unsigned long
类型描述:
// 定义在内核文件:/arch/x86/include/asm/pgtable_64_types.h
typedef unsigned long pteval_t;
typedef unsigned long pmdval_t;
typedef unsigned long pudval_t;
typedef unsigned long pgdval_t;
typedef struct { pteval_t pte; } pte_t;
// 定义在内核文件:/arch/x86/include/asm/pgtable_types.h
typedef struct { pmdval_t pmd; } pmd_t;
typedef struct { pudval_t pud; } pud_t;
typedef struct { pgdval_t pgd; } pgd_t;
页表存在的内存寻址
当 CPU 访问一个进程虚拟内存空间中的某个虚拟内存地址之后,操作系统背后到底发生了什么。
经过本文前边内容的介绍,上图中的这个四级页表的遍历过程,我们已经非常的清楚了,我们可以明显的体会到整个地址翻译的过程需要的步骤还是比较多的,而 CPU 访问内存的操作是非常非常频繁的,如果我们采用内核这种软件的方式对页表进行遍历,效率会非常的差。
而采用一种专门的硬件来对软件进行加速,无疑是一种最简单,最直接有效的优化手段,于是在 CPU 中引入了一个专门对页表进行遍历的地址翻译硬件 MMU(Memory Management Unit),有了 MMU 硬件的加持整个地址翻译的过程就非常的快了。
事实上,上图中展示的四级页表的整个遍历操作均是在 MMU 中进行的:
- 存储控制器将读取到的数据放到存储总线传输上,随后 IO bridge 将存储总线上的数据信号转换为系统总线上的数据信号,然后继续沿着系统总线传递。
- CPU 感受到系统总线上的数据信号之后,将数据从系统总线上读取出来并拷贝到寄存器中,随后通过 ALU 完成计算。
不同架构的MMU都是有区别的我们可以同通过不同arch的switch_mm实现方式看出这个架构的mmu设置页目录的方式。
匿名映射缺页异常
与匿名页相对应的是文件页,文件页我们应该很好理解,就是映射文件的页,如:通过mmap映射文件到虚拟内存然后读文件数据,进程的代码数据段等,这些页有后备缓存也就是块设备上的文件,而匿名页就是没有关联到文件的页,如:进程的堆、栈等。还有一点需要注意:下面讨论的都是私有的匿名页的情况,共享匿名页在内核演变为文件映射缺页异常(伪文件系统)。
前面我们讲解了什么是匿名页,那么思考一下什么情况下会触发匿名映射缺页异常呢?这种异常对于我们来说非常常见:
1.当我们应用程序使用malloc来申请一块内存(堆分配),在没有使用这块内存之前,仅仅是分配了虚拟内存,并没有分配物理内存,第一次去访问的时候才会通过触发缺页异常来分配物理页建立和虚拟页的映射关系。
2.当我们应用程序使用mmap来创建匿名的内存映射的时候,页同样只是分配了虚拟内存,并没有分配物理内存,第一次去访问的时候才会通过触发缺页异常来分配物理页建立和虚拟页的映射关系。
3.当函数的局部变量比较大,或者是函数调用的层次比较深,导致了当前的栈不够用了,这个时候需要扩大栈。当然了上面的这几种场景对应应用程序来说是透明的,内核为用户程序做了大量的处理工作,下面会看到如何处理。
当 CPU 访问这段由 mmap 映射出来的虚拟内存区域 vma 中的任意虚拟地址时,MMU 在遍历进程页表的时候就会发现,该虚拟内存地址在进程顶级页目录 PGD(Page Global Directory)中对应的页目录项 pgd_t 是空的,该 pgd_t 并没有指向其下一级页目录 PUD(Page Upper Directory)。
也就是说,此时进程页表中只有一张顶级页目录表 PGD,而上层页目录 PUD(Page Upper Directory),中间页目录 PMD(Page Middle Directory),一级页表(Page Table)内核都还没有创建。
由于现在被访问到的虚拟内存地址对应的 pgd_t 是空的,进程的四级页表体系还未建立,所以 MMU 会产生一个缺页中断,进程从用户态转入内核态来处理这个缺页异常。
此时 CPU 会将发生缺页异常时,进程正在使用的相关寄存器中的值压入内核栈中。比如,引起进程缺页异常的虚拟内存地址会被存放在 CR2 寄存器中。同时 CPU 还会将缺页异常的错误码 error_code 压入内核栈中。
随后内核会在 do_page_fault 函数中来处理缺页异常,该函数的参数都是内核在处理缺页异常的时候需要用到的基本信息:
do_page_fault(struct pt_regs *regs, unsigned long error_code, unsigned long address)
struct pt_regs 结构中存放的是缺页异常发生时,正在使用中的寄存器值的集合。address 表示触发缺页异常的虚拟内存地址。
error_code 是对缺页异常的一个描述,目前内核只使用了 error_code 的前六个比特位来描述引起缺页异常的具体原因,后面比特位的含义我们先暂时忽略。
do_page_fault
缺页中断产生的根本原因是由于 CPU 访问的这段虚拟内存背后没有物理内存与之映射,表现的具体形式主要有三种:
- 虚拟内存对应在进程页表体系中的相关各级页目录或者页表是空的,也就是说这段虚拟内存完全没有被映射过。
- 虚拟内存之前被映射过,其在进程页表的各级页目录以及页表中均有对应的页目录项和页表项,但是其对应的物理内存被内核 swap out 到磁盘上了。
- 虚拟内存虽然背后映射着物理内存,但是由于对物理内存的访问权限不够而导致的保护类型的缺页中断。比如,尝试去写一个只读的物理内存页。
虽然缺页中断产生的原因多种多样,内核也会根据不同的缺页原因进行不同的处理,但不管怎么说,一切的起点都是从 CPU 访问虚拟内存开始的。
既然进程虚拟内存地址范围有用户空间与内核空间之分,那么当 CPU 访问虚拟内存地址时产生的缺页中断也要区分下是用户空间产生的缺页还是内核空间产生的缺页。
static int fault_in_kernel_space(unsigned long address)
{
/*
* On 64-bit systems, the vsyscall page is at an address above
* TASK_SIZE_MAX, but is not considered part of the kernel
* address space.
*/
if (IS_ENABLED(CONFIG_X86_64) && is_vsyscall_vaddr(address))
return false;
// 在进程虚拟内存空间中,TASK_SIZE_MAX 以上的虚拟地址均属于内核空间
return address >= TASK_SIZE_MAX;
}
当引起缺页中断的虚拟内存地址 address 是在 TASK_SIZE_MAX 之上时,表示该缺页地址是属于内核空间的,内核的缺页处理程序 __do_page_fault 就要进入 do_kern_addr_fault 分支去处理内核空间的缺页中断。
当引起缺页中断的虚拟内存地址 address 是在 TASK_SIZE_MAX 之下时,表示该缺页地址是属于用户空间的,内核则进入 do_user_addr_fault 分支处理用户空间的缺页中断。
static noinline void
__do_page_fault(struct pt_regs *regs, unsigned long hw_error_code,
unsigned long address)
{
// mmap_sem 是进程虚拟内存空间 mm_struct 的读写锁
// 内核这里将 mmap_sem 预取到 cacheline 中,并标记为独占状态( MESI 协议中的 X 状态)
prefetchw(¤t->mm->mmap_sem);
// 这里判断引起缺页异常的虚拟内存地址 address 是属于内核空间的还是用户空间的
if (unlikely(fault_in_kernel_space(address)))
// 如果缺页异常发生在内核空间,则由 vmalloc_fault 进行处理
// 这里使用 unlikely 的原因是,内核对内存的使用通常是高优先级的而且使用比较频繁,所以内核空间一般很少发生缺页异常。
do_kern_addr_fault(regs, hw_error_code, address);
else
// 缺页异常发生在用户态
do_user_addr_fault(regs, hw_error_code, address);
}
NOKPROBE_SYMBOL(__do_page_fault);
比如,mmap 内存映射,就是工作在进程用户地址空间中的文件映射与匿名映射区,进程在使用 mmap 申请内存的时候,内核仅仅只是为进程在文件映射与匿名映射区分配一段虚拟内存,重要的物理内存资源不会马上分配,而是延迟到进程真正使用的时候,才会通过缺页中断 __do_page_fault 进入到 do_user_addr_fault 分支进行物理内存资源的分配。
内核空间中的缺页异常主要发生在进程内核虚拟地址空间中 32T 的 vmalloc 映射区,这段区域的虚拟内存地址范围为:0xFFFF C900 0000 0000 – 0xFFFF E900 0000 0000。内核中的 vmalloc 内存分配接口就工作在这个区域,它用于将那些不连续的物理内存映射到连续的虚拟内存上。
这两者的实现太过复杂总的来说就是对于不同的缺页异常原因进行对应处理。
物理内存分配接口
分配接口分析
在为大家介绍物理内存分配之前,先来介绍下内核中用于物理内存分配的几个核心接口,这几个物理内存分配接口全部是基于伙伴系统的,伙伴系统有一个特点就是它所分配的物理内存页全部都是物理上连续的,并且只能分配 2 的整数幂个页,这里的整数幂在内核中称之为分配阶。
下面要介绍的这些物理内存分配接口均需要指定这个分配阶,意思就是从伙伴系统申请多少个物理内存页,假设我们指定分配阶为 order,那么就会从伙伴系统中申请 2 的 order 次幂个物理内存页。
内核中提供了一个 alloc_pages 函数用于分配 2 的 order 次幂个物理内存页,参数中的 unsigned int order 表示向底层伙伴系统指定的分配阶,参数 gfp_t gfp 是内核中定义的一个用于规范物理内存分配行为的修饰符,这里我们先不展开,后面的小节中笔者会详细为大家介绍。
struct page *alloc_pages(gfp_t gfp, unsigned int order);
alloc_pages 函数用于向底层伙伴系统申请 2 的 order 次幂个物理内存页组成的内存块,该函数返回值是一个 struct page 类型的指针用于指向申请的内存块中第一个物理内存页。
alloc_pages 函数用于分配多个连续的物理内存页,在内核的某些内存分配场景中有时候并不需要分配这么多的连续内存页,而是只需要分配一个物理内存页即可,于是内核又提供了 alloc_page 宏,用于这种单内存页分配的场景,我们可以看到其底层还是依赖了 alloc_pages 函数,只不过 order 指定为 0。
#define alloc_page(gfp_mask) alloc_pages(gfp_mask, 0)
当系统中空闲的物理内存无法满足内存分配时,就会导致内存分配失败,alloc_pages,alloc_page 就会返回空指针 NULL 。
vmalloc 分配机制底层就是用的 alloc_page
在物理内存分配成功的情况下, alloc_pages,alloc_page 函数返回的都是指向其申请的物理内存块第一个物理内存页 struct page 指针。
大家可以直接理解成返回的是一块物理内存,而 CPU 可以直接访问的却是虚拟内存,所以内核又提供了一个函数 __get_free_pages ,该函数直接返回物理内存页的虚拟内存地址。用户可以直接使用。
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);
__get_free_pages 函数在使用方式上和 alloc_pages 是一样的,函数参数的含义也是一样,只不过一个是返回物理内存页的虚拟内存地址,一个是直接返回物理内存页。
事实上 __get_free_pages 函数的底层也是基于 alloc_pages 实现的,只不过多了一层虚拟地址转换的工作。
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order)
{
struct page *page;
// 不能在高端内存中分配物理页,因为无法直接映射获取虚拟内存地址
page = alloc_pages(gfp_mask & ~__GFP_HIGHMEM, order);
if (!page)
return 0;
// 将直接映射区中的物理内存页转换为虚拟内存地址
return (unsigned long) page_address(page);
}
page_address 函数用于将给定的物理内存页 page 转换为它的虚拟内存地址, 不过这里只适用于内核虚拟内存空间中的直接映射区 ,因为在直接映射区中虚拟内存地址到物理内存地址是直接映射的,虚拟内存地址减去一个固定的偏移就可以直接得到物理内存地址。
如果物理内存页处于高端内存中,则不能这样直接进行转换,在通过 alloc_pages 函数获取物理内存页 page 之后,需要调用 kmap 映射将 page 映射到内核虚拟地址空间中。
同 alloc_page 函数一样,内核也提供了 get_free_page 用于只分配单个物理内存页的场景,底层还是依赖于 get_free_pages 函数,参数 order 指定为 0
#define __get_free_page(gfp_mask)
__get_free_pages((gfp_mask), 0)
当然了,有内存的分配就会有内存的释放,所以内核还提供了两个用于释放物理内存页的函数:
void __free_pages(struct page *page, unsigned int order);
void free_pages(unsigned long addr, unsigned int order);
- __free_pages : 同 alloc_pages 函数对应,用于释放一个或者 2 的 order 次幂个内存页,释放的物理内存区域起始地址由该区域中的第一个 page 实例指针表示,也就是参数里的 struct page *page 指针。
- free_pages:同 get_free_pages 函数对应,与 free_pages 函数的区别是在释放物理内存时,使用了虚拟内存地址而不是 page 指针。
在释放内存时需要非常谨慎小心,我们只能释放属于你自己的内存页,传递了错误的 struct page 指针或者错误的虚拟内存地址,或者传递错了 order 值,都可能会导致系统的崩溃。在内核空间中,内核是完全信赖自己的,这点和用户空间不同。
源码实现
我们看到内存分配的任务最终会落在 alloc_pages 这个接口函数中,在 alloc_pages 中会调用 alloc_pages_node 进而调用 alloc_pages_node 函数,最终通过 alloc_pages 函数,这些函数的代码大多位于mm/page_alloc.c文件中。正式进入内核内存分配的世界~~
__alloc_pages 函数为 Linux 内核内存分配的核心入口函数
static inline struct page *alloc_pages(gfp_t gfp_mask, unsigned int order)
{
return alloc_pages_node(numa_node_id(), gfp_mask, order);
}
static inline struct page *
__alloc_pages_node(int nid, gfp_t gfp_mask, unsigned int order)
{
// 校验指定的 NUMA 节点 ID 是否合法,不要越界
VM_BUG_ON(nid < 0 || nid >= MAX_NUMNODES);
// 指定节点必须是有效在线的
VM_WARN_ON((gfp_mask & __GFP_THISNODE) && !node_online(nid));
return __alloc_pages(gfp_mask, order, nid, NULL);
}
在我们进入 __alloc_pages 函数之前,了解一下内存水线的概念:
#define ALLOC_WMARK_MIN WMARK_MIN
#define ALLOC_WMARK_LOW WMARK_LOW
#define ALLOC_WMARK_HIGH WMARK_HIGH
#define ALLOC_NO_WATERMARKS 0x04 /* don't check watermarks at all */
#define ALLOC_HARDER 0x10 /* try to alloc harder */
#define ALLOC_HIGH 0x20 /* __GFP_HIGH set */
#define ALLOC_CPUSET 0x40 /* check for correct cpuset */
#define ALLOC_KSWAPD 0x800 /* allow waking of kswapd, __GFP_KSWAPD_RECLAIM set */
我们先来看前四个标识内存水位线的常量含义,这四个内存水位线标识表示内核在分配内存时必须考虑内存的水位线,在不同的水位线下内存的分配行为也会有所不同。
内核会为 NUMA 节点中的每个物理内存区域 zone 定制三条用于指示内存容量的水位线,它们分别是:WMARK_MIN(页最小阈值), WMARK_LOW (页低阈值),WMARK_HIGH(页高阈值)。
这三个水位线定义在 /include/linux/mmzone.h
文件中:
enum zone_watermarks {
WMARK_MIN,
WMARK_LOW,
WMARK_HIGH,
NR_WMARK
};
三条水位线对应的 watermark 具体数值存储在每个物理内存区域 struct zone 结构中的 _watermark[NR_WMARK] 数组中。
物理内存区域中不同水位线的含义以及内存分配在不同水位线下的行为如下图所示:
- 当该物理内存区域的剩余内存容量高于 _watermark[WMARK_HIGH] 时,说明此时该物理内存区域中的内存容量非常充足,内存分配完全没有压力。
- 当剩余内存容量在 _watermark[WMARK_LOW] 与_watermark[WMARK_HIGH] 之间时,说明此时内存有一定的消耗但是还可以接受,能够继续满足进程的内存分配需求。
- 当剩余内存容量在 _watermark[WMARK_MIN] 与 _watermark[WMARK_LOW] 之间时,说明此时内存容量已经有点危险了,内存分配面临一定的压力,但是还可以满足进程此时的内存分配要求,当给进程分配完内存之后,就会唤醒 kswapd 进程开始内存回收,直到剩余内存高于 _watermark[WMARK_HIGH] 为止。
在这种情况下,进程的内存分配会触发内存回收,但请求进程本身不会被阻塞,由内核的 kswapd 进程异步回收内存。
- 当剩余内存容量低于 _watermark[WMARK_MIN] 时,说明此时的内存容量已经非常危险了,如果进程在这时请求内存分配,内核就会进行 直接内存回收 ,这时内存回收的任务将会由请求进程同步完成。
注意:上面提到的物理内存区域 zone 的剩余内存是需要刨去 lowmem_reserve 预留内存大小(用于紧急内存分配)。也就是说 zone 里被伙伴系统所管理的内存并不包含 lowmem_reserve 预留内存。
可以通过cat /proc/zoneinfo查看不同节点内存水线:
min、low、high 就是上面提到的三条内存水位线:_watermark[WMARK_MIN],_watermark[WMARK_LOW] ,_watermark[WMARK_HIGH]。
WMARK_MIN,WMARK_LOW ,WMARK_HIGH 这三个水位线的数值是通过内核参数 /proc/sys/vm/min_free_kbytes
为基准分别计算出来的,用户也可以通过 sysctl
来动态设置这个内核参数。
__alloc_pages
物理内存分配的核心函数 __alloc_pages ,从下面内核源码的注释中我们可以看出,这个函数正是伙伴系统的核心心脏,它是内核内存分配的核心入口函数,整个内存分配的完整过程全部封装在这里。
mm/page_alloc.c
/*
* This is the 'heart' of the zoned buddy allocator.
*/
struct page *__alloc_pages(gfp_t gfp, unsigned int order, int preferred_nid,
nodemask_t *nodemask)
{
// 用于指向分配成功的内存
struct page *page;
// 内存区域中的剩余内存需要在 WMARK_LOW 水位线之上才能进行内存分配,否则失败(初次尝试快速内存分配)
unsigned int alloc_flags = ALLOC_WMARK_LOW;
// 之前小节中介绍的内存分配掩码集合
gfp_t alloc_gfp;
// 用于在不同内存分配辅助函数中传递参数
struct alloc_context ac = { };
// 检查用于向伙伴系统申请内存容量的分配阶 order 的合法性
// 内核定义最大分配阶 MAX_ORDER -1 = 10,也就是说一次最多只能从伙伴系统中申请 1024 个内存页。
if (WARN_ON_ONCE_GFP(order >= MAX_ORDER, gfp))
return NULL;
// 表示在内存分配期间进程可以休眠阻塞
gfp &= gfp_allowed_mask;
alloc_gfp = gfp;
// 初始化 alloc_context,并为接下来的快速内存分配设置相关 gfp
if (!prepare_alloc_pages(gfp, order, preferred_nid, nodemask, &ac,
&alloc_gfp, &alloc_flags))
// 提前判断本次内存分配是否能够成功,如果不能则尽早失败
return NULL;
// 避免内存碎片化的相关分配标识设置,可暂时忽略
alloc_flags |= alloc_flags_nofragment(ac.preferred_zoneref->zone, gfp);
// 内存分配快速路径:第一次尝试从底层伙伴系统分配内存,注意此时是在 WMARK_LOW 水位线之上分配内存
page = get_page_from_freelist(alloc_gfp, order, alloc_flags, &ac);
if (likely(page))
// 如果内存分配成功则直接返回
goto out;
// 流程走到这里表示内存分配在快速路径下失败
// 这里需要恢复最初的内存分配标识设置,后续会尝试更加激进的内存分配策略
alloc_gfp = gfp;
// 恢复最初的 node mask 因为它可能在第一次内存分配的过程中被改变
// 本函数中 nodemask 起初被设置为 null
ac.nodemask = nodemask;
// 在第一次快速内存分配失败之后,说明内存已经不足了,内核需要做更多的工作
// 比如通过 kswap 回收内存,或者直接内存回收等方式获取更多的空闲内存以满足内存分配的需求
// 所以下面的过程称之为慢速分配路径
page = __alloc_pages_slowpath(alloc_gfp, order, &ac);
out:
// 内存分配成功,直接返回 page。否则返回 NULL
return page;
}
__alloc_pages 函数中的内存分配整体逻辑如下:
- 首先内核会尝试在内存水位线 WMARK_LOW 之上快速的进行一次内存分配。这一点我们从开始的
unsigned int alloc_flags = ALLOC_WMARK_LOW
语句中可以看得出来。
- 校验本次内存分配指定伙伴系统的分配阶 order 的有效性,伙伴系统在内核中的最大分配阶定义在
/include/linux/mmzone.h
文件中,最大分配阶 MAX_ORDER -1 = 10,也就是说一次最多只能从伙伴系统中申请 1024 个内存页,对应 4M 大小的连续物理内存。 - 调用 prepare_alloc_pages 初始化 alloc_context ,用于在不同内存分配辅助函数中传递内存分配参数。为接下来即将进行的快速内存分配做准备。
- 调用 get_page_from_freelist 方法首次尝试在伙伴系统中进行内存分配,这次内存分配比较快速,只是快速的扫描一下各个内存区域中是否有足够的空闲内存能够满足本次内存分配,如果有则立马从伙伴系统中申请,如果没有立即返回, page 设置为 null,进行后续慢速内存分配处理。
这里需要注意的是:首次尝试的快速内存分配是在 WMARK_LOW 水位线之上进行的。
- 当快速内存分配失败之后,情况就会变得非常复杂,内核将不得不做更多的工作,比如开启 kswapd 进程异步内存回收,更极端的情况则需要进行直接内存回收,或者直接内存整理以获取更多的空闲连续内存。这一切的复杂逻辑全部封装在 __alloc_pages_slowpath 函数中。
- alloc_pages_slowpath 函数复杂在于需要结合前边小节中介绍的 GFP ,ALLOC * 这些内存分配标识,根据不同的标识进入不同的内存分配逻辑分支,涉及到的情况比较繁杂。这里大家只需要简单了解,后面笔者会详细介绍~~~
以上介绍的 __alloc_pages 函数内存分配逻辑以及与对应的内存水位线之间的关系如下图所示:
alloc_pages_slowpath
正如前边小节我们提到的那样,alloc_pagesslowpath 函数非常的复杂,其中包含了内存分配的各种异常情况的处理,并且会根据前边介绍的 GFP,ALLOC_ 等各种内存分配策略掩码进行不同分支的处理,这样就变得非常的庞大而繁杂。
alloc_pages_slowpath 函数包含了整个内存分配的核心流程,本身非常的繁杂庞大。
下面这段伪代码是提取出来的 alloc_pages_slowpath 函数的主干框架,其中包含的一些核心分支以及核心步骤都通过注释的形式为大家标注出来了,这里我先从总体上大概浏览下 alloc_pages_slowpath 主要分为哪几个逻辑处理模块,它们分别处理了哪些事情。
static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
struct alloc_context *ac)
{
......... 初始化慢速内存分配路径下的相关参数 .......
retry_cpuset:
......... 调整内存分配策略 alloc_flags 采用更加激进方式获取内存 ......
......... 此时内存分配主要是在进程所允许运行的 CPU 相关联的 NUMA 节点上 ......
......... 内存水位线下调至 WMARK_MIN ...........
......... 唤醒所有 kswapd 进程进行异步内存回收 ...........
......... 触发直接内存整理 direct_compact 来获取更多的连续空闲内存 ......
retry:
......... 进一步调整内存分配策略 alloc_flags 使用更加激进的非常手段进行内存分配 ...........
......... 在内存分配时忽略内存水位线 ...........
......... 触发直接内存回收 direct_reclaim ...........
......... 再次触发直接内存整理 direct_compact ...........
......... 最后的杀手锏触发 OOM 机制 ...........
nopage:
......... 经过以上激进的内存分配手段仍然无法满足内存分配就会来到这里 ......
......... 如果设置了 __GFP_NOFAIL 不允许内存分配失败,则不停重试上述内存分配过程 ......
fail:
......... 内存分配失败,输出告警信息 ........
warn_alloc(gfp_mask, ac->nodemask,
"page allocation failure: order:%u", order);
got_pg:
......... 内存分配成功,返回新申请的内存块 ........
return page;
}
初始化
static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
struct alloc_context *ac)
{
// 在慢速内存分配路径中可能会导致内核进行直接内存回收
// 这里设置 __GFP_DIRECT_RECLAIM 表示允许内核进行直接内存回收
bool can_direct_reclaim = gfp_mask & __GFP_DIRECT_RECLAIM;
// 本次内存分配是否是针对大量内存页的分配,内核定义 PAGE_ALLOC_COSTLY_ORDER = 3
// 也就是说内存请求内存页的数量大于 2 ^ 3 = 8 个内存页时,costly_order = true,后续会影响是否进行 OOM
const bool costly_order = order > PAGE_ALLOC_COSTLY_ORDER;
// 用于指向成功申请的内存
struct page *page = NULL;
// 内存分配标识,后续会根据不同标识进入到不同的内存分配逻辑处理分支
unsigned int alloc_flags;
// 后续用于记录直接内存回收了多少内存页
unsigned long did_some_progress;
// 关于内存整理相关参数
enum compact_priority compact_priority;
enum compact_result compact_result;
int compaction_retries;
// 记录重试的次数,超过一定的次数(16次)则内存分配失败
int no_progress_loops;
// 临时保存调整后的内存分配策略
int reserve_flags;
// 流程现在来到了慢速内存分配这里,说明快速分配路径已经失败了
// 内核需要对 gfp_mask 分配行为掩码做一些修改,修改为一些更可能导致内存分配成功的标识
// 因为接下来的直接内存回收非常耗时可能会导致进程阻塞睡眠,不适用原子 __GFP_ATOMIC 内存分配的上下文。
if (WARN_ON_ONCE((gfp_mask & (__GFP_ATOMIC|__GFP_DIRECT_RECLAIM)) ==
(__GFP_ATOMIC|__GFP_DIRECT_RECLAIM)))
gfp_mask &= ~__GFP_ATOMIC;
retry_cpuset:
retry:
nopage:
fail:
got_pg:
}
在内核进入慢速内存分配路径之前,首先会在这里初始化后续内存分配需要的参数,由于已经在各个字段上标注了丰富的注释,所以这里笔者只对那些难以理解的核心参数为大家进行相关细节的铺垫,这里大家对这些参数有个大概印象即可,后续在使用到的时候,笔者还会再次提起~~~
首先我们看 costly_order 参数,order 表示底层伙伴系统的分配阶,内核只能向伙伴系统申请 2 的 order 次幂个内存页,costly 从字面意思上来说表示有一定代价和消耗的,costly_order 连起来就表示在内核中 order 分配阶达到多少,在内核看来就是代价比较大的内存分配行为。
这个临界值就是 PAGE_ALLOC_COSTLY_ORDER 定义在 /include/linux/mmzone.h
文件中:
#define PAGE_ALLOC_COSTLY_ORDER 3
也就是说在内核看来,当请求内存页的数量大于 2 ^ 3 = 8 个内存页时,costly_order = true,内核就认为本次内存分配是一次成本比较大的行为。后续会根据这个参数 costly_order 来决定是否触发 OOM 。
const bool costly_order = order > PAGE_ALLOC_COSTLY_ORDER;
当内存严重不足的时候,内核会开启直接内存回收 direct_reclaim ,参数 did_some_progress 表示经过一次直接内存回收之后,内核回收了多少个内存页。这个参数后续会影响是否需要进行内存分配重试。
no_progress_loops 用于记录内存分配重试的次数,如果内存分配重试的次数超过最大限制 MAX_RECLAIM_RETRIES,则停止重试,开启 OOM。
MAX_RECLAIM_RETRIES 定义在 /mm/internal.h
文件中:
#define MAX_RECLAIM_RETRIES 16
compact_* 相关的参数用于直接内存整理 direct_compact,内核通常会在直接内存回收 direct_reclaim 之前进行一次 direct_compact,如果经过 direct_compact 整理之后有了足够多的空间内存就不需要进行 direct_reclaim 了。
retry_cpuset
在介绍完了内存分配在慢速路径下所需要的相关参数之后,下面就正式来到了 alloc_pages_slowpath 的内存分配逻辑:
static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
struct alloc_context *ac)
{
......... 初始化慢速内存分配路径下的相关参数 .......
retry_cpuset:
// 在之前的快速内存分配路径下设置的相关分配策略比较保守,不是很激进,用于在 WMARK_LOW 水位线之上进行快速内存分配
// 走到这里表示快速内存分配失败,此时空闲内存严重不足了
// 所以在慢速内存分配路径下需要重新设置更加激进的内存分配策略,采用更大的代价来分配内存
alloc_flags = gfp_to_alloc_flags(gfp_mask);
// 重新按照新的设置按照内存区域优先级计算 zonelist 的迭代起点(最高优先级的 zone)
// fast path 和 slow path 的设置不同所以这里需要重新计算
ac->preferred_zoneref = first_zones_zonelist(ac->zonelist,
ac->highest_zoneidx, ac->nodemask);
// 如果没有合适的内存分配区域,则跳转到 nopage , 内存分配失败
if (!ac->preferred_zoneref->zone)
goto nopage;
// 唤醒所有的 kswapd 进程异步回收内存
if (alloc_flags & ALLOC_KSWAPD)
wake_all_kswapds(order, gfp_mask, ac);
// 此时所有的 kswapd 进程已经被唤醒,正在异步进行内存回收
// 之前我们已经在 gfp_to_alloc_flags 方法中重新调整了 alloc_flags
// 换成了一套更加激进的内存分配策略,注意此时是在 WMARK_MIN 水位线之上进行内存分配
// 调整后的 alloc_flags 很可能会立即成功,因此这里先尝试一下
page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);
if (page)
// 内存分配成功,跳转到 got_pg 直接返回 page
goto got_pg;
// 对于分配大内存来说 costly_order = true (超过 8 个内存页),需要首先进行内存整理,这样内核可以避免直接内存回收从而获取更多的连续空闲内存页
// 对于需要分配不可移动的高阶内存的情况,也需要先进行内存整理,防止永久内存碎片
if (can_direct_reclaim &&
(costly_order ||
(order > 0 && ac->migratetype != MIGRATE_MOVABLE))
&& !gfp_pfmemalloc_allowed(gfp_mask)) {
// 进行直接内存整理,获取更多的连续空闲内存防止内存碎片
page = __alloc_pages_direct_compact(gfp_mask, order,
alloc_flags, ac,
INIT_COMPACT_PRIORITY,
&compact_result);
if (page)
goto got_pg;
if (costly_order && (gfp_mask & __GFP_NORETRY)) {
// 流程走到这里表示经过内存整理之后依然没有足够的内存供分配
// 但是设置了 NORETRY 标识不允许重试,那么就直接失败,跳转到 nopage
if (compact_result == COMPACT_SKIPPED ||
compact_result == COMPACT_DEFERRED)
goto nopage;
// 同步内存整理开销太大,后续开启异步内存整理
compact_priority = INIT_COMPACT_PRIORITY;
}
}
retry:
nopage:
fail:
got_pg:
return page;
}
流程走到这里,说明内核在 《3.2 内存分配的心脏 __alloc_pages》小节中介绍的快速路径下尝试的内存分配已经失败了,所以才会走到慢速分配路径这里来。
之前我们介绍到快速分配路径是在 WMARK_LOW 水位线之上进行内存分配,与其相配套的内存分配策略比较保守,目的是快速的在各个内存区域 zone 之间搜索可供分配的空闲内存。
快速分配路径下的失败意味着此时系统中的空闲内存已经不足了,所以在慢速分配路径下内核需要改变内存分配策略,采用更加激进的方式来进行内存分配,首先会把内存分配水位线降低到 WMARK_MIN 之上,然后将内存分配策略调整为更加容易促使内存分配成功的策略。
而内存分配策略相关的调整逻辑,内核定义在 gfp_to_alloc_flags 函数中。
在调整好的新的内存分配策略 alloc_flags 之后,就需要根据新的策略来重新获取可供分配的内存区域 zone。
ac->preferred_zoneref = first_zones_zonelist(ac->zonelist,
ac->highest_zoneidx, ac->nodemask);
从上图中我们可以看出,当剩余内存处于 WMARK_MIN 与 WMARK_LOW 之间时,内核会唤醒所有 kswapd 进程来异步回收内存,直到剩余内存重新回到水位线 WMARK_HIGH 之上。
if (alloc_flags & ALLOC_KSWAPD)
wake_all_kswapds(order, gfp_mask, ac);
到目前为止,内核已经在慢速分配路径下通过 gfp_to_alloc_flags 调整为更加激进的内存分配策略,并将水位线降低到 WMARK_MIN,同时也唤醒了 kswapd 进程来异步回收内存。
此时在新的内存分配策略下进行内存分配很可能会一次性成功,所以内核会首先尝试进行一次内存分配。
page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);
如果首次尝试分配内存失败之后,内核就需要进行直接内存整理 direct_compact 来获取更多的可供分配的连续内存页。
如果经过 direct_compact 之后依然没有足够的内存可供分配,那么就会进入 retry 分支采用更加激进的方式来分配内存。如果内存分配策略设置了 __GFP_NORETRY 表示不允许重试,那么就会直接失败,流程跳转到 nopage 分支进行处理。
retry
内存分配流程来到 retry 分支这里说明情况已经变得非常危急了,在经过 retry_cpuset 分支的处理,内核将内存水位线下调至 WMARK_MIN,并开启了 kswapd 进程进行异步内存回收,触发直接内存整理 direct_compact,在采取了这些措施之后,依然无法满足内存分配的需求。
所以在接下来的分配逻辑中,内核会近一步采取更加激进的非常手段来获取连续的空闲内存,
下面我们来一起看下这部分激进的内容:
static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
struct alloc_context *ac)
{
......... 初始化慢速内存分配路径下的相关参数 .......
retry_cpuset:
......... 调整内存分配策略 alloc_flags 采用更加激进方式获取内存 ......
......... 此时内存分配主要是在进程所允许运行的 CPU 相关联的 NUMA 节点上 ......
......... 内存水位线下调至 WMARK_MIN ...........
......... 唤醒所有 kswapd 进程进行异步内存回收 ...........
......... 触发直接内存整理 direct_compact 来获取更多的连续空闲内存 ......
retry:
// 确保所有 kswapd 进程不要意外进入睡眠状态
if (alloc_flags & ALLOC_KSWAPD)
wake_all_kswapds(order, gfp_mask, ac);
// 流程走到这里,说明在 WMARK_MIN 水位线之上也分配内存失败了
// 并且经过内存整理之后,内存分配仍然失败,说明当前内存容量已经严重不足
// 接下来就需要使用更加激进的非常手段来尝试内存分配(忽略掉内存水位线),继续修改 alloc_flags 保存在 reserve_flags 中
reserve_flags = __gfp_pfmemalloc_flags(gfp_mask);
if (reserve_flags)
alloc_flags = gfp_to_alloc_flags_cma(gfp_mask, reserve_flags);
// 如果内存分配可以任意跨节点分配(忽略内存分配策略),这里需要重置 nodemask 以及 zonelist。
if (!(alloc_flags & ALLOC_CPUSET) || reserve_flags) {
// 这里的内存分配是高优先级系统级别的内存分配,不是面向用户的
ac->nodemask = NULL;
ac->preferred_zoneref = first_zones_zonelist(ac->zonelist,
ac->highest_zoneidx, ac->nodemask);
}
// 这里使用重新调整的 zonelist 和 alloc_flags 在尝试进行一次内存分配
// 注意此次的内存分配是忽略内存水位线的 ALLOC_NO_WATERMARKS
page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);
if (page)
goto got_pg;
// 在忽略内存水位线的情况下仍然分配失败,现在内核就需要进行直接内存回收了
if (!can_direct_reclaim)
// 如果进程不允许进行直接内存回收,则只能分配失败
goto nopage;
// 开始直接内存回收
page = __alloc_pages_direct_reclaim(gfp_mask, order, alloc_flags, ac,
&did_some_progress);
if (page)
goto got_pg;
// 直接内存回收之后仍然无法满足分配需求,则再次进行直接内存整理
page = __alloc_pages_direct_compact(gfp_mask, order, alloc_flags, ac,
compact_priority, &compact_result);
if (page)
goto got_pg;
// 在内存直接回收和整理全部失败之后,如果不允许重试,则只能失败
if (gfp_mask & __GFP_NORETRY)
goto nopage;
// 后续会触发 OOM 来释放更多的内存,这里需要判断本次内存分配是否需要分配大量的内存页(大于 8 ) costly_order = true
// 如果是的话则内核认为即使执行 OOM 也未必会满足这么多的内存页分配需求.
// 所以还是直接失败比较好,不再执行 OOM,除非设置 __GFP_RETRY_MAYFAIL
if (costly_order && !(gfp_mask & __GFP_RETRY_MAYFAIL))
goto nopage;
// 流程走到这里说明我们已经尝试了所有措施内存依然分配失败了,此时内存已经非常危急了。
// 走到这里说明进程允许内核进行重试流程,但在开始重试之前,内核需要判断是否应该进行重试,重试标准:
// 1 如果内核已经重试了 MAX_RECLAIM_RETRIES (16) 次仍然失败,则放弃重试执行后续 OOM。
// 2 如果内核将所有可选内存区域中的所有可回收页面全部回收之后,仍然无法满足内存的分配,那么放弃重试执行后续 OOM
if (should_reclaim_retry(gfp_mask, order, ac, alloc_flags,
did_some_progress > 0, &no_progress_loops))
goto retry;
// 如果内核判断不应进行直接内存回收的重试,这里还需要判断下是否应该进行内存整理的重试。
// did_some_progress 表示上次直接内存回收,具体回收了多少内存页
// 如果 did_some_progress = 0 则没有必要在进行内存整理重试了,因为内存整理的实现依赖于足够的空闲内存量
if (did_some_progress > 0 &&
should_compact_retry(ac, order, alloc_flags,
compact_result, &compact_priority,
&compaction_retries))
goto retry;
// 根据 nodemask 中的内存分配策略判断是否应该在进程所允许运行的所有 CPU 关联的 NUMA 节点上重试
if (check_retry_cpuset(cpuset_mems_cookie, ac))
goto retry_cpuset;
// 最后的杀手锏,进行 OOM,选择一个得分最高的进程,释放其占用的内存
page = __alloc_pages_may_oom(gfp_mask, order, ac, &did_some_progress);
if (page)
goto got_pg;
// 只要 oom 产生了作用并释放了内存 did_some_progress > 0 就不断的进行重试
if (did_some_progress) {
no_progress_loops = 0;
goto retry;
}
nopage:
fail:
warn_alloc(gfp_mask, ac->nodemask,
"page allocation failure: order:%u", order);
got_pg:
return page;
}
retry 分支包含的是更加激进的内存分配逻辑,所以在一开始需要调用 __gfp_pfmemalloc_flags 函数来重新调整内存分配策略,调整后的策略为:后续内存分配会忽略水位线的影响,并且允许内核从紧急预留内存中获取内存。
static inline int __gfp_pfmemalloc_flags(gfp_t gfp_mask)
{
// 如果不允许从紧急预留内存中分配,则不改变 alloc_flags
if (unlikely(gfp_mask & __GFP_NOMEMALLOC))
return 0;
// 如果允许从紧急预留内存中分配,则后面的内存分配会忽略内存水位线的限制
if (gfp_mask & __GFP_MEMALLOC)
return ALLOC_NO_WATERMARKS;
// 当前进程处于软中断上下文并且进程设置了 PF_MEMALLOC 标识
// 则忽略内存水位线
if (in_serving_softirq() && (current->flags & PF_MEMALLOC))
return ALLOC_NO_WATERMARKS;
// 当前进程不在任何中断上下文中
if (!in_interrupt()) {
if (current->flags & PF_MEMALLOC)
// 忽略内存水位线
return ALLOC_NO_WATERMARKS;
else if (oom_reserves_allowed(current))
// 当前进程允许进行 OOM
return ALLOC_OOM;
}
// alloc_flags 不做任何修改
return 0;
}
在调整好更加激进的内存分配策略 alloc_flags 之后,内核会首先尝试从伙伴系统中进行一次内存分配,这时会有很大概率促使内存分配成功。
注意:此次尝试进行的内存分配会忽略内存水位线:ALLOC_NO_WATERMARKS
page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);
如果在忽略内存水位线的情况下,内存依然分配失败,则进行直接内存回收 direct_reclaim 。
page = __alloc_pages_direct_reclaim(gfp_mask, order, alloc_flags, ac,
&did_some_progress);
经过 direct_reclaim 之后,仍然没有足够的内存可供分配的话,那么内核会再次进行直接内存整理 direct_compact 。
page = __alloc_pages_direct_compact(gfp_mask, order, alloc_flags, ac,
compact_priority, &compact_result);
如果 direct_compact 之后还是没有足够的内存,那么现在内核已经处于绝境了,是时候使用杀手锏:触发 OOM 机制杀死得分最高的进程以获取更多的空闲内存。
但是在进行 OOM 之前,内核还是需要经过一系列的判断,这时就用到了我们在 《4.1 初始化内存分配慢速路径下的相关参数》小节中介绍的 costly_order 参数了,它会影响内核是否触发 OOM 。
如果 costly_order = true,表示此次内存分配的内存页大于 8 个页,内核会认为这是一次代价比较大的分配行为,况且此时内存已经非常危急,严重不足。在这种情况下内核认为即使触发了 OOM,也无法获取这么多的内存,依然无法满足内存分配。
所以当 costly_order = true 时,内核不会触发 OOM,直接跳转到 nopage 分支,除非设置了 __GFP_RETRY_MAYFAIL 内存分配策略:
if (costly_order && !(gfp_mask & __GFP_RETRY_MAYFAIL))
goto nopage;
下面内核也不会直接开始 OOM,而是进入到重试流程,在重试流程开始之前内核需要调用 should_reclaim_retry 判断是否应该进行重试,重试标准:
- 如果内核已经重试了 MAX_RECLAIM_RETRIES (16) 次仍然失败,则放弃重试执行后续 OOM。
- 如果内核将所有可选内存区域中的所有可回收页面全部回收之后,仍然无法满足内存的分配,那么放弃重试执行后续 OOM。
如果 should_reclaim_retry = false,后面会进一步判断是否应该进行 direct_compact 的重试。
if (did_some_progress > 0 &&
should_compact_retry(ac, order, alloc_flags,
compact_result, &compact_priority,
&compaction_retries))
goto retry;
did_some_progress 表示上次直接内存回收具体回收了多少内存页,如果 did_some_progress = 0 则没有必要在进行内存整理重试了,因为内存整理的实现依赖于足够的空闲内存量。
当这些所有的重试请求都被拒绝时,杀手锏 OOM 就开始登场了:
page = __alloc_pages_may_oom(gfp_mask, order, ac, &did_some_progress);
if (page)
goto got_pg;
如果 OOM 之后并没有释放内存,那么就来到 nopage 分支处理。
但是如果 did_some_progress > 0 表示 OOM 产生了作用,至少释放了一些内存那么就再次进行重试。
nopage
到现在为止,内核已经尝试了包括 OOM 在内的所有回收内存的措施,但是仍然没有足够的内存来满足分配要求,看上去此次内存分配就要宣告失败了。但是这里还有一定的回旋余地,如果内存分配策略中配置了 __GFP_NOFAIL,则表示此次内存分配非常的重要,不允许失败。内核会在这里不停的重试直到分配成功为止。
完整代码流程
伙伴分配系统
无论是在快路径还是慢路径都会使用下面的函数去申请get_page_from_freelist,这个函数是伙伴分配系统的入口,我们看一下伙伴系统的代码逻辑。
如上图所示,内核会为 NUMA 节点中的每个物理内存区域 zone 分配一个伙伴系统用于管理该物理内存区域 zone 里的空闲内存页。
而伙伴系统的核心数据结构就封装在 struct zone 里。
struct zone {
// 被伙伴系统所管理的物理内存页个数
atomic_long_t managed_pages;
// 伙伴系统的核心数据结构
struct free_area free_area[MAX_ORDER];
}
struct zone 结构中的 managed_pages 用于表示该内存区域内被伙伴系统所管理的物理内存页数量。
managed_pages ,它是通过 present_pages (不包含内存空洞)减去内核为应对紧急情况而预留的物理内存页 reserved_pages 得到的。
伙伴系统的真正核心数据结构就是这个 struct free_area 类型的数组 free_area[MAX_ORDER] 。MAX_ORDER 就是分配阶 order 的最大值减 1。
伙伴系统所分配的物理内存页全部都是物理上连续的,并且只能分配 2 的整数幂个页,这里的整数幂在内核中称之为分配阶 order。
在我们调用物理内存分配接口时,均需要指定这个分配阶 order,意思是从伙伴系统申请多少个物理内存页,假设我们指定分配阶为 order,那么就会从伙伴系统中申请 2 的 order 次幂个物理内存页。
伙伴系统会将物理内存区域中的空闲内存根据分配阶 order 划分出不同尺寸的内存块,并将这些不同尺寸的内存块分别用一个双向链表组织起来。
比如:分配阶 order 为 0 时,对应的内存块就是一个 page。分配阶 order 为 1 时,对应的内存块就是 2 个 pages。依次类推,当分配阶 order 为 n 时,对应的内存块就是 2 的 order 次幂个 pages。
MAX_ORDER – 1 就是内核中规定的分配阶 order 的最大值,定义在 /include/linux/mmzone.h
文件中,最大分配阶 MAX_ORDER – 1 = 10,也就是说一次,最多只能从伙伴系统中申请 1024 个内存页,对应 4M 大小的连续物理内存。
/* Free memory management - zoned buddy allocator. */
#ifndef CONFIG_FORCE_MAX_ZONEORDER
#define MAX_ORDER 11
数组 free_area[MAX_ORDER] 中的索引表示的就是分配阶 order,用于指定对应双向链表组织管理的内存块包含多少个 page。
我们可以通过 cat /proc/buddyinfo
命令来查看 NUMA 节点中不同内存区域 zone 的伙伴系统当前状态:
上图展示了不同内存区域伙伴系统的 free_area[MAX_ORDER] 数组中,不同分配阶对应的内存块个数,从左到右依次是 0 阶,1 阶, …….. ,10 阶对应的双向链表中包含的内存块个数。
伙伴系统会将物理内存区域 zone 中的空闲内存页按照分配阶 order 将相同尺寸的内存块组织在 free_area[MAX_ORDER] 数组中:随后在 struct free_area 结构中伙伴系统近一步根据这些相同尺寸内存块的页面迁移类型 MIGRATE_TYPES,将相同迁移类型的物理页面组织在 free_list[MIGRATE_TYPES] 数组中,最终形成了完整的伙伴系统结构。
我们可以通过 cat /proc/pagetypeinfo
命令可以查看当前各个内存区域中的伙伴系统中不同页面迁移类型以及不同 order 尺寸的内存块个数。
可以看出在内存使用不多的情况下,系统的内存基本分布在大阶的free_area中。
此外伙伴系统涉及到对于大内存块的拆分,小内存块的合并,具体细节这里就不研究了。下面继续看 这个分配的核心函数:get_page_from_freelist
get_page_from_freelist 的核心逻辑其实很简单,就是遍历 struct alloc_context 里的 zonelist,挨个检查各个 NUMA 节点中的物理内存区域是否有足够的空闲内存可以满足本次的内存分配要求,如果可以满足则进入该物理内存区域的伙伴系统中完整真正的内存分配动作。下面我们先来看一下 get_page_from_freelist 函数的完整逻辑:
static struct page *
get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
const struct alloc_context *ac)
{
struct zoneref *z;
// 当前遍历到的内存区域 zone 引用
struct zone *zone;
// 最近遍历的NUMA节点
struct pglist_data *last_pgdat = NULL;
// 最近遍历的NUMA节点中包含的脏页数量是否在内核限制范围内
bool last_pgdat_dirty_ok = false;
// 如果需要避免内存碎片,则 no_fallback = true
bool no_fallback;
retry:
// 是否需要避免内存碎片
no_fallback = alloc_flags & ALLOC_NOFRAGMENT;
z = ac->preferred_zoneref;
// 开始遍历 zonelist,查找可以满足本次内存分配的物理内存区域 zone
for_next_zone_zonelist_nodemask(zone, z, ac->highest_zoneidx,
ac->nodemask) {
// 指向分配成功之后的内存
struct page *page;
// 内存分配过程中设定的水位线
unsigned long mark;
// 检查内存区域所在 NUMA 节点是否在进程所允许的 CPU 上
if (cpusets_enabled() &&
(alloc_flags & ALLOC_CPUSET) &&
!__cpuset_zone_allowed(zone, gfp_mask))
continue;
// 每个 NUMA 节点中包含的脏页数量都有一定的限制。
// 如果本次内存分配是为 page cache 分配的 page,用于写入数据(不久就会变成脏页)
// 这里需要检查当前 NUMA 节点的脏页比例是否在限制范围内允许的
// 如果没有超过脏页限制则可以进行分配,如果已经超过 last_pgdat_dirty_ok = false
if (ac->spread_dirty_pages) {
if (last_pgdat != zone->zone_pgdat) {
last_pgdat = zone->zone_pgdat;
last_pgdat_dirty_ok = node_dirty_ok(zone->zone_pgdat);
}
if (!last_pgdat_dirty_ok)
continue;
}
// 如果内核设置了避免内存碎片标识,在本地节点无法满足内存分配的情况下(因为需要避免内存碎片)
// 这轮循环会遍历 remote 节点(跨NUMA节点)
if (no_fallback && nr_online_nodes > 1 &&
zone != ac->preferred_zoneref->zone) {
int local_nid;
// 如果本地节点分配内存失败是因为避免内存碎片的原因,那么会继续回到本地节点进行 retry 重试同时取消 ALLOC_NOFRAGMENT(允许引入碎片)
local_nid = zone_to_nid(ac->preferred_zoneref->zone);
if (zone_to_nid(zone) != local_nid) {
// 内核认为保证本地的局部性会比避免内存碎片更加重要
alloc_flags &= ~ALLOC_NOFRAGMENT;
goto retry;
}
}
// 获取本次内存分配需要考虑到的内存水位线,快速路径下是 WMARK_LOW, 慢速路径下是 WMARK_MIN
mark = wmark_pages(zone, alloc_flags & ALLOC_WMARK_MASK);
// 检查当前遍历到的 zone 里剩余的空闲内存容量是否在指定水位线 mark 之上
// 剩余内存容量在水位线之下返回 false
if (!zone_watermark_fast(zone, order, mark,
ac->highest_zoneidx, alloc_flags,
gfp_mask)) {
int ret;
// 如果本次内存分配策略是忽略内存水位线,那么就在本次遍历到的zone里尝试分配内存
if (alloc_flags & ALLOC_NO_WATERMARKS)
goto try_this_zone;
// 如果本次内存分配不能忽略内存水位线的限制,那么就会判断当前 zone 所属 NUMA 节点是否允许进行内存回收
if (!node_reclaim_enabled() ||
!zone_allows_reclaim(ac->preferred_zoneref->zone, zone))
// 不允许进行内存回收则继续遍历下一个 NUMA 节点的内存区域
continue;
// 针对当前 zone 所在 NUMA 节点进行内存回收
ret = node_reclaim(zone->zone_pgdat, gfp_mask, order);
switch (ret) {
case NODE_RECLAIM_NOSCAN:
// 返回该值表示当前 NUMA 节点没有必要进行回收。比如快速分配路径下就不处理页面回收的问题
continue;
case NODE_RECLAIM_FULL:
// 返回该值表示通过扫描之后发现当前 NUMA 节点并没有可以回收的内存页
continue;
default:
// 该分支表示当前 NUMA 节点已经进行了内存回收操作
// zone_watermark_ok 判断内存回收是否回收了足够的内存能否满足内存分配的需要
if (zone_watermark_ok(zone, order, mark,
ac->highest_zoneidx, alloc_flags))
goto try_this_zone;
continue;
}
}
try_this_zone:
// 这里就是伙伴系统的入口,rmqueue 函数中封装的就是伙伴系统的核心逻辑
// 从伙伴系统中获取内存
page = rmqueue(ac->preferred_zoneref->zone, zone, order,
gfp_mask, alloc_flags, ac->migratetype);
if (page) {
// 分配内存成功,初始化内存页 page
prep_new_page(page, order, gfp_mask, alloc_flags);
return page;
} else {
....... 省略 .....
}
}
// 内存分配失败
return NULL;
}
伙伴系统的核心分配逻辑可以使用如下的示例展现
其中从下面的情况中使用伙伴系统分配1个page为例:
分配过程如下:
总结
在整个物理内存分配链路中,内存的分配整体分为了两个路径:
- 快速路径 fast path:该路径的下,内存分配的逻辑比较简单,主要是在 WMARK_LOW 水位线之上快速的扫描一下各个内存区域中是否有足够的空闲内存能够满足本次内存分配,如果有则立马从伙伴系统中申请,如果没有立即返回。
- 慢速路径 slow path:慢速路径下的内存分配逻辑就变的非常复杂了,其中包含了内存分配的各种异常情况的处理,并且会根据文中介绍的 GFP,ALLOC 等各种内存分配策略掩码进行不同分支的处理,整个链路非常庞大且繁杂。并且在这个代码流程中会在最激进的内存分配路径中走到OOM的流程中,这也是之后要研究的主要内容。
OOM触发流程
__alloc_pages_may_oom
在内存分配路径上,当内存不足的时候会触发kswapd、或者内存规整,极端情况会触发OOM,来获取更多内存。在内存回收失败之后,会进行OOM,OOM的入口是__alloc_pages_may_oom,文件位于mm/page_alloc.c中:
static inline struct page *
__alloc_pages_may_oom(gfp_t gfp_mask, unsigned int order,
const struct alloc_context *ac, unsigned long *did_some_progress)
{
struct oom_control oc = {//OOM控制参数
.zonelist = ac->zonelist,
.nodemask = ac->nodemask,
.memcg = NULL,
.gfp_mask = gfp_mask,
.order = order,
};
struct page *page;
*did_some_progress = 0;
/*
* Acquire the oom lock. If that fails, somebody else is
* making progress for us.
*/
//尝试加锁,如果获取不到锁则返回
if (!mutex_trylock(&oom_lock)) {
*did_some_progress = 1;
schedule_timeout_uninterruptible(1);
return NULL;
}
/*
* Go through the zonelist yet one more time, keep very high watermark
* here, this is only to catch a parallel oom killing, we must fail if
* we're still under heavy pressure. But make sure that this reclaim
* attempt shall not depend on __GFP_DIRECT_RECLAIM && !__GFP_NORETRY
* allocation which will never fail due to oom_lock already held.
*/
//尝试再次使用高水位分配内存一次,判断是否需要启动oom
page = get_page_from_freelist((gfp_mask | __GFP_HARDWALL) &
~__GFP_DIRECT_RECLAIM, order,
ALLOC_WMARK_HIGH|ALLOC_CPUSET, ac);
if (page)
goto out;
/* Coredumps can quickly deplete all memory reserves */
if (current->flags & PF_DUMPCORE)
goto out;
/* The OOM killer will not help higher order allocs */
//OOM不可以分配高于PAGE_ALLOC_COSTLY_ORDER的阶数,也就是3阶
if (order > PAGE_ALLOC_COSTLY_ORDER)
goto out;
/*
* We have already exhausted all our reclaim opportunities without any
* success so it is time to admit defeat. We will skip the OOM killer
* because it is very likely that the caller has a more reasonable
* fallback than shooting a random task.
*/
//__GFP_NOFAIL是不允许内存申请失败的情况,如果不允许失败则从out退出
if (gfp_mask & __GFP_RETRY_MAYFAIL)
goto out;
/* The OOM killer does not needlessly kill tasks for lowmem */
//OOM不会为低端内存启动,如果是要分配低端内存则从out退出
if (ac->high_zoneidx < ZONE_NORMAL)
goto out;
if (pm_suspended_storage())
goto out;
/*
* XXX: GFP_NOFS allocations should rather fail than rely on
* other request to make a forward progress.
* We are in an unfortunate situation where out_of_memory cannot
* do much for this context but let's try it to at least get
* access to memory reserved if the current task is killed (see
* out_of_memory). Once filesystems are ready to handle allocation
* failures more gracefully we should just bail out here.
*/
/* The OOM killer may not free memory on a specific node */
//OOM不会释放特定节点上的内存
if (gfp_mask & __GFP_THISNODE)
goto out;
/* Exhausted what can be done so it's blame time */
//经过上面各种情况,仍然需要进行OOM处理。调用out_of_memory()。
if (out_of_memory(&oc) || WARN_ON_ONCE(gfp_mask & __GFP_NOFAIL)) {
*did_some_progress = 1;
/*
* Help non-failing allocations by giving them access to memory
* reserves
*/
//对于__GFP_NOFAIL的分配情况,需要降低分配标准到无水线
if (gfp_mask & __GFP_NOFAIL)
page = __alloc_pages_cpuset_fallback(gfp_mask, order,
ALLOC_NO_WATERMARKS, ac);
}
out:
mutex_unlock(&oom_lock);
return page;
}
__alloc_pages_may_oom会先配置OOM参数,再尝试高水位分配内存,成功就返回,失败再判断标志位等判断是否适合oom,适合才会调用out_of_memory进行OOM操作:
out_of_memory
bool out_of_memory(struct oom_control *oc)
{
unsigned long freed = 0;
enum oom_constraint constraint = CONSTRAINT_NONE;
//在freeze_processes会将其置位,即禁止OOM;因为在冻结过程不允许OOM
if (oom_killer_disabled)
return false;
if (!is_memcg_oom(oc)) {//检查是否有资格杀死进程启动oom
//调用oom通知链上的函数
blocking_notifier_call_chain(&oom_notify_list, 0, &freed);
if (freed > 0)
/* Got some memory back in the last second. */
return true;
}
/*
* If current has a pending SIGKILL or is exiting, then automatically
* select it. The goal is to allow it to allocate so that it may
* quickly exit and free its memory.
*/
//如果当前进程将要退出,或者释放内存
if (task_will_free_mem(current)) {
mark_oom_victim(current);//标记当前进程作为OOM候选者
wake_oom_reaper(current);//唤醒OOM reaper去收割进而释放内存
return true;//由于当前进程由于自身原因将要退出,不需要经过下面的打分和杀死流程
}
/*
* The OOM killer does not compensate for IO-less reclaim.
* pagefault_out_of_memory lost its gfp context so we have to
* make sure exclude 0 mask - all other users should have at least
* ___GFP_DIRECT_RECLAIM to get here.
*/
//如果内存申请掩码包括__GFP_DS或__GFP_NOFAIL,则不进行OOM收割。
if (oc->gfp_mask && !(oc->gfp_mask & __GFP_FS))
return true;
/*
* Check if there were limitations on the allocation (only relevant for
* NUMA and memcg) that may require different handling.
*/
//检查是否需要处理不同的分配限制
constraint = constrained_alloc(oc);
if (constraint != CONSTRAINT_MEMORY_POLICY)
oc->nodemask = NULL;
//检查sysctl_panic_on_oom设置,以及是否由sysrq触发,来决定是否触发panic。
check_panic_on_oom(oc, constraint);
//如果设置了sysctl_oom_kill_allocating_task,那么当内存耗尽时,会把当前申请内存分配的进程杀掉。
if (!is_memcg_oom(oc) && sysctl_oom_kill_allocating_task &&
current->mm && !oom_unkillable_task(current, NULL, oc->nodemask) &&
current->signal->oom_score_adj != OOM_SCORE_ADJ_MIN) {
get_task_struct(current);
oc->chosen = current;
oom_kill_process(oc, "Out of memory (oom_kill_allocating_task)");
return true;
}
select_bad_process(oc);//遍历所有进程和进程下的线程,查找合适的候选进程
/* Found nothing?!?! */
if (!oc->chosen) {//如果没有合适候选进程,并且OOM不是由sysrq触发的,进入panic。
dump_header(oc, NULL);
pr_warn("Out of memory and no killable processes...n");
/*
* If we got here due to an actual allocation at the
* system level, we cannot survive this and will enter
* an endless loop in the allocator. Bail out now.
*/
if (!is_sysrq_oom(oc) && !is_memcg_oom(oc))
panic("System is deadlocked on memoryn");
}
if (oc->chosen && oc->chosen != (void *)-1UL)
//杀死选中的进程
oom_kill_process(oc, !is_memcg_oom(oc) ? "Out of memory" :
"Memory cgroup out of memory");
return !!oc->chosen;
}
out_of_memory函数是OOM机制的核心,他主要做了三件事:
- 调用select_bad_process调挑选最’bad‘的进程
- 如果没有合适的进程,则调用dump_header,打印OOM信息,找出OOM原因
- 调用oom_kill_process杀死第一步挑选的进程下面就是oom触发的调用过程:
_alloc_pages //内存分配时调用
|-->__alloc_pages_nodemask
|--> __alloc_pages_slowpath
|--> __alloc_pages_may_oom
| --> out_of_memory //触发
Page_owner排查oom问题
Page_owner使用方式
Page_owner通过扩展page结构体,增加成员变量用于存储该page被分配的调用栈及标志位,然后hook内存页的分配和释放接口,在内存页被分配时,保存调用栈信息,设置标志位;在内存页被释放的时候,清除调用栈信息,清除标志位。然后,通过一个debugfs的接口,将所有读取该接口时刻已经被分配出去的内存页的调用栈信息传递给用户态,并在用户态制作了一个工具,用于统计这些调用栈的信息。
Page_owner支持
CONFIG_PAGE_OWNER=y
正常page_owner使用方式:
在使能page_owner后可以通过
cat /sys/kernel/debug/page_owner
获取还在占用的所有page的调用信息,但是该文件信息量密度很低,是按照每一次调用进行的保存。
完全导出需要占用至少7M的空间。
可以使用内核自带的排序工具page_owner_sort:
经过排序整理后文件缩小到600K其中一部分内容如下:
3938 times, 3938 pages, allocated by OTHERS :
Page allocated via order 0, mask 0x100cca(GFP_HIGHUSER_MOVABLE), pid 1, tgid 1 (null), ts ns
prep_new_page+0xe4/0x168
get_page_from_freelist+0xe60/0x1028
__alloc_pages_nodemask+0x104/0x2c0
shmem_alloc_page+0x34/0x40
shmem_getpage_gfp.isra.67+0x130/0x7c0
shmem_write_begin+0x58/0x90
generic_perform_write+0xcc/0x188
__generic_file_write_iter+0x134/0x1b8
generic_file_write_iter+0xe4/0x178
new_sync_write+0xfc/0x168
__vfs_write+0x74/0x90
vfs_write+0xb8/0x1c0
ksys_write+0x5c/0xd8
xwrite+0x3c/0x84
do_copy+0x94/0xd0
write_buffer+0x40/0x5c
3052 times, 3052 pages, allocated by OTHERS :
Page allocated via order 0, mask 0x0(), pid 1, tgid 1 (null), ts ns
register_early_stack+0x20/0x40
init_page_owner+0x3c/0x218
page_ext_init+0x154/0x170
kernel_init_freeable+0x18c/0x2d0
1689 times, 1689 pages, allocated by OTHERS :
Page allocated via order 0, mask 0x100cca(GFP_HIGHUSER_MOVABLE), pid 1342, tgid 1342 (null), ts ns
prep_new_page+0xe4/0x168
get_page_from_freelist+0xe60/0x1028
__alloc_pages_nodemask+0x104/0x2c0
shmem_alloc_page+0x34/0x40
shmem_getpage_gfp.isra.67+0x130/0x7c0
shmem_write_begin+0x58/0x90
generic_perform_write+0xcc/0x188
__generic_file_write_iter+0x134/0x1b8
generic_file_write_iter+0xe4/0x178
new_sync_write+0xfc/0x168
__vfs_write+0x74/0x90
vfs_write+0xb8/0x1c0
ksys_write+0x5c/0xd8
__arm64_sys_write+0x24/0x30
el0_svc_common.constprop.2+0x88/0x120
el0_svc_handler+0x34/0xa0
。。。
__get_vm_area_node.isra.53+0xa4/0x1a0
__vmalloc_node_range+0x80/0x2c8
copy_process+0x614/0x15a8
_do_fork+0x70/0x418
kernel_thread+0x5c/0x70
call_usermodehelper_exec_work+0xa4/0xc0
process_one_work+0x208/0x468
worker_thread+0x54/0x500
TOTAL PAGES 20090 MEM 80360
这份包含所有page调用栈的信息对于调试内存问题非常有帮助,可以清楚的知道内存是这么被消耗掉的。
Page_owner原理
内存页被分配出去前,会走进post_alloc_hook函数,进行一些处理,post_alloc_hook函数会调用set_page_owner函数,完成内存页分配调用栈的保存。
mm/page_alloc.c
inline void post_alloc_hook(struct page *page, unsigned int order, gfp_t gfp_flags)
{
bool init = !want_init_on_free() && want_init_on_alloc(gfp_flags) &&
!should_skip_init(gfp_flags);
bool zero_tags = init && (gfp_flags & __GFP_ZEROTAGS);
bool reset_tags = true;
int i;
set_page_private(page, 0);
set_page_refcounted(page);
arch_alloc_page(page, order);
debug_pagealloc_map_pages(page, 1 << order);
/*
* Page unpoisoning must happen before memory initialization.
* Otherwise, the poison pattern will be overwritten for __GFP_ZERO
* allocations and the page unpoisoning code will complain.
*/kernel_unpoison_pages(page, 1 << order);
/*
* As memory initialization might be integrated into KASAN,
* KASAN unpoisoning and memory initializion code must be
* kept together to avoid discrepancies in behavior.
*//*
* If memory tags should be zeroed
* (which happens only when memory should be initialized as well).
*/if (zero_tags) {
/* Initialize both memory and memory tags. */for (i = 0; i != 1 << order; ++i)
tag_clear_highpage(page + i);
/* Take note that memory was initialized by the loop above. */init = false;
}
if (!should_skip_kasan_unpoison(gfp_flags)) {
/* Try unpoisoning (or setting tags) and initializing memory. */if (kasan_unpoison_pages(page, order, init)) {
/* Take note that memory was initialized by KASAN. */if (kasan_has_integrated_init())
init = false;
/* Take note that memory tags were set by KASAN. */reset_tags = false;
} else {
/*
* KASAN decided to exclude this allocation from being
* (un)poisoned due to sampling. Make KASAN skip
* poisoning when the allocation is freed.
*/SetPageSkipKASanPoison(page);
}
}
/*
* If memory tags have not been set by KASAN, reset the page tags to
* ensure page_address() dereferencing does not fault.
*/if (reset_tags) {
for (i = 0; i != 1 << order; ++i)
page_kasan_tag_reset(page + i);
}
/* If memory is still not initialized, initialize it now. */if (init)
kernel_init_pages(page, 1 << order);
/* Propagate __GFP_SKIP_KASAN_POISON to page flags. */if (kasan_hw_tags_enabled() && (gfp_flags & __GFP_SKIP_KASAN_POISON))
SetPageSkipKASanPoison(page);
set_page_owner(page, order, gfp_flags);
page_table_check_alloc(page, order);
}
noinline void __set_page_owner(struct page *page, unsigned int order,
gfp_t gfp_mask)
{
struct page_ext *page_ext = lookup_page_ext(page);//获取该page对应的struct page_ext结构体
depot_stack_handle_t handle;
if (unlikely(!page_ext))
return;
handle = save_stack(gfp_mask);//将该页被分配时的调用栈保存下来
__set_page_owner_handle(page_ext, handle, order, gfp_mask);
}
Page_ext
我们之前看过page的结构,内核为了节省内存将struct page已经用到了机制,在page中每增加一个字节都会耗费大量内存,所以说内核使用了一种机制可以动态的扩展page的机制,page_ext。
我们可以看一下在平坦内存模型中,在平坦内存模型中,PFN与page是直接对应的,内核使用alloc_node_page_ext为这种方式进行Page_ext内存申请:
static int __init alloc_node_page_ext(int nid)
{
struct page_ext *base;
unsigned long table_size;
unsigned long nr_pages;
nr_pages = NODE_DATA(nid)->node_spanned_pages;
if (!nr_pages)
return 0;
/*
* Need extra space if node range is not aligned with
* MAX_ORDER_NR_PAGES. When page allocator's buddy algorithm
* checks buddy's status, range could be out of exact node range.
*/
if (!IS_ALIGNED(node_start_pfn(nid), MAX_ORDER_NR_PAGES) ||
!IS_ALIGNED(node_end_pfn(nid), MAX_ORDER_NR_PAGES))
nr_pages += MAX_ORDER_NR_PAGES;
table_size = sizeof(struct page_ext) * nr_pages;
base = memblock_virt_alloc_try_nid_nopanic(
table_size, PAGE_SIZE, __pa(MAX_DMA_ADDRESS),
BOOTMEM_ALLOC_ACCESSIBLE, nid);
if (!base)
return -ENOMEM;
NODE_DATA(nid)->node_page_ext = base;
total_usage += table_size;
return 0;
}
简单来说就是每一个page再通过pfn关联到一个page_ext。
struct page_ext {
unsigned long flags;
}
那怎样通过Page访问page_ext4呢,内核通过lookup_page_ext通过Page查找page_ext
struct page_ext *lookup_page_ext(struct page *page)
{
unsigned long pfn = page_to_pfn(page);
unsigned long offset;
struct page_ext *base;
base = NODE_DATA(page_to_nid(page))->node_page_ext;
#ifdef CONFIG_DEBUG_VM
/*
* The sanity checks the page allocator does upon freeing a
* page can reach here before the page_ext arrays are
* allocated when feeding a range of pages to the allocator
* for the first time during bootup or memory hotplug.
*/
if (unlikely(!base))
return NULL;
#endif
offset = pfn - round_down(node_start_pfn(page_to_nid(page)),
MAX_ORDER_NR_PAGES);
return base + offset;
}
就是通过pfn,将两者关联起来的。
page_ext是一个简单的long类型,那怎么存放自己的数据呢,就以page_owner为例:
static struct page_ext_operations *page_ext_ops[] = {
#ifdef CONFIG_PAGE_OWNER
&page_owner_ops,
#endif
#if defined(CONFIG_IDLE_PAGE_TRACKING) && !defined(CONFIG_64BIT)
&page_idle_ops,
#endif
};
unsigned long page_ext_size = sizeof(struct page_ext);
static unsigned long total_usage;
static bool __init invoke_need_callbacks(void)
{
int i;
int entries = ARRAY_SIZE(page_ext_ops);
bool need = false;
for (i = 0; i < entries; i++) {
if (page_ext_ops[i]->need && page_ext_ops[i]->need()) {
page_ext_ops[i]->offset = page_ext_size;
page_ext_size += page_ext_ops[i]->size;
need = true;
}
}
return need;
}
对于要扩展的类型需要定义一个page_ext_operations变量,除了一个flags变量,page_owner相当于在flags变量后面拼接了一个自己的结构体,这个结构体定义如下:
struct page_owner {
unsigned short order;
short last_migrate_reason;
gfp_t gfp_mask;
depot_stack_handle_t handle;
depot_stack_handle_t free_handle;
pid_t pid;
pid_t tgid;
};
每一个page_owner 38个字节,则每一个page_ext是42字节,以7986为例,7986大概有512M ,120000个page 可以算出这部分大概需要多耗费120000*42≈5000000 也就是5M内存。
Page_owner 优化
经过测试page_owner 使用内核自带的工具虽然可以生成归并信息,这个过程本身就非常消耗内存,可能在发生OOM的时候或者内存紧张的时候并不适用,所以需要有一种方式可以在消耗很少的内存的情况下,完成对于页的调用信息的归并。为此需要使用拉链法将所有的调用栈进行归并,在完成归并后再将所有链表合并,再对这个链表使用冒泡排序按照占用页数量进行排序。
每一个节点定义如下:
typedef struct stack_node_s {
depot_stack_handle_t handle;
int times;//栈次数
int pages;//占用页数
int pid;
int tgid;
struct stack_node_s *next;
} stack_node_t;
代码流程如下:
然后就是注册oom notify,会在 oom触发的时候运行这个函数并打印最终结果。
留言