一步一图带你深入理解 Linux 物理内存管理(下)

我们接着上半部分 《一步一图带你深入理解 Linux 物理内存管理(上)》 继续 Linux 物理内存管理的下半部分~~~

5.7 物理内存区域中的冷热页

之前笔者在《一文聊透对象在JVM中的内存布局,以及内存对齐和压缩指针的原理及应用》 一文中为大家介绍 CPU 的高速缓存时曾提到过,根据摩尔定律:芯片中的晶体管数量每隔 18 个月就会翻一番。导致 CPU 的性能和处理速度变得越来越快,而提升 CPU 的运行速度比提升内存的运行速度要容易和便宜的多,所以就导致了 CPU 与内存之间的速度差距越来越大。

CPU 与 内存之间的速度差异到底有多大呢? 我们知道寄存器是离 CPU 最近的,CPU 在访问寄存器的时候速度近乎于 0 个时钟周期,访问速度最快,基本没有时延。而访问内存则需要 50 - 200 个时钟周期。

所以为了弥补 CPU 与内存之间巨大的速度差异,提高CPU的处理效率和吞吐,于是我们引入了 L1 , L2 , L3 高速缓存集成到 CPU 中。CPU 访问高速缓存仅需要用到 1 - 30 个时钟周期,CPU 中的高速缓存是对内存热点数据的一个缓存。

CPU缓存结构.png

CPU 访问高速缓存的速度比访问内存的速度快大约10倍,引入高速缓存的目的在于消除CPU与内存之间的速度差距,CPU 用高速缓存来用来存放内存中的热点数据。

另外我们根据程序的时间局部性原理可以知道,内存的数据一旦被访问,那么它很有可能在短期内被再次访问,如果我们把经常访问的物理内存页缓存在 CPU 的高速缓存中,那么当进程再次访问的时候就会直接命中 CPU 的高速缓存,避免了进一步对内存的访问,极大提升了应用程序的性能。

程序局部性原理表现为:时间局部性和空间局部性。时间局部性是指如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行;如果某块数据被访问,则不久之后该数据可能再次被访问。空间局部性是指一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问。

本文我们的主题是 Linux 物理内存的管理,那么在 NUMA 内存架构下,这些 NUMA 节点中的物理内存区域 zone 管理的这些物理内存页,哪些是在 CPU 的高速缓存中?哪些又不在 CPU 的高速缓存中呢?内核如何来管理这些加载进 CPU 高速缓存中的物理内存页呢?

image.png

本小节标题中所谓的热页就是已经加载进 CPU 高速缓存中的物理内存页,所谓的冷页就是还未加载进 CPU 高速缓存中的物理内存页,冷页是热页的后备选项。

笔者先以内核版本 2.6.25 之前的冷热页相关的管理逻辑为大家讲解,因为这个版本的逻辑比较直观,大家更容易理解。在这个基础之上,笔者会在介绍内核 5.0 版本对于冷热页管理的逻辑,差别不是很大。

struct zone {
    struct per_cpu_pageset  pageset[NR_CPUS];
}

在 2.6.25 版本之前的内核源码中,物理内存区域 struct zone 包含了一个 struct per_cpu_pageset 类型的数组 pageset。其中内核关于冷热页的管理全部封装在 struct per_cpu_pageset 结构中。

因为每个 CPU 都有自己独立的高速缓存,所以每个 CPU 对应一个 per_cpu_pageset 结构,pageset 数组容量 NR_CPUS 是一个可以在编译期间配置的宏常数,表示内核可以支持的最大 CPU个数,注意该值并不是系统实际存在的 CPU 数量。

在 NUMA 内存架构下,每个物理内存区域都是属于一个特定的 NUMA 节点,NUMA 节点中包含了一个或者多个 CPU,NUMA 节点中的每个内存区域会关联到一个特定的 CPU 上,但 struct zone 结构中的 pageset 数组包含的是系统中所有 CPU 的高速缓存页。

因为虽然一个内存区域关联到了 NUMA 节点中的一个特定 CPU 上,但是其他CPU 依然可以访问该内存区域中的物理内存页,因此其他 CPU 上的高速缓存仍然可以包含该内存区域中的物理内存页。

每个 CPU 都可以访问系统中的所有物理内存页,尽管访问速度不同(这在前边我们介绍 NUMA 架构的时候已经介绍过),因此特定的物理内存区域 struct zone 不仅要考虑到所属 NUMA 节点中相关的 CPU,还需要照顾到系统中的其他 CPU。

在表示每个 CPU 高速缓存结构 struct per_cpu_pageset 中有一个 struct per_cpu_pages 类型的数组 pcp,容量为 2。 数组 pcp 索引 0 表示该内存区域加载进 CPU 高速缓存的热页集合,索引 1 表示该内存区域中还未加载进 CPU 高速缓存的冷页集合。

struct per_cpu_pageset {
    struct per_cpu_pages pcp[2];    /* 0: hot.  1: cold */
}

struct per_cpu_pages 结构则是最终用于管理 CPU 高速缓存中的热页,冷页集合的数据结构:

struct per_cpu_pages {
    int count;      /* number of pages in the list */
    int high;       /* high watermark, emptying needed */
    int batch;      /* chunk size for buddy add/remove */
    struct list_head list;  /* the list of pages */
};
  • int count :表示集合中包含的物理页数量,如果该结构是热页集合,则表示加载进 CPU 高速缓存中的物理页面个数。

  • struct list_head list :该 list 是一个双向链表,保存了当前 CPU 的热页或者冷页。

  • int batch:每次批量向 CPU 高速缓存填充或者释放的物理页面个数。

  • int high:如果集合中页面的数量 count 值超过了 high 的值,那么表示 list 中的页面太多了,内核会从高速缓存中释放 batch 个页面到物理内存区域中的伙伴系统中。

  • int low : 在之前更老的版本中,per_cpu_pages 结构还定义了一个 low 下限值,如果 count 低于 low 的值,那么内核会从伙伴系统中申请 batch 个页面填充至当前 CPU 的高速缓存中。之后的版本中取消了 low ,内核对容量过低的页面集合并没有显示的使用水位值 low,当列表中没有其他成员时,内核会重新填充高速缓存。

