文章目录
- 重要:本系列文章内容摘自
<Linux内核深度解析>
基于ARM64架构的Linux4.x内核一书,作者余华兵。系列文章主要用于记录Linux内核的大部分机制及参数的总结说明
1 巨型页
当运行内存需求量较大的应用程序时,如果使用长度为4KB的页,将会产生较多的TLB未命中和缺页异常,严重影响应用程序的性能。如果使用长度为2MB甚至更大的巨型页,可以大幅减少TLB未命中和缺页异常的数量,大幅提高应用程序的性能。这正是内核引入巨型页(Huge Page)的直接原因。
巨型页首先需要处理器支持,然后需要内核支持,内核有如下两种实现方式:
(1)使用hugetlbfs伪文件系统实现巨型页。hugetlbfs文件系统是一个假的文件系统,只是利用了文件系统的编程接口。使用hugetlbfs文件系统实现的巨型页称为hugetblfs巨型页、传统巨型页或标准巨型页,统一称为标准巨型页。
(2)透明巨型页。标准巨型页的优点是预先分配巨型页到巨型页池,进程申请巨型页的时候从巨型页池取,成功的概率很高,缺点是应用程序需要使用文件系统的编程接口。透明巨型页的优点是对应用程序透明,缺点是动态分配,在内存碎片化的时候分配成功的概率很低。
1.1 处理器对巨型页的支持
ARM64处理器支持巨型页的方式有两种:
(1)通过块描述符支持。
(2)通过页/块描述符的连续位支持。
1.1.1 通过块描述符支持巨型页
如(十八)所描述,如果页长度是4KB,那么使用4级转换表,0级转换表不能使用块描述符,1级转换表的块描述符指向1GB巨型页,2级转换表的块描述符指向2MB巨型页。
如果页长度是16KB,那么使用4级转换表,0级转换表不能使用块描述符,1级转换表不能使用块描述符,2级转换表的块描述符指向32MB巨型页。
如果页长度是64KB,那么使用3级转换表,1级转换表不能使用块描述符,2级转换表的块描述符指向512MB巨型页。
1.1.2 通过页/块描述符的连续位支持巨型页
页/块描述符中的连续位指示表项是一个连续表项集合中的一条表项,一个连续表项集合可以被缓存在一条TLB表项里面。通俗地说,进程申请了n页的虚拟内存区域,然后申请了n页的物理内存区域,使用n个连续的页表项把每个虚拟页映射到物理页,每个页表项设置了连续标志位,当处理器的内存管理单元遍历内存中的页表时,访问到n个页表项中的任何一个页表项,发现页表项设置了连续标志位,就会把n个页表项合并以后填充到一个TLB表项。当然,n不是随意选择的,而且n页的虚拟内存区域的起始地址必须是n页的整数倍,n页的物理内存区域的起始地址也必须是n页的整数倍:
如下所示,如果页长度是4KB,那么使用4级转换表,1级转换表的块描述符不能使用连续位;2级转换表的块描述符支持16个连续块,即支持(16 × 2MB = 32MB)巨型页;3级转换表的页描述符支持16个连续页,即支持(16 × 4KB = 64KB)巨型页:
如果页长度是16KB,那么使用4级转换表,2级转换表的块描述符支持32个连续块,即支持(32 × 32MB = 1GB)巨型页;3级转换表的页描述符支持128个连续页,即支持(128 × 16KB = 2MB)巨型页。
如果页长度是64KB,那么使用3级转换表,2级转换表的块描述符不能使用连续位;3级转换表的页描述符支持32个连续页,即支持(32 × 64KB = 2MB)巨型页。
1.2 标准巨型页
1.使用方法
编译内核时需要打开配置宏CONFIG_HUGETLBFS和CONFIG_HUGETLB_PAGE(打开配置宏CONFIG_HUGETLBFS的时候会自动打开)。
通过文件“/proc/sys/vm/nr_hugepages”指定巨型页池中永久巨型页的数量,预先分配指定数量的永久巨型页到巨型页池中。另一种方法是在引导内核时指定内核参数“hugepages=N”以分配永久巨型页,这是分配巨型页最可靠的方法,因为内存还没有碎片化。
有些平台支持多种巨型页长度。如果要分配特定长度的巨型页,必须在内核参数“hugepages”前面添加选择巨型页长度的参数“hugepagesz=[kKmMgG]”。可以使用内核参数“default_hugepagesz=[kKmMgG]”选择默认的巨型页长度。文件“/proc/ sys/vm/nr_hugepages”表示默认长度的永久巨型页的数量。
通过文件“/proc/sys/vm/nr_overcommit_hugepages”指定巨型页池中临时巨型页的数量,当永久巨型页用完的时候,可以从页分配器申请临时巨型页。
nr_hugepages是巨型页池的最小长度,(nr_hugepages + nr_overcommit_hugepages)是巨型页池的最大长度。这两个参数的默认值都是0,至少要设置一个,不然分配巨型页会失败。
创建匿名的巨型页映射,其代码如下:
#define MAP_LENGTH (10 * 1024 * 1024)
addr = mmap(0, MAP_LENGTH, PROT_READ | PROT_WRITE,
MAP_ANONYMOUS | MAP_HUGETLB, -1, 0);
如果要创建基于文件的巨型页映射,首先管理员需要在某个目录下挂载hugetlbfs文件系统:
mount -t hugetlbfs \
-o uid=<value>,gid=<value>,mode=<value>,pagesize=<value>,size=<value>,\
min_size=<value>,nr_inodes=<value> none <目录>
各选项的意思如下:
(1)选项uid和gid指定文件系统的根目录的用户和组,默认取当前进程的用户和组。
(2)选项mode指定文件系统的根目录的模式,默认值是0755。
(3)如果平台支持多种巨型页长度,可以使用选项pagesize指定巨型页长度和关联的巨型页池。如果不使用选项pagesize,表示使用默认的巨型页长度。
(4)选项size指定允许文件系统使用的巨型页的最大数量。如果不指定选项size,表示没有限制。
(5)选项min_size指定允许文件系统使用的巨型页的最小数量。挂载文件系统的时候,申请巨型页池为这个文件系统预留选项min_size指定的巨型页数量。如果不指定选项min_size,表示没有限制。
(6)选项nr_inodes指定文件系统中文件(一个文件对应一个索引节点)的最大数量。如果不指定选项nr_inodes,表示没有限制。
假设在目录“/mnt/huge”下挂载了hugetlbfs文件系统,应用程序在hugetlbfs文件系统中创建文件,然后创建基于文件的内存映射,这个内存映射就会使用巨型页。
#define MAP_LENGTH (10 * 1024 * 1024)
fd = open("/mnt/huge/test", O_CREAT | O_RDWR, S_IRWXU);
addr = mmap(0, MAP_LENGTH, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
应用程序可以使用开源的hugetlbfs库,这个库对hugetlbfs文件系统做了封装。使用hugetlbfs库的好处如下:
(1)启动程序时使用环境变量“LD_PRELOAD=libhugetlbfs.so”把hugetlbfs库设置成优先级最高的动态库,malloc()使用巨型页,对应用程序完全透明,应用程序不需要修改代码。
(2)可以把代码段、数据段和未初始化数据段都放在巨型页中。
执行命令“cat /proc/meminfo”可以看到巨型页的信息:
…
HugePages_Total: vvv
HugePages_Free: www
HugePages_Rsvd: xxx
HugePages_Surp: yyy
Hugepagesize: zzz kB
这些字段的意思如下:
(1)HugePages_Total:巨型页池的大小。
(2)HugePages_Free:巨型页池中没有分配的巨型页的数量。
(3)HugePages_Rsvd:“Rsvd”是“Reserved”的缩写,意思是“预留的”,是已经承诺从巨型页池中分配但是还没有分配的巨型页的数量。预留的巨型页保证应用程序在发生缺页异常的时候能够从巨型页池中分配一个巨型页。
(4)HugePages_Surp:“Surp”是“Surplus”的缩写,意思是“多余的”,是巨型页池中临时巨型页的数量。临时巨型页的最大数量由“/proc/sys/vm/nr_overcommit_hugepages”控制。
(5)Hugepagesize:巨型页的大小。
2.实现原理
(1)巨型页池。
内核使用巨型页池管理巨型页。有的处理器架构支持多种巨型页长度,每种巨型页长度对应一个巨型页池,有一个默认的巨型页长度,默认只创建巨型页长度是默认长度的巨型页池。例如ARM64架构在页长度为4KB的时候支持的巨型页长度是1GB、32MB、2MB和64KB,默认的巨型页长度是2MB,默认只创建巨型页长度是2MB的巨型页池。如果需要创建巨型页长度不是默认长度的巨型页池,可以在引导内核时指定内核参数“hugepagesz=[kKmMgG]”,长度必须是处理器支持的长度。可以使用内核参数“default_hugepagesz=[kKmMgG]”选择默认的巨型页长度。
巨型页池的数据结构是结构体hstate,全局数组hstates是巨型页池数组,全局变量hugetlb_max_hstate是巨型页池的数量,全局变量default_hstate_idx是默认巨型页池的索引。
mm/hugetlb.c
int hugetlb_max_hstate __read_mostly;
unsigned int default_hstate_idx;
struct hstate hstates[HUGE_MAX_HSTATE];
巨型页池中的巨型页分为两种:
1)永久巨型页:永久巨型页是保留的,不能有其他用途,被预先分配到巨型页池,当进程释放永久巨型页的时候,永久巨型页被归还到巨型页池。
2)临时巨型页:也称为多余的(surplus)巨型页,当永久巨型页用完的时候,可以从页分配器分配临时巨型页;进程释放临时巨型页的时候,直接释放到页分配器。当设备长时间运行后,内存可能碎片化,分配临时巨型页可能失败。
巨型页池的数据结构hstate的主要成员如下所示:
成 员 | 说 明 |
---|---|
char name[HSTATE_NAME_LEN] | 巨型页池的名称,格式是“hugepages-kB” |
unsigned int order | 巨型页的长度,页的阶数 |
unsigned long mask | 巨型页页号的掩码,将虚拟地址和掩码按位与,得到巨型页页号 |
unsigned long max_huge_pages | 永久巨型页的最大数量 |
unsigned long nr_overcommit_huge_pages | 临时巨型页的最大数量 |
unsigned long nr_huge_pages | 巨型页的数量 |
unsigned int nr_huge_pages_node[MAX_NUMNODES] | 每个内存节点中巨型页的数量 |
unsigned long surplus_huge_pages | 临时巨型页的数量 |
unsigned int surplus_huge_pages_node[MAX_NUMNODES] | 每个内存节点中临时巨型页的数量 |
unsigned long free_huge_pages | 空闲巨型页的数量 |
unsigned int free_huge_pages_node[MAX_NUMNODES] | 每个内存节点中空闲巨型页的数量 |
unsigned long resv_huge_pages | 预留巨型页的数量,它们已经承诺分配但还没有分配 |
struct list_head hugepage_freelists[MAX_NUMNODES] | 每个内存节点一个空闲巨型页链表 |
struct list_head hugepage_activelist | 把已分配出去的巨型页链接起来 |
int next_nid_to_alloc | 分配永久巨型页并添加到巨型页池中的时候,在允许的内存节点集合中轮流从每个内存节点分配永久巨型页,这个成员用来记录下次从哪个内存节点分配永久巨型页 |
int next_nid_to_free | 从巨型页池释放空闲巨型页的时候,在允许的内存节点集合中轮流从每个内存节点释放巨型页,这个成员用来记录下次从哪个内存节点释放巨型页 |
(2)预先分配永久巨型页。
预先分配指定数量的永久巨型页到巨型页池中有两种方法:
1)最可靠的方法是在引导内核时指定内核参数“hugepages=N”来分配永久巨型页,因为内核初始化的时候内存还没有碎片化。
有些处理器架构支持多种巨型页长度。如果要分配特定长度的巨型页,必须在内核参数“hugepages”前面添加选择巨型页长度的参数“hugepagesz=[kKmMgG]”。
2)通过文件“/proc/sys/vm/nr_hugepages”指定默认长度的永久巨型页的数量。
内核参数“hugepages=N”的处理函数是hugetlb_nrpages_setup,其代码如下:
mm/hugetlb.c
static int __init hugetlb_nrpages_setup(char *s)
{
unsigned long *mhp;
static unsigned long *last_mhp;
if (!parsed_valid_hugepagesz) {
pr_warn("hugepages = %s preceded by "
"an unsupported hugepagesz, ignoring\n", s);
parsed_valid_hugepagesz = true;
return 1;
}
/*
* “!hugetlb_max_hstate”意味着没有解析一个“hugepagesz=”参数,
* 所以这个“hugepages=”参数对应默认的巨型页池。
*/
else if (!hugetlb_max_hstate)
mhp = &default_hstate_max_huge_pages;
else
mhp = &parsed_hstate->max_huge_pages;
if (mhp == last_mhp) {
pr_warn("hugepages= specified twice without interleaving hugepagesz=, ignoring\n");
return 1;
}
if (sscanf(s, "%lu", mhp) <= 0)
*mhp = 0;
if (hugetlb_max_hstate && parsed_hstate->order >= MAX_ORDER)
hugetlb_hstate_alloc_pages(parsed_hstate);
last_mhp = mhp;
return 1;
}
(3)挂载hugetlbfs文件。
hugetlbfs文件系统在初始化的时候,调用函数register_filesystem以注册hugetlbfs文件系统,hugetlbfs文件系统的结构体如下:
fs/hugetlbfs/inode.c
static struct file_system_type hugetlbfs_fs_type = {
.name = "hugetlbfs",
.mount = hugetlbfs_mount,
.kill_sb = kill_litter_super,
};
挂载hugetlbfs文件系统的时候,挂载函数调用hugetlbfs文件系统的挂载函数hugetlbfs_mount,创建超级块和根目录,把文件系统和巨型页池关联起来。
如下所示,超级块的成员s_fs_info指向hugetblfs文件系统的私有信息;成员s_blocksize是块长度,被设置为巨型页的长度:
结构体hugetlbfs_sb_info 描述hugetblfs文件系统的私有信息:
1)成员max_inode是允许的索引节点最大数量。
2)成员free_inodes是空闲的索引节点数量。
3)成员hstate指向关联的巨型页池。
4)如果指定了最大巨型页数量或最小巨型页数量,那么为巨型页池创建一个子池,成员spool指向子池。
结构体hugepage_subpool描述子池的信息:
1)成员max_hpages是允许的最大巨型页数量。
2)used_hpages是已使用的巨型页数量,包括分配的和预留的。
3)成员hstate指向巨型页池。
4)成员min_hpages是最小巨型页数量。
5)成员rsv_hpages是子池向巨型页池申请预留的巨型页的数量。
(4)创建文件。
调用系统调用open(),在hugetlbfs文件系统的一个目录下创建一个文件的时候,系统调用open最终调用函数hugetlbfs_create()为文件分配索引节点(结构体inode)并且初始化,索引节点的成员i_fop指向hugetlbfs文件系统特有的文件操作集合hugetlbfs_file_operations,这个文件操作集合的成员mmap方法是函数hugetlbfs_file_mmap(),这个函数在创建内存映射的时候很关键。
(5)创建内存映射。
在hugetlbfs文件系统中打开文件,然后基于这个文件创建内存映射时,系统调用mmap将会调用函数hugetlbfs_file_mmap()。
函数hugetlbfs_file_mmap()的主要功能如下:
1)设置标准巨型页标志VM_HUGETLB 和不允许扩展标志VM_DONTEXPAND。
2)虚拟内存区域的成员vm_ops指向巨型页特有的虚拟内存操作集合hugetlb_vm_ops。
3)检查文件的偏移是不是巨型页长度的整数倍。
4)调用函数hugetlb_reserve_pages(),向巨型页池申请预留巨型页。
函数hugetlb_reserve_pages()的主要功能如下:
1)如果设置标志位VM_NORESERVE指定不需要预留巨型页,直接返回。
2)如果是共享映射,那么使用文件的索引节点的预留图(结构体resv_map),如图3.67所示,在预留图中查看从文件的起始偏移到结束偏移有哪些部分以前没有预留,计算需要预留的巨型页的数量N:
3)如果是私有映射,那么创建预留图,虚拟内存区域的成员vm_private_data指向预留图,并且设置标志HPAGE_RESV_OWNER指明该虚拟内存区域拥有这个预留,如下所示。计算需要预留的巨型页的数量N =(文件的结束偏移 − 起始偏移),偏移的单位是巨型页长度:
虚拟内存区域的成员vm_private_data的最低两位用来存储标志位。
标志位HPAGE_RESV_OWNER,值为1,指明当前进程是预留的拥有者。
标志位HPAGE_RESV_UNMAPPED,值为2。对于私有映射,如果创建映射的进程在执行写时复制时分配巨型页失败,那么删除所有子进程的映射,设置该标志,让子进程在发生页错误异常时被杀死。
4)如果文件系统创建了巨型页子池,计算子池需要向巨型页池申请预留的巨型页的数量,否则需要向巨型页池申请预留的巨型页的数量是N。
如果子池以前申请预留的巨型页数量大于或等于N,那么子池不需要向巨型页池申请预留。
如果子池以前申请预留的巨型页数量小于N,那么子池需要向巨型页池申请预留的数量等于(N − 子池以前申请预留的巨型页数量)。
5)向巨型页池申请预留指定数量的巨型页。
6)如果是共享映射,那么在预留图的区域链表中增加1个file_region实例,记录预留区域。
(6)分配和映射到巨型页。
第一次访问巨型页的时候触发缺页异常,函数handle_mm_fault发现虚拟内存区域设置了标志VM_HUGETLB,调用巨型页的页错误处理函数hugetlb_fault。
函数hugetlb_fault发现页表项是空表项,调用函数hugetlb_no_page以分配并且映射到巨型页。
函数hugetlb_no_page的执行过程如下:
1)在文件的页缓存中根据文件的页偏移查找页。
2)如果在页缓存中没有找到页,调用函数alloc_huge_page以分配巨型页。如果是共享映射,那么把巨型页加入文件的页缓存,以便和其他进程共享页。
3)设置页表项。
4)如果第一步在页缓存中找到页,映射是私有的,并且执行写操作,那么执行写时复制。
函数alloc_huge_page的执行过程如下:
1)检查预留图,确定进程是否预留过要分配的巨型页。
2)如果进程没有预留巨型页,检查分配是否超过子池的限制。
3)从巨型页池中目标内存节点的空闲链表中分配永久巨型页。
4)如果分配永久巨型页失败,那么尝试从页分配器分配临时巨型页。
(7)写时复制。
假设进程1创建了私有的巨型页映射,然后进程1分叉生成进程2和进程3。其中一个进程试图写巨型页的时候,触发页错误异常,巨型页的页错误处理函数hugetlb_fault调用函数hugetlb_cow以执行写时复制。
函数hugetlb_cow的执行过程如下:
1)如果只有一个虚拟页映射到该物理页,并且是匿名映射,那么不需要复制,直接修改页表项设置可写。
2)分配巨型页。
3)处理分配巨型页失败的情况。
如果触发页错误异常的进程是创建私有映射的进程,那么删除所有子进程的映射,为子进程的虚拟内存区域的成员vm_private_data设置标志HPAGE_RESV_UNMAPPED,让子进程在发生页错误异常的时候被杀死。
如果触发页错误异常的进程不是创建私有映射的进程,返回错误。
4)把旧页的数据复制到新页。
5)修改页表项,映射到新页,并且设置可写。
1.3 透明巨型页
透明巨型页(Transparent Huge Page,THP)对进程是透明的,如果虚拟内存区域足够大,并且允许使用巨型页,那么内核在分配内存的时候首先选择分配巨型页,如果分配巨型页失败,回退分配普通页。
1.使用方法
透明巨型页的配置宏如下所示:
(1)CONFIG_TRANSPARENT_HUGEPAGE:支持透明巨型页。
(2)CONFIG_TRANSPARENT_HUGEPAGE_ALWAYS:总是使用透明巨型页。
(3)CONFIG_TRANSPARENT_HUGEPAGE_MADVISE:只在进程使用madvise(MADV_HUGEPAGE)指定的虚拟地址范围内使用透明巨型页。
(4)CONFIG_TRANSPARENT_HUGE_PAGECACHE:文件系统的页缓存使用透明巨型页。
可以在引导内核的时候通过内核参数开启或关闭透明巨型页。
(1)transparent_hugepage=always
(2)transparent_hugepage=madvise
(3)transparent_hugepage=never
可以在运行过程中开启或关闭透明巨型页。
(1)总是使用透明巨型页。
echo always >/sys/kernel/mm/transparent_hugepage/enabled
(2)只在进程使用madvise(MADV_HUGEPAGE)指定的虚拟地址范围内使用透明巨型页。
echo madvise >/sys/kernel/mm/transparent_hugepage/enabled
(3)禁止使用透明巨型页。
echo never >/sys/kernel/mm/transparent_hugepage/enabled
分配透明巨型页失败的时候,页分配器采取什么消除内存碎片的策略?可以配置以下策略:
(1)直接回收页,执行同步模式的内存碎片整理。
echo always >/sys/kernel/mm/transparent_hugepage/defrag
(2)异步回收页,执行异步模式的内存碎片整理。
echo defer >/sys/kernel/mm/transparent_hugepage/defrag
(3)只针对madvise(MADV_HUGEPAGE)指定的虚拟内存区域,异步回收页,执行异步模式的内存碎片整理。
echo defer+madvise >/sys/kernel/mm/transparent_hugepage/defrag
(4)只针对madvise(MADV_HUGEPAGE)指定的虚拟内存区域,直接回收页,执行同步模式的内存碎片整理。这是默认策略。
echo madvise >/sys/kernel/mm/transparent_hugepage/defrag
(5)不采取任何策略。
echo never >/sys/kernel/mm/transparent_hugepage/defrag
可以查看透明巨型页的长度,单位是字节:
cat /sys/kernel/mm/transparent_hugepage/hpage_pmd_size
透明巨型页扫描线程定期扫描允许使用透明巨型页的虚拟内存区域,尝试把普通页合并成透明巨型页。
可以通过文件“/sys/kernel/mm/transparent_hugepage/khugepaged/pages_to_scan”配置每次扫描多少页(指普通页),默认值是一个巨型页包含的普通页数量的8倍。
可以通过文件“/sys/kernel/mm/transparent_hugepage/khugepaged/scan_sleep_millisecs”配置两次扫描的时间间隔,单位是毫秒,默认值是10秒。
系统调用madvise针对透明巨型页提供了两个Linux私有的建议值:
(1)MADV_HUGEPAGE表示指定的虚拟地址范围允许使用透明巨型页。
(2)MADV_NOHUGEPAGE表示指定的虚拟地址范围不要合并成巨型页。
2.实现原理
虚拟内存区域vm_area_struct的成员vm_flags增加了以下两个标志:
(1)VM_HUGEPAGE表示允许虚拟内存区域使用透明巨型页,进程使用madvise (MADV_HUGEPAGE)给虚拟内存区域设置这个标志。
(2)VM_NOHUGEPAGE表示不允许虚拟内存区域使用透明巨型页,进程使用madvise(MADV_NOHUGEPAGE)给虚拟内存区域设置这个标志。
注意:标志VM_HUGETLB表示允许使用标准巨型页。
虚拟内存区域满足以下条件才允许使用透明巨型页。
(1)以下条件二选一。
1)总是使用透明巨型页。
2)只在进程使用madvise(MADV_HUGEPAGE)指定的虚拟地址范围内使用透明巨型页,并且虚拟内存区域设置了允许使用透明巨型页的标志。
(2)虚拟内存区域没有设置不允许使用透明巨型页的标志。
假设一个虚拟内存区域允许使用透明巨型页,访问虚拟内存区域的时候,如果没有映射到物理页,那么生成页错误异常,页错误异常处理程序的处理过程如下。
(1)首先尝试在页上层目录分配巨型页。如果触发异常的虚拟地址所属的虚拟巨型页超出虚拟内存区域,或者分配巨型页失败,那么回退,尝试在页中间目录分配巨型页。
(2)尝试在页中间目录分配巨型页。如果触发异常的虚拟地址所属的虚拟巨型页超出虚拟内存区域,或者分配巨型页失败,那么回退,尝试分配普通页。
(3)分配普通页。
页上层目录级别的巨型页和页中间目录级别的巨型页仅仅大小不同,页上层目录级别的巨型页大。以页中间目录级别的巨型页为例说明,分配巨型页的时候,会分配直接页表,把直接页表添加到页中间目录的直接页表寄存队列中。直接页表寄存队列有什么用处呢?当释放巨型页的一部分时,巨型页分裂成普通页,需要从直接页表寄存队列取一个直接页表。直接页表寄存队列分两种情况。
(1)如果每个页中间目录使用独立的锁,那么每个页中间目录一个直接页表寄存队列,头节点是页中间目录的页描述符的成员pmd_huge_pte。
(2)如果一个进程的所有页中间目录共用一个锁,那么每个进程一个直接页表寄存队列,头节点是内存描述符的成员pmd_huge_pte:
内核有一个透明巨型页线程(线程名称是khugepaged),定期地扫描允许使用透明巨型页的虚拟内存区域,尝试把普通页合并成巨型页。
在分配透明巨页时,会把进程的内存描述符加入透明巨型页线程的扫描链表中,如果分配透明巨型页失败,回退使用普通页,透明巨型页线程将会尝试把普通页合并成巨型页。
透明巨型页线程的数据结构如下所示:
(1)扫描游标khugepaged_scan:成员mm_head是扫描链表的头节点,扫描链表的成员是内存描述符插槽;成员 mm_slot 指向当前正在扫描的内存描述符插槽,成员 address是即将扫描的下一个虚拟地址。
(2)内存描述符插槽mm_slot:成员mm指向进程的内存描述符,成员hash用来加入散列表,成员mm_node用来加入扫描链表。
(3)内存描述符插槽散列表mm_slots_hash。
(4)加入扫描链表的内存描述符设置了标志MMF_VM_HUGEPAGE。
当进程使用munmap释放巨型页的一部分时,需要把巨型页分裂成普通页。
以页中间目录级别的巨型页为例说明,执行过程如下:
(1)先把巨型页分裂成普通页。从直接页表寄存队列取一个直接页表,页中间目录表项指向直接页表,直接页表的每个表项指向巨型页中的一个普通页。
(2)释放普通页,把直接页表表项删除。
透明巨型页分裂前如下所示:
透明巨型页分裂后如下所示:
(1)分配透明巨型页。
函数handle_mm_fault是页错误异常处理程序的核心函数,如果触发异常的虚拟内存区域使用普通页或透明巨型页,把主要工作委托给函数__handle_mm_fault。
1)在页全局目录中查找表项。
2)在页四级目录中查找表项,如果页四级目录不存在,先创建页四级目录。
3)在页上层目录中查找表项,如果页上层目录不存在,先创建页上层目录。
4)如果页上层目录表项是空表项,并且虚拟内存区域允许使用透明巨型页,那么分配巨型页,页上层目录表项指向巨型页。如果触发异常的虚拟地址所属的虚拟巨型页超出虚拟内存区域,或者分配巨型页失败,那么回退使用页中间目录级别的巨型页。
5)如果页上层目录表项指向巨型页,表项没有设置写权限,但是虚拟内存区域有写权限,那么执行写时复制。如果分配巨型页失败,那么回退使用页中间目录级别的巨型页。
6)在页中间目录中查找表项,如果页中间目录不存在,先创建页中间目录。
7)如果页中间目录表项是空表项,并且虚拟内存区域允许使用透明巨型页,那么分配巨型页,页中间目录表项指向巨型页。如果触发异常的虚拟地址所属的虚拟巨型页超出虚拟内存区域,或者分配巨型页失败,那么回退使用普通页。
8)如果页中间目录表项指向巨型页,表项没有设置写权限,但是虚拟内存区域有写权限,那么执行写时复制。如果分配巨型页失败,那么回退使用普通页。
9)在直接页表中分配并映射到普通页。
1)如果是私有匿名映射,调用函数do_huge_pmd_anonymous_page来处理。
如果触发异常的虚拟地址所属的虚拟巨型页超出虚拟内存区域,那么回退使用普通页。
调用函数anon_vma_prepare,为反向映射准备结构体anon_vma。
调用函数khugepaged_enter,把内存描述符添加到透明巨型页线程的扫描链表中。如果分配巨型页失败,回退使用普通页,透明巨型页线程将会尝试把普通页合并成巨型页。
如果是读操作,并且允许使用巨型零页,那么映射到全局的巨型零页。
分配巨型页。
调用函数__do_huge_pmd_anonymous_page,设置页中间目录表项,映射到巨型页。
2)如果是文件映射或者共享匿名映射,调用虚拟内存区域的虚拟内存操作集合中的huge_fault方法来处理。
1)分配直接页表。
2)把巨型页清零。
3)设置页描述符的标志位PG_uptodate,表示物理页包含有效的数据。
4)锁住页表。
5)如果锁住页表以后发现页中间目录表项不是空表项,说明其他处理器正在竞争,已经分配并且映射到物理页,那么当前处理器放弃操作。
6)构造页中间目录表项的值。
7)为匿名巨型页添加反向映射。
8)把巨型页添加到LRU链表中。
9)把直接页表添加到寄存队列中。当释放巨型页的一部分时,需要把巨型页分裂成普通页,从寄存队列取出直接页表使用。
10)设置页中间目录表项指向巨型页。
11)释放页表锁。
(2)透明巨型页线程。
透明巨型页线程负责定期扫描允许使用透明巨型页的虚拟内存区域,尝试把普通页合并成巨型页,执行流程如图3.77所示。每次从上次结束的位置继续扫描内存描述符插槽链表,对扫描的页数(指普通页的数量)有限制,如果扫描的页数达到限制,睡眠一段时间后继续扫描。函数khugepaged_do_scan的执行过程是,如果扫描的页数没有达到限制,重复执行下面的步骤:
1)调用函数khugepaged_prealloc_page,预先分配一个巨型页。在NUMA系统上函数khugepaged_prealloc_page不会分配巨型页,执行到函数collapse_huge_page的时候才分配巨型页。
2)调用函数khugepaged_scan_mm_slot,扫描一个内存描述符插槽,针对进程的每个虚拟内存区域,处理如下。
调用函数hugepage_vma_check,检查虚拟内存区域是否允许使用透明巨型页。
如果是共享内存,调用函数khugepaged_scan_shmem来处理。
如果不是共享内存,调用函数khugepaged_scan_pmd来处理。
函数khugepaged_scan_pmd的执行过程是:如果页中间目录表项指向直接页表,至少一个页有写权限,并且至少一个页刚刚被访问过,那么调用函数collapse_huge_page,把普通页合并成巨型页。如果全部是只读页,或者最近都没有访问过,那么不会合并成巨型页。
函数collapse_huge_page负责把普通页合并成巨型页,执行流程如图3.78所示:
1)调用函数khugepaged_alloc_page以分配巨型页。这个函数只会在NUMA系统上分配巨型页,如果不是NUMA系统,函数khugepaged_do_scan已经分配了巨型页。
2)部分普通页可能换出到交换区,需要把这些普通页从交换区读到内存中。
3)隔离准备合并的所有普通页。
4)把数据从普通页复制到巨型页,释放普通页。
5)为巨型页添加反向映射。
6)把巨型页加入LRU链表。
7)把直接页表添加到寄存队列中。
8)设置页中间目录表项指向巨型页。
9)更新页表缓存。
(3)释放透明巨型页。
假设进程使用munmap释放虚拟内存区域,而这个虚拟内存区域可能映射到透明巨型页,可能是释放巨型页的一部分,也可能是释放整个巨型页。
函数unmap_single_vma负责删除一个虚拟内存区域,执行流程如图3.79所示。如果虚拟内存区域使用普通页或透明巨型页,把主要工作委托给函数unmap_page_range:
函数unmap_page_range负责处理页全局目录,针对每个需要删除的页全局目录表项,调用函数zap_p4d_range来处理页四级目录。
函数zap_p4d_range负责处理页四级目录,针对每个需要删除的页四级目录表项,调用函数zap_pud_range来处理页上层目录。
函数zap_pud_range负责处理页上层目录,针对每个需要删除的页上层目录表项,执行过程如下。
1)如果页上层目录表项指向透明巨型页,处理如下。
如果释放巨型页的一部分,那么调用函数split_huge_pud以分裂巨型页。
如果释放整个巨型页,那么调用函数zap_huge_pud以删除页上层目录表项,释放巨型页。
2)如果页上层目录表项指向页中间目录,或者释放巨型页的一部分,那么调用函数zap_pmd_range以处理页中间目录。
函数zap_pmd_range负责处理页中间目录,针对每个需要删除的页中间目录表项,执行过程如下。
1)如果页中间目录表项指向透明巨型页,处理如下。
如果释放巨型页的一部分,那么调用函数__split_huge_pmd以分裂巨型页。
如果释放整个巨型页,那么调用函数zap_huge_pmd以删除页中间目录表项,释放巨型页。
2)如果页中间目录表项指向直接页表,或者释放巨型页的一部分,那么调用函数zap_pte_range以处理直接页表。
函数__split_huge_pmd负责把页中间目录级别的透明巨型页分裂成普通页,执行流程如图3.80所示。把主要工作委托给函数__split_huge_pmd_locked,执行过程如下。
1)如果是文件映射或者共享匿名映射,说明巨型页在文件的页缓存中,因为可能有多个进程共享,所以不能把巨型页分裂成普通页,只需要删除映射,处理如下。
删除页中间目录表项。
从寄存队列中取一个直接页表并删除。
删除反向映射。
把巨型页的引用计数减1。
2)如果是私有匿名映射,映射到巨型零页,那么调用函数__split_huge_zero_page_pmd以分裂巨型零页。
3)如果是私有匿名映射,没有映射到巨型零页,那么把巨型页分裂成普通页,处理如下。
从寄存队列中取一个直接页表。
填充直接页表,每个表项指向巨型页中的一个普通页。
清除页中间目录表项。
页中间目录表项指向直接页表。