以上则是内核版本 2.6.25 之前管理 CPU 高速缓存冷热页的相关数据结构,我们看到在 2.6.25 之前,内核是使用两个 per_cpu_pages 结构来分别管理冷页和热页集合的

后来内核开发人员通过测试发现,用两个列表来管理冷热页,并不会比用一个列表集中管理冷热页带来任何的实质性好处,因此在内核版本 2.6.25 之后,将冷页和热页的管理合并在了一个列表中,热页放在列表的头部,冷页放在列表的尾部。

在内核 5.0 的版本中, struct zone 结构中去掉了原来使用 struct per_cpu_pageset 数,因为 struct per_cpu_pageset 结构中分别管理了冷页和热页。

struct zone {
    struct per_cpu_pages    __percpu *per_cpu_pageset;

    int pageset_high;
    int pageset_batch;

} ____cacheline_internodealigned_in_smp;

直接使用 struct per_cpu_pages 结构的链表来集中管理系统中所有 CPU 高速缓存冷热页。

struct per_cpu_pages {
    int count;      /* number of pages in the list */
    int high;       /* high watermark, emptying needed */
    int batch;      /* chunk size for buddy add/remove */
        
        .............省略............

    /* Lists of pages, one per migrate type stored on the pcp-lists */
    struct list_head lists[NR_PCP_LISTS];
};

前面我们提到,内核为了最大程度的防止内存碎片,将物理内存页面按照是否可迁移的特性分为了多种迁移类型:可迁移,可回收,不可迁移。在 struct per_cpu_pages 结构中,每一种迁移类型都会对应一个冷热页链表。

6. 内核如何描述物理内存页

image.png

经过前边几个小节的介绍,我想大家现在应该对 Linux 内核整个内存管理框架有了一个总体上的认识。

如上图所示,在 NUMA 架构下内存被划分成了一个一个的内存节点(NUMA Node),在每个 NUMA 节点中,内核又根据节点内物理内存的功能用途不同,将 NUMA 节点内的物理内存划分为四个物理内存区域分别是:ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHMEM。其中 ZONE_MOVABLE 区域是逻辑上的划分,主要是为了防止内存碎片和支持内存的热插拔。

物理内存区域中管理的就是物理内存页( Linux 内存管理的最小单位),前面我们介绍的内核对物理内存的换入,换出,回收,内存映射等操作的单位就是页。内核为每一个物理内存区域分配了一个伙伴系统,用于管理该物理内存区域下所有物理内存页面的分配和释放。

Linux 默认支持的物理内存页大小为 4KB,在 64 位体系结构中还可以支持 8KB,有的处理器还可以支持 4MB,支持物理地址扩展 PAE 机制的处理器上还可以支持 2MB。

那么 Linux 为什么会默认采用 4KB 作为标准物理内存页的大小呢

首先关于物理页面的大小,Linux 规定必须是 2 的整数次幂,因为 2 的整数次幂可以将一些数学运算转换为移位操作,比如乘除运算可以通过移位操作来实现,这样效率更高。

那么系统支持 4KB,8KB,2MB,4MB 等大小的物理页面,它们都是 2 的整数次幂,为啥偏偏要选 4KB 呢?

因为前面提到,在内存紧张的时候,内核会将不经常使用到的物理页面进行换入换出等操作,还有在内存与文件映射的场景下,都会涉及到与磁盘的交互,数据在磁盘中组织形式也是根据一个磁盘块一个磁盘块来管理的,4kB 和 4MB 都是磁盘块大小的整数倍,但在大多数情况下,内存与磁盘之间传输小块数据时会更加的高效,所以综上所述内核会采用 4KB 作为默认物理内存页大小。


假设我们有 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 结构在不同场景下的使用方式:

第一种使用方式是内核直接分配使用一整页的物理内存,在《5.2 物理内存区域中的水位线》小节中我们提到,内核中的物理内存页有两种类型,分别用于不同的场景:

  1. 一种是匿名页,匿名页背后并没有一个磁盘中的文件作为数据来源,匿名页中的数据都是通过进程运行过程中产生的,匿名页直接和进程虚拟地址空间建立映射供进程使用。

  2. 另外一种是文件页,文件页中的数据来自于磁盘中的文件,文件页需要先关联一个磁盘中的文件,然后再和进程虚拟地址空间建立映射供进程使用,使得进程可以通过操作虚拟内存实现对文件的操作,这就是我们常说的内存文件映射。

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 字段。提到 struct address_space 结构,如果大家之前看过笔者 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》 这篇文章的话,一定不会对 struct address_space 感到陌生。

image.png

在内核中每个文件都会有一个属于自己的 page cache(页高速缓存),页高速缓存在内核中的结构体就是这个 struct address_space。它被文件的 inode 所持有。

如果当前物理内存页 struct page 是一个文件页的话,那么 mapping 指针的最低位会被设置为 0 ,指向该内存页关联文件的 struct address_space(页高速缓存),pgoff_t index 字段表示该内存页 page 在页高速缓存 page cache 中的 index 索引。内核会利用这个 index 字段从 page cache 中查找该物理内存页,

image.png

同时该 pgoff_t index 字段也表示该内存页中的文件数据在文件内部的偏移 offset。偏移单位为 page size。

对相关查找细节感兴趣的同学可以在回看下笔者 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》 文章中的《8. page cache 中查找缓存页》小节。

如果当前物理内存页 struct page 是一个匿名页的话,那么 mapping 指针的最低位会被设置为 1 , 指向该匿名页在进程虚拟内存空间中的匿名映射区域 struct anon_vma 结构(每个匿名页对应唯一的 anon_vma 结构),用于物理内存到虚拟内存的反向映射。

6.1 匿名页的反向映射

我们通常所说的内存映射是正向映射,即从虚拟内存到物理内存的映射。而反向映射则是从物理内存到虚拟内存的映射,用于当某个物理内存页需要进行回收或迁移时,此时需要去找到这个物理页被映射到了哪些进程的虚拟地址空间中,并断开它们之间的映射。

在没有反向映射的机制前,需要去遍历所有进程的虚拟地址空间中的映射页表,这个效率显然是很低下的。有了反向映射机制之后内核就可以直接找到该物理内存页到所有进程映射的虚拟地址空间 VMA ,并从 VMA 使用的进程页表中取消映射,

谈到 VMA 大家一定不会感到陌生,VMA 相关的内容笔者在 《深入理解 Linux 虚拟内存管理》 这篇文章中详细的介绍过。

如下图所示,进程的虚拟内存空间在内核中使用 struct mm_struct 结构表示,进程的虚拟内存空间包含了一段一段的虚拟内存区域 VMA,比如我们经常接触到的堆,栈。内核中使用 struct vm_area_struct 结构来描述这些虚拟内存区域。

image.png

这里笔者只列举出 struct vm_area_struct 结构中与匿名页反向映射相关的字段属性:

struct vm_area_struct {  

    struct list_head anon_vma_chain;
    struct anon_vma *anon_vma;   
}

这里大家可能会感到好奇,既然内核中有了 struct vm_area_struct 结构来描述虚拟内存区域,那不管是文件页也好,还是匿名页也好,都可以使用 struct vm_area_struct 结构体来进行描述,这里为什么有会出现 struct anon_vma 结构和 struct anon_vma_chain 结构?这两个结构到底是干嘛的?如何利用它俩来完成匿名内存页的反向映射呢?

根据前几篇文章的内容我们知道,进程利用 fork 系统调用创建子进程的时候,内核会将父进程的虚拟内存空间相关的内容拷贝到子进程的虚拟内存空间中,此时子进程的虚拟内存空间和父进程的虚拟内存空间是一模一样的,其中虚拟内存空间中映射的物理内存页也是一样的,在内核中都是同一份,在父进程和子进程之间共享(包括 anon_vma 和 anon_vma_chain)。

当进程在向内核申请内存的时候,内核首先会为进程申请的这块内存创建初始化一段虚拟内存区域 struct vm_area_struct 结构,但是并不会为其分配真正的物理内存。

当进程开始访问这段虚拟内存时,内核会产生缺页中断,在缺页中断处理函数中才会去真正的分配物理内存(这时才会为子进程创建自己的 anon_vma 和 anon_vma_chain),并建立虚拟内存与物理内存之间的映射关系(正向映射)。

static vm_fault_t handle_pte_fault(struct vm_fault *vmf)
{
        .............

    if (!vmf->pte) {
        if (vma_is_anonymous(vmf->vma))
            // 处理匿名页缺页
            return do_anonymous_page(vmf);
        else
            // 处理文件页缺页
            return do_fault(vmf);
    }

        .............

    if (vmf->flags & (FAULT_FLAG_WRITE|FAULT_FLAG_UNSHARE)) {
        if (!pte_write(entry))
            // 子进程缺页处理
            return do_wp_page(vmf);
    }

这里我们主要关注 do_anonymous_page 函数,正是在这里内核完成了 struct anon_vma 结构和 struct anon_vma_chain 结构的创建以及相关匿名页反向映射数据结构的相互关联。

static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
    struct vm_area_struct *vma = vmf->vma;
    struct page *page;  

        ........省略虚拟内存到物理内存正向映射相关逻辑.........

    if (unlikely(anon_vma_prepare(vma)))
        goto oom;

    page = alloc_zeroed_user_highpage_movable(vma, vmf->address);

    if (!page)
        goto oom;
  // 建立反向映射关系
    page_add_new_anon_rmap(page, vma, vmf->address);

        ........省略虚拟内存到物理内存正向映射相关逻辑.........
}

在 do_anonymous_page 匿名页缺页处理函数中会为 struct vm_area_struct 结构创建匿名页相关的 struct anon_vma 结构和 struct anon_vma_chain 结构。

并在 anon_vma_prepare 函数中实现 anon_vma 和 anon_vma_chain 之间的关联 ,随后调用 alloc_zeroed_user_highpage_movable 从伙伴系统中获取物理内存页 struct page,并在 page_add_new_anon_rmap 函数中完成 struct page 与 anon_vma 的关联(这里正是反向映射关系建立的关键)

在介绍匿名页反向映射源码实现之前,笔者先来为大家介绍一下相关的两个重要数据结构 struct anon_vma 和 struct anon_vma_chain,方便大家理解为何 struct page 与 anon_vma 关联起来就能实现反向映射?

前面我们提到,匿名页的反向映射关键就是建立物理内存页 struct page 与进程虚拟内存空间 VMA 之间的映射关系。

匿名页的 struct page 中的 mapping 指针指向的是 struct anon_vma 结构。

struct page {
    struct address_space *mapping; 
    pgoff_t index;  
}

只要我们实现了 anon_vma 与 vm_area_struct 之间的关联,那么 page 到 vm_area_struct 之间的映射就建立起来了,struct anon_vma_chain 结构做的事情就是建立 anon_vma 与 vm_area_struct 之间的关联关系。

struct anon_vma_chain {
    // 匿名页关联的进程虚拟内存空间(vma属于一个特定的进程,多个进程多个vma)
    struct vm_area_struct *vma;
    // 匿名页 page 指向的 anon_vma
    struct anon_vma *anon_vma;
    struct list_head same_vma;   
    struct rb_node rb;         
    unsigned long rb_subtree_last;
#ifdef CONFIG_DEBUG_VM_RB
    unsigned long cached_vma_start, cached_vma_last;
#endif
};

struct anon_vma_chain 结构通过其中的 vma 指针和 anon_vma 指针将相关的匿名页与其映射的进程虚拟内存空间关联了起来。

image.png

从目前来看匿名页 struct page 算是与 anon_vma 建立了关系,又通过 anon_vma_chain 将 anon_vma 与 vm_area_struct 建立了关系。那么就剩下最后一道关系需要打通了,就是如何通过 anon_vma 找到 anon_vma_chain 进而找到 vm_area_struct 呢?这就需要我们将 anon_vma 与 anon_vma_chain 之间的关系也打通。

我们知道每个匿名页对应唯一的 anon_vma 结构,但是一个匿名物理页可以映射到不同进程的虚拟内存空间中,每个进程的虚拟内存空间都是独立的,也就是说不同的进程就会有不同的 VMA。

image.png

不同的 VMA 意味着同一个匿名页 anon_vma 就会对应多个 anon_vma_chain。那么如何通过一个 anon_vma 找到和他关联的所有 anon_vma_chain 呢?找到了这些 anon_vma_chain 也就意味着 struct page 找到了与它关联的所有进程虚拟内存空间 VMA。

我们看看能不能从 struct anon_vma 结构中寻找一下线索:

struct anon_vma {
    struct anon_vma *root;      /* Root of this anon_vma tree */
    struct rw_semaphore rwsem; 
    atomic_t refcount;
    unsigned degree;
    struct anon_vma *parent;    /* Parent of this anon_vma */
    struct rb_root rb_root; /* Interval tree of private "related" vmas */
};

我们重点来看 struct anon_vma 结构中的 rb_root 字段,struct anon_vma 结构中管理了一颗红黑树,这颗红黑树上管理的全部都是与该 anon_vma 关联的 anon_vma_chain。我们可以通过 struct page 中的 mapping 指针找到 anon_vma,然后遍历 anon_vma 中的这颗红黑树 rb_root ,从而找到与其关联的所有 anon_vma_chain。

struct anon_vma_chain {
    // 匿名页关联的进程虚拟内存空间(vma属于一个特定的进程,多个进程多个vma)
    struct vm_area_struct *vma;
    // 匿名页 page 指向的 anon_vma
    struct anon_vma *anon_vma;
    // 指向 vm_area_struct 中的 anon_vma_chain 列表
    struct list_head same_vma;   
    // anon_vma 管理的红黑树中该 anon_vma_chain 对应的红黑树节点
    struct rb_node rb;         
};

struct anon_vma_chain 结构中的 rb 字段表示其在对应 anon_vma 管理的红黑树中的节点。

image.png

到目前为止,物理内存页 page 到与其映射的进程虚拟内存空间 VMA,这样一种一对多的映射关系现在就算建立起来了。

而 vm_area_struct 表示的只是进程虚拟内存空间中的一段虚拟内存区域,这块虚拟内存区域中可能会包含多个匿名页,所以 VMA 与物理内存页 page 也是有一对多的映射关系存在。而这个映射关系在哪里保存呢?

大家注意 struct anon_vma_chain 结构中还有一个列表结构 same_vma,从这个名字上我们很容易就能猜到这个列表 same_vma 中存储的 anon_vma_chain 对应的 VMA 全都是一样的,而列表元素 anon_vma_chain 中的 anon_vma 却是不一样的。内核用这样一个链表结构 same_vma 存储了进程相应虚拟内存区域 VMA 中所包含的所有匿名页。

struct vm_area_struct 结构中的 struct list_head anon_vma_chain 指向的也是这个列表 same_vma。

struct vm_area_struct {  
    // 存储该 VMA 中所包含的所有匿名页 anon_vma
    struct list_head anon_vma_chain;
    // 用于快速判断 VMA 有没有对应的匿名 page
    // 一个 VMA 可以包含多个 page,但是该区域内的所有 page 只需要一个 anon_vma 来反向映射即可。
    struct anon_vma *anon_vma;   
}
image.png

现在整个匿名页到进程虚拟内存空间的反向映射链路关系,笔者就为大家梳理清楚了,下面我们接着回到 do_anonymous_page 函数中,来一一验证上述映射逻辑:

static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
    struct vm_area_struct *vma = vmf->vma;
    struct page *page;  

        ........省略虚拟内存到物理内存正向映射相关逻辑.........

    if (unlikely(anon_vma_prepare(vma)))
        goto oom;

    page = alloc_zeroed_user_highpage_movable(vma, vmf->address);

    if (!page)
        goto oom;

    page_add_new_anon_rmap(page, vma, vmf->address);

        ........省略虚拟内存到物理内存正向映射相关逻辑.........
}

在 do_anonymous_page 中首先会调用 anon_vma_prepare 方法来为匿名页创建 anon_vma 实例和 anon_vma_chain 实例,并建立它们之间的关联关系。

int __anon_vma_prepare(struct vm_area_struct *vma)
{
    // 获取进程虚拟内存空间
    struct mm_struct *mm = vma->vm_mm;
    // 准备为匿名页分配 anon_vma 以及 anon_vma_chain
    struct anon_vma *anon_vma, *allocated;
    struct anon_vma_chain *avc;
    // 分配 anon_vma_chain 实例
    avc = anon_vma_chain_alloc(GFP_KERNEL);
    if (!avc)
        goto out_enomem;
    // 在相邻的虚拟内存区域 VMA 中查找可复用的 anon_vma
    anon_vma = find_mergeable_anon_vma(vma);
    allocated = NULL;
    if (!anon_vma) {
        // 没有可复用的 anon_vma 则创建一个新的实例
        anon_vma = anon_vma_alloc();
        if (unlikely(!anon_vma))
            goto out_enomem_free_avc;
        allocated = anon_vma;
    }

    anon_vma_lock_write(anon_vma);
    /* page_table_lock to protect against threads */
    spin_lock(&mm->page_table_lock);
    if (likely(!vma->anon_vma)) {
        // VMA 中的 anon_vma 属性就是在这里赋值的
        vma->anon_vma = anon_vma;
        // 建立反向映射关联
        anon_vma_chain_link(vma, avc, anon_vma);
        /* vma reference or self-parent link for new root */
        anon_vma->degree++;
        allocated = NULL;
        avc = NULL;
    }
        .................
}

anon_vma_prepare 方法中调用 anon_vma_chain_link 方法来建立 anon_vma,anon_vma_chain,vm_area_struct 三者之间的关联关系:

static void anon_vma_chain_link(struct vm_area_struct *vma,
                struct anon_vma_chain *avc,
                struct anon_vma *anon_vma)
{
    // 通过 anon_vma_chain 关联 anon_vma 和对应的 vm_area_struct
    avc->vma = vma;
    avc->anon_vma = anon_vma;
    // 将 vm_area_struct 中的 anon_vma_chain 链表加入到 anon_vma_chain 中的 same_vma 链表中
    list_add(&avc->same_vma, &vma->anon_vma_chain);
    // 将初始化好的 anon_vma_chain 加入到 anon_vma 管理的红黑树 rb_root 中
    anon_vma_interval_tree_insert(avc, &anon_vma->rb_root);
}
image.png

到现在为止还缺关键的最后一步,就是打通匿名内存页 page 到 vm_area_struct 之间的关系,首先我们就需要调用 alloc_zeroed_user_highpage_movable 方法从伙伴系统中申请一个匿名页。当获取到 page 实例之后,通过 page_add_new_anon_rmap 最终建立起 page 到 vm_area_struct 的整条反向映射链路。

static void __page_set_anon_rmap(struct page *page,
    struct vm_area_struct *vma, unsigned long address, int exclusive)
{
    struct anon_vma *anon_vma = vma->anon_vma;
           .........省略..............
    // 低位置 1
    anon_vma = (void *) anon_vma + PAGE_MAPPING_ANON;
    // 转换为 address_space 指针赋值给 page 结构中的 mapping 字段
    page->mapping = (struct address_space *) anon_vma;
    // page 结构中的 index 表示该匿名页在虚拟内存区域 vma 中的偏移
    page->index = linear_page_index(vma, address);
}

现在让我们再次回到本小节 《6.1 匿名页的反向映射》的开始,再来看这段话,是不是感到非常清晰了呢~~

如果当前物理内存页 struct page 是一个匿名页的话,那么 mapping 指针的最低位会被设置为 1 , 指向该匿名页在进程虚拟内存空间中的匿名映射区域 struct anon_vma 结构(每个匿名页对应唯一的 anon_vma 结构),用于物理内存到虚拟内存的反向映射。

如果当前物理内存页 struct page 是一个文件页的话,那么 mapping 指针的最低位会被设置为 0 ,指向该内存页关联文件的 struct address_space(页高速缓存)。pgoff_t index 字段表示该内存页 page 在页高速缓存中的 index 索引,也表示该内存页中的文件数据在文件内部的偏移 offset。偏移单位为 page size。

struct page 结构中的 struct address_space *mapping 指针的最低位如何置 1 ,又如何置 0 呢?关键在下面这条语句:

    struct anon_vma *anon_vma = vma->anon_vma;
    // 低位置 1
    anon_vma = (void *) anon_vma + PAGE_MAPPING_ANON;

anon_vma 指针加上 PAGE_MAPPING_ANON ,并转换为 address_space 指针,这样可确保 address_space 指针的低位为 1 表示匿名页。

address_space 指针在转换为 anon_vma 指针的时候可通过如下语句实现:

anon_vma = (struct anon_vma *) (mapping - PAGE_MAPPING_ANON)

PAGE_MAPPING_ANON 常量定义在内核 /include/linux/page-flags.h 文件中:

#define PAGE_MAPPING_ANON   0x1

而对于文件页来说,page 结构的 mapping 指针最低位本来就是 0 ,因为 address_space 类型的指针实现总是对齐至 sizeof(long),因此在 Linux 支持的所有计算机上,指向 address_space 实例的指针最低位总是为 0 。

内核可以通过这个技巧直接检查 page 结构中的 mapping 指针的最低位来判断该物理内存页到底是匿名页还是文件页

前面说了文件页的 page 结构的 index 属性表示该内存页 page 在磁盘文件中的偏移 offset ,偏移单位为 page size 。

那匿名页的 page 结构中的 index 属性表示什么呢?我们接着来看 linear_page_index 函数:

static inline pgoff_t linear_page_index(struct vm_area_struct *vma,
                    unsigned long address)
{
    pgoff_t pgoff;
    if (unlikely(is_vm_hugetlb_page(vma)))
        return linear_hugepage_index(vma, address);
    pgoff = (address - vma->vm_start) >> PAGE_SHIFT;
    pgoff += vma->vm_pgoff;
    return pgoff;
}

逻辑很简单,就是表示匿名页在对应进程虚拟内存区域 VMA 中的偏移。

在本小节最后,还有一个与反向映射相关的重要属性就是 page 结构中的 _mapcount。

struct page {
    struct address_space *mapping; 
    pgoff_t index;  
    // 表示该 page 映射了多少个进程的虚拟内存空间,一个 page 可以被多个进程映射
    atomic_t _mapcount
}

经过本小节详细的介绍,我想大家现在已经猜到 _mapcount 字段的含义了,我们知道一个物理内存页可以映射到多个进程的虚拟内存空间中,比如:共享内存映射,父子进程的创建等。page 与 VMA 是一对多的关系,这里的 _mapcount 就表示该物理页映射到了多少个进程的虚拟内存空间中。

6.2 内存页回收相关属性

我们接着来看 struct page 中剩下的其他属性,我们知道物理内存页在内核中分为匿名页和文件页,在《5.2 物理内存区域中的水位线》小节中,笔者还提到过两个重要的链表分别为:active 链表和 inactive 链表。

其中 active 链表用来存放访问非常频繁的内存页(热页), inactive 链表用来存放访问不怎么频繁的内存页(冷页),当内存紧张的时候,内核就会优先将 inactive 链表中的内存页置换出去。

内核在回收内存的时候,这两个列表中的回收优先级为:inactive 链表尾部 > inactive 链表头部 > active 链表尾部 > active 链表头部。

我们可以通过 cat /proc/zoneinfo 命令来查看不同 NUMA 节点中不同内存区域中的 active 链表和 inactive 链表中物理内存页的个数:

image.png
  • nr_zone_active_anon 和 nr_zone_inactive_anon 分别是该内存区域内活跃和非活跃的匿名页数量。

  • nr_zone_active_file 和 nr_zone_inactive_file 分别是该内存区域内活跃和非活跃的文件页数量。

为什么会有 active 链表和 inactive 链表

内存回收的关键是如何实现一个高效的页面替换算法 PFRA (Page Frame Replacement Algorithm) ,提到页面替换算法大家可能立马会想到 LRU (Least-Recently-Used) 算法。LRU 算法的核心思想就是那些最近最少使用的页面,在未来的一段时间内可能也不会再次被使用,所以在内存紧张的时候,会优先将这些最近最少使用的页面置换出去。在这种情况下其实一个 active 链表就可以满足我们的需求。

但是这里会有一个严重的问题,LRU 算法更多的是在时间维度上的考量,突出最近最少使用,但是它并没有考量到使用频率的影响,假设有这样一种状况,就是一个页面被疯狂频繁的使用,毫无疑问它肯定是一个热页,但是这个页面最近的一次访问时间离现在稍微久了一点点,此时进来大量的页面,这些页面的特点是只会使用一两次,以后将再也不会用到。

在这种情况下,根据 LRU 的语义这个之前频繁地被疯狂访问的页面就会被置换出去了(本来应该将这些大量一次性访问的页面置换出去的),当这个页面在不久之后要被访问时,此时已经不在内存中了,还需要在重新置换进来,造成性能的损耗。这种现象也叫 Page Thrashing(页面颠簸)。

因此,内核为了将页面使用频率这个重要的考量因素加入进来,于是就引入了 active 链表和 inactive 链表。工作原理如下:

  1. 首先 inactive 链表的尾部存放的是访问频率最低并且最少访问的页面,在内存紧张的时候,这些页面被置换出去的优先级是最大的。

  2. 对于文件页来说,当它被第一次读取的时候,内核会将它放置在 inactive 链表的头部,如果它继续被访问,则会提升至 active 链表的尾部。如果它没有继续被访问,则会随着新文件页的进入,内核会将它慢慢的推到 inactive 链表的尾部,如果此时再次被访问则会直接被提升到 active 链表的头部。大家可以看出此时页面的使用频率这个因素已经被考量了进来。

  3. 对于匿名页来说,当它被第一次读取的时候,内核会直接将它放置在 active 链表的尾部,注意不是 inactive 链表的头部,这里和文件页不同。因为匿名页的换出 Swap Out 成本会更大,内核会对匿名页更加优待。当匿名页再次被访问的时候就会被被提升到 active 链表的头部。

  4. 当遇到内存紧张的情况需要换页时,内核会从 active 链表的尾部开始扫描,将一定量的页面降级到 inactive 链表头部,这样一来原来位于 inactive 链表尾部的页面就会被置换出去。

内核在回收内存的时候,这两个列表中的回收优先级为:inactive 链表尾部 > inactive 链表头部 > active 链表尾部 > active 链表头部。

为什么会把 active 链表和 inactive 链表分成两类,一类是匿名页,一类是文件页

在本文 《5.2 物理内存区域中的水位线》小节中,笔者为大家介绍了一个叫做 swappiness 的内核参数, 我们可以通过 cat /proc/sys/vm/swappiness 命令查看,swappiness 选项的取值范围为 0 到 100,默认为 60。

swappiness 用于表示 Swap 机制的积极程度,数值越大,Swap 的积极程度,越高越倾向于回收匿名页。数值越小,Swap 的积极程度越低,越倾向于回收文件页

因为回收匿名页和回收文件页的代价是不一样的,回收匿名页代价会更高一点,所以引入 swappiness 来控制内核回收的倾向。

注意: swappiness 只是表示 Swap 积极的程度,当内存非常紧张的时候,即使将 swappiness 设置为 0 ,也还是会发生 Swap 的。

假设我们现在只有 active 链表和 inactive 链表,不对这两个链表进行匿名页和文件页的归类,在需要页面置换的时候,内核会先从 active 链表尾部开始扫描,当 swappiness 被设置为 0 时,内核只会置换文件页,不会置换匿名页。

由于 active 链表和 inactive 链表没有进行物理页面类型的归类,所以链表中既会有匿名页也会有文件页,如果链表中有大量的匿名页的话,内核就会不断的跳过这些匿名页去寻找文件页,并将文件页替换出去,这样从性能上来说肯定是低效的。

因此内核将 active 链表和 inactive 链表按照匿名页和文件页进行了归类,当 swappiness 被设置为 0 时,内核只需要去 nr_zone_active_file 和 nr_zone_inactive_file 链表中扫描即可,提升了性能。

其实除了以上笔者介绍的四种 LRU 链表(匿名页的 active 链表,inactive 链表和文件页的active 链表, inactive 链表)之外,内核还有一种链表,比如进程可以通过 mlock() 等系统调用把内存页锁定在内存里,保证该内存页无论如何不会被置换出去,比如出于安全或者性能的考虑,页面中可能会包含一些敏感的信息不想被 swap 到磁盘上导致泄密,或者一些频繁访问的内存页必须一直贮存在内存中。

当这些被锁定在内存中的页面很多时,内核在扫描 active 链表的时候也不得不跳过这些页面,所以内核又将这些被锁定的页面单独拎出来放在一个独立的链表中。

现在笔者为大家介绍五种用于存放 page 的链表,内核会根据不同的情况将一个物理页放置在这五种链表其中一个上。那么对于物理页的 struct page 结构中就需要有一个属性用来标识该物理页究竟被内核放置在哪个链表上。

struct page {
   struct list_head lru;
   atomic_t _refcount;
}

struct list_head lru 属性就是用来指向物理页被放置在了哪个链表上。

atomic_t _refcount 属性用来记录内核中引用该物理页的次数,表示该物理页的活跃程度。

6.3 物理内存页属性和状态的标志位 flag

struct page {
    unsigned long flags;
} 

在本文 《2.3 SPARSEMEM 稀疏内存模型》小节中,我们提到,内核为了能够更灵活地管理粒度更小的连续物理内存,于是就此引入了 SPARSEMEM 稀疏内存模型。

image.png

SPARSEMEM 稀疏内存模型的核心思想就是提供对粒度更小的连续内存块进行精细的管理,用于管理连续内存块的单元被称作 section 。内核中用于描述 section 的数据结构是 struct mem_section。

由于 section 被用作管理小粒度的连续内存块,这些小的连续物理内存在 section 中也是通过数组的方式被组织管理(图中 struct page 类型的数组)。

每个 struct mem_section 结构体中有一个 section_mem_map 指针用于指向连续内存的 page 数组。而所有的 mem_section 也会被存放在一个全局的数组 mem_section 中。

那么给定一个具体的 struct page,在稀疏内存模型中内核如何定位到这个物理内存页到底属于哪个 mem_section 呢 ?这是第一个问题~~

笔者在《5. 内核如何管理 NUMA 节点中的物理内存区域》小节中讲到了内存的架构,在 NUMA 架构下,物理内存被划分成了一个一个的内存节点(NUMA 节点),在每个 NUMA 节点内部又将其所管理的物理内存按照功能不同划分成了不同的内存区域 zone,每个内存区域管理一片用于特定具体功能的物理内存 page。

物理内存在内核中管理的层级关系为:None -> Zone -> page

image.png

那么在 NUMA 架构下,给定一个具体的 struct page,内核又该如何确定该物理内存页究竟属于哪个 NUMA 节点,属于哪块内存区域 zone 呢? 这是第二个问题。

关于以上笔者提出的两个问题所需要的定位信息全部存储在 struct page 结构中的 flags 字段中。前边我们提到,struct page 是 Linux 世界里最繁华的地段,这里的地价非常昂贵,所以 page 结构中这些字段里的每一个比特内核都会物尽其用。

struct page {
    unsigned long flags;
} 

因此这个 unsigned long 类型的 flags 字段中不仅包含上面提到的定位信息还会包括物理内存页的一些属性和标志位。flags 字段的高 8 位用来表示 struct page 的定位信息,剩余低位表示特定的标志位。

image.png

struct page 与其所属上层结构转换的相应函数定义在 /include/linux/mm.h 文件中:

static inline unsigned long page_to_section(const struct page *page)
{
    return (page->flags >> SECTIONS_PGSHIFT) & SECTIONS_MASK;
}

static inline pg_data_t *page_pgdat(const struct page *page)
{
    return NODE_DATA(page_to_nid(page));
}

static inline struct zone *page_zone(const struct page *page)
{
    return &NODE_DATA(page_to_nid(page))->node_zones[page_zonenum(page)];
}

在我们介绍完了 flags 字段中高位存储的位置定位信息之后,接下来就该来介绍下在低位比特中表示的物理内存页的那些标志位~~

物理内存页的这些标志位定义在内核 /include/linux/page-flags.h文件中:

enum pageflags {
    PG_locked,      /* Page is locked. Don't touch. */
    PG_referenced,
    PG_uptodate,
    PG_dirty,
    PG_lru,
    PG_active,
    PG_slab,
    PG_reserved,
    PG_compound,
    PG_private,     
    PG_writeback,       
    PG_reclaim,     
#ifdef CONFIG_MMU
    PG_mlocked,     /* Page is vma mlocked */
    PG_swapcache = PG_owner_priv_1, 

        ................
};
  • PG_locked 表示该物理页面已经被锁定,如果该标志位置位,说明有使用者正在操作该 page , 则内核的其他部分不允许访问该页, 这可以防止内存管理出现竞态条件,例如:在从硬盘读取数据到 page 时。

  • PG_mlocked 表示该物理内存页被进程通过 mlock 系统调用锁定常驻在内存中,不会被置换出去。

  • PG_referenced 表示该物理页面刚刚被访问过。

  • PG_active 表示该物理页位于 active list 链表中。PG_referenced 和 PG_active 共同控制了系统使用该内存页的活跃程度,在内存回收的时候这两个信息非常重要。

  • PG_uptodate 表示该物理页的数据已经从块设备中读取到内存中,并且期间没有出错。

  • PG_readahead 当进程在顺序访问文件的时候,内核会预读若干相邻的文件页数据到 page 中,物理页 page 结构设置了该标志位,表示它是一个正在被内核预读的页。相关详细内容可回看笔者之前的这篇文章 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》

  • PG_dirty 物理内存页的脏页标识,表示该物理内存页中的数据已经被进程修改,但还没有同步会磁盘中。笔者在 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》 一文中也详细介绍过。

  • PG_lru 表示该物理内存页现在被放置在哪个 lru 链表上,比如:是在 active list 链表中 ? 还是在 inactive list 链表中 ?

  • PG_highmem 表示该物理内存页是在高端内存中。

  • PG_writeback 表示该物理内存页正在被内核的 pdflush 线程回写到磁盘中。详情可回看文章《从 Linux 内核角度探秘 JDK NIO 文件读写本质》 。

  • PG_slab 表示该物理内存页属于 slab 分配器所管理的一部分。

  • PG_swapcache 表示该物理内存页处于 swap cache 中。 struct page 中的 private 指针这时指向 swap_entry_t 。

  • PG_reclaim 表示该物理内存页已经被内核选中即将要进行回收。

  • PG_buddy 表示该物理内存页是空闲的并且被伙伴系统所管理。

  • PG_compound 表示物理内存页属于复合页的其中一部分。

  • PG_private 标志被置位的时候表示该 struct page 结构中的 private 指针指向了具体的对象。不同场景指向的对象不同。

除此之外内核还定义了一些标准宏,用来检查某个物理内存页 page 是否设置了特定的标志位,以及对这些标志位的操作,这些宏在内核中的实现都是原子的,命名格式如下:

  • PageXXX(page):检查 page 是否设置了 PG_XXX 标志位

  • SetPageXXX(page):设置 page 的 PG_XXX 标志位

  • ClearPageXXX(page):清除 page 的 PG_XXX 标志位

  • TestSetPageXXX(page):设置 page 的 PG_XXX 标志位,并返回原值

另外在很多情况下,内核通常需要等待物理页 page 的某个状态改变,才能继续恢复工作,内核提供了如下两个辅助函数,来实现在特定状态的阻塞等待:

static inline void wait_on_page_locked(struct page *page)
static inline void wait_on_page_writeback(struct page *page)

当物理页面在锁定的状态下,进程调用了 wait_on_page_locked 函数,那么进程就会阻塞等待知道页面解锁。

当物理页面正在被内核回写到磁盘的过程中,进程调用了 wait_on_page_writeback 函数就会进入阻塞状态直到脏页数据被回写到磁盘之后被唤醒。

6.4 复合页 compound_page 相关属性

我们都知道 Linux 管理内存的最小单位是 page,每个 page 描述 4K 大小的物理内存,但在一些对于内存敏感的使用场景中,用户往往期望使用一些巨型大页。

巨型大页就是通过两个或者多个物理上连续的内存页 page 组装成的一个比普通内存页 page 更大的页,

因为这些巨型页要比普通的 4K 内存页要大很多,所以遇到缺页中断的情况就会相对减少,由于减少了缺页中断所以性能会更高。

另外,由于巨型页比普通页要大,所以巨型页需要的页表项要比普通页要少,页表项里保存了虚拟内存地址与物理内存地址的映射关系,当 CPU 访问内存的时候需要频繁通过 MMU 访问页表项获取物理内存地址,由于要频繁访问,所以页表项一般会缓存在 TLB 中,因为巨型页需要的页表项较少,所以节约了 TLB 的空间同时降低了 TLB 缓存 MISS 的概率,从而加速了内存访问。

还有一个使用巨型页受益场景就是,当一个内存占用很大的进程(比如 Redis)通过 fork 系统调用创建子进程的时候,会拷贝父进程的相关资源,其中就包括父进程的页表,由于巨型页使用的页表项少,所以拷贝的时候性能会提升不少。

以上就是巨型页存在的原因以及使用的场景,但是在 Linux 内存管理架构中都是统一通过 struct page 来管理内存,而巨型大页却是通过两个或者多个物理上连续的内存页 page 组装成的一个比普通内存页 page 更大的页,那么巨型页的管理与普通页的管理如何统一呢?

这就引出了本小节的主题-----复合页 compound_page,下面我们就来看下 Linux 如果通过统一的 struct page 结构来描述这些巨型页(compound_page):

虽然巨型页(compound_page)是由多个物理上连续的普通 page 组成的,但是在内核的视角里它还是被当做一个特殊内存页来看待。

下图所示,是由 4 个连续的普通内存页 page 组成的一个 compound_page:

image.png

组成复合页的第一个 page 我们称之为首页(Head Page),其余的均称之为尾页(Tail Page)。

我们来看一下 struct page 中关于描述 compound_page 的相关字段:

      struct page {      
            // 首页 page 中的 flags 会被设置为 PG_head 表示复合页的第一页
            unsigned long flags;    
            // 其余尾页会通过该字段指向首页
            unsigned long compound_head;   
            // 用于释放复合页的析构函数,保存在首页中
            unsigned char compound_dtor;
            // 该复合页有多少个 page 组成,order 还是分配阶的概念,首页中保存
            // 本例中的 order = 2 表示由 4 个普通页组成
            unsigned char compound_order;
            // 该复合页被多少个进程使用,内存页反向映射的概念,首页中保存
            atomic_t compound_mapcount;
            // 复合页使用计数,首页中保存
            atomic_t compound_pincount;
      }

首页对应的 struct page 结构里的 flags 会被设置为 PG_head,表示这是复合页的第一页。

另外首页中还保存关于复合页的一些额外信息,比如用于释放复合页的析构函数会保存在首页 struct page 结构里的 compound_dtor 字段中,复合页的分配阶 order 会保存在首页中的 compound_order 中,以及用于指示复合页的引用计数 compound_pincount,以及复合页的反向映射个数(该复合页被多少个进程的页表所映射)compound_mapcount 均在首页中保存。

复合页中的所有尾页都会通过其对应的 struct page 结构中的 compound_head 指向首页,这样通过首页和尾页就组装成了一个完整的复合页 compound_page 。

image.png

6.5 Slab 对象池相关属性

本小节只是对 slab 的一个简单介绍,大家有个大概的印象就可以了,后面笔者会有一篇专门的文章为大家详细介绍 slab 的相关实现细节,到时候还会在重新详细介绍 struct page 中的相关属性。

内核中对内存页的分配使用有两种方式,一种是一页一页的分配使用,这种以页为单位的分配方式内核会向相应内存区域 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 。

总结

到这里,关于 Linux 物理内存管理的相关内容笔者就为大家介绍完了,本文的内容比较多,尤其是物理内存页反向映射相关的内容比较复杂,涉及到的关联关系比较多,现在笔者在带大家总结一下本文的主要内容,方便大家复习回顾:

在本文的开始,笔者首先从 CPU 角度为大家介绍了三种物理内存模型:FLATMEM 平坦内存模型,DISCONTIGMEM 非连续内存模型,SPARSEMEM 稀疏内存模型。

随后笔者又接着介绍了两种物理内存架构:一致性内存访问 UMA 架构,非一致性内存访问 NUMA 架构。

在这个基础之上,又按照内核对物理内存的组织管理层次,分别介绍了 Node 节点,物理内存区域 zone 等相关内核结构。它们的层次如下图所示:

image.png

在把握了物理内存的总体架构之后,又引出了众多细节性的内容,比如:物理内存区域的管理与划分,物理内存区域中的预留内存,物理内存区域中的水位线及其计算方式,物理内存区域中的冷热页。

最后,笔者详细介绍了内核如何通过 struct page 结构来描述物理内存页,其中匿名页反向映射的内容比较复杂,需要大家多多梳理回顾一下。

好了,本文的内容到这里就全部结束了,感谢大家的耐心观看,我们下篇文章见~~~

你可能感兴趣的:(一步一图带你深入理解 Linux 物理内存管理(下))