一文聊透 Linux 缺页异常的处理—— 图解 Page Faults(下)

10. do_swap_page 处理 swap 缺页异常

如果在遍历进程页表的时候发现,虚拟内存地址 address 对应的页表项 pte 不为空,但是 pte 中第 0 个比特位置为 0 ,则表示该 pte 之前是被物理内存映射过的,只不过后来被内核 swap out 出去了。

我们需要的物理内存页不在内存中反而在磁盘中,现在我们就需要将物理内存页从磁盘中 swap in 进来。但在 swap in 之前内核需要知道该物理内存页的内容被保存在磁盘的什么位置上。

笔者在之前文章《一步一图带你构建 Linux 页表体系》 中的第 4.2.1 小节中详细介绍了 64 位页表项 pte 的比特位布局,以及各个比特位的含义。

c 复制代码
typedef unsigned long   pteval_t;
typedef struct { pteval_t pte; } pte_t;

64 位的 pte 主要用来表示物理内存页的地址以及相关的权限标识位,但是当物理内存页不在内存中的时候,这些比特位就没有了任何意义。我们何不将这些已经没有任何意义的比特位利用起来,在物理内存页被 swap out 到磁盘上的时候,将物理内存页在磁盘上的位置保存在这些比特位中。本质上还利用的是之前 pte 中的那 64 个比特,为了区别 swap 的场景,内核使用了一个新的结构体 swp_entry_t 来包装。

c 复制代码
typedef struct {
	unsigned long val;
} swp_entry_t;

swap in 的首要任务就是先要从进程页表中将这个 swp_entry_t 读取出来,然后从 swp_entry_t 中解析出内存页在 swap 交换区中的位置,根据磁盘位置信息将内存页的内容读取到内存中。由于产生了新的物理内存页,所以就要创建新的 pte 来映射这个物理内存页,然后将新的 pte 设置到页表中,替换原来的 swp_entry_t。

这里笔者需要为大家解释的第一个问题就是 ------ 这个 swp_entry_t 究竟是长什么样子 的,它是如何保存 swap 交换区相关位置信息的 ?

10.1 交换区的布局及其组织结构

要明白这个,我们就需要先了解一下 swap 交换区(swap area)的布局,swap 交换区共有两种类型,一种是 swap 分区(swap partition),另一种是 swap 文件(swap file)。

swap partition 可以认为是一个没有文件系统的裸磁盘分区,分区中的磁盘块在磁盘中是连续分布的。

swap file 可以认为是在某个现有的文件系统上,创建的一个定长的普通文件,专门用于保存匿名页被 swap 出来的内容。背后的磁盘块是不连续的。

Linux 系统中可以允许多个这样的 swap 交换区存在,我们可以同时使用多个交换区,也可以为这些交换区指定优先级,优先级高的会被内核优先使用。这些交换区都可以被灵活地添加,删除,而不需要重启系统。多个交换区可以分散在不同的磁盘设备上,这样可以实现硬件的并行访问。

在使用交换区之前,我们可以通过 mkswap 首先创建一个交换区出来,如果我们创建的是 swap partition,则在 mkswap 命令后面直接指定分区的设备文件名称即可。

c 复制代码
mkswap /dev/sdb7

如果我们创建的是 swap file,则需要额外先使用 dd 命令在现有文件系统中创建出一个定长的文件出来。比如下面通过 dd 命令从 /dev/zero 中拷贝创建一个 /swapfile 文件,大小为 4G。

c 复制代码
dd if=/dev/zero of=/swapfile bs=1M count=4096

然后使用 mkswap 命令创建 swap file :

c 复制代码
mkswap /swapfile

当 swap partition 或者 swap file 创建好之后,我们通过 swapon 命令来初始化并激活这个交换区。

c 复制代码
swapon /swapfile

当前系统中各个交换区的情况,我们可以通过 cat /proc/swaps 或者 swapon -s 命令产看:

交换区在内核中使用 struct swap_info_struct 结构体来表示,系统中众多的交换区被组织在一个叫做 swap_info 的数组中,数组中的最大长度为 MAX_SWAPFILES,MAX_SWAPFILES 在内核中是一个常量,一般指定为 32,也就是说,系统中最大允许 32 个交换区存在。

c 复制代码
struct swap_info_struct *swap_info[MAX_SWAPFILES];

由于交换区是有优先级的,所以内核又会按照优先级高低,将交换区组织在一个叫做 swap_avail_heads 的双向链表中。

c 复制代码
static struct plist_head *swap_avail_heads;

swap_info_struct 结构用于描述单个交换区中的各种信息:

c 复制代码
/*
 * The in-memory structure used to track swap areas.
 */
struct swap_info_struct {
    // 用于表示该交换区的状态,比如 SWP_USED 表示正在使用状态,SWP_WRITEOK 表示交换区是可写的状态
    unsigned long   flags;      /* SWP_USED etc: see above */
    // 交换区的优先级
    signed short    prio;       /* swap priority of this type */
    // 指向该交换区在 swap_avail_heads 链表中的位置
    struct plist_node list;     /* entry in swap_active_head */
    // 该交换区在 swap_info 数组中的索引
    signed char type;       /* strange name for an index */
    // 该交换区可以容纳 swap 的匿名页总数
    unsigned int pages;     /* total of usable pages of swap */
    // 已经 swap 到该交换区的匿名页总数
    unsigned int inuse_pages;   /* number of those currently in use */
    // 如果该交换区是 swap partition 则指向该磁盘分区的块设备结构 block_device
    // 如果该交换区是 swap file 则指向文件底层依赖的块设备结构 block_device
    struct block_device *bdev;  /* swap device or bdev of swap file */
    // 指向 swap file 的 file 结构
    struct file *swap_file;     /* seldom referenced */
};

而在每个交换区 swap area 内部又会分为很多连续的 slot (槽),每个 slot 的大小刚好和一个物理内存页的大小相同都是 4K,物理内存页在被 swap out 到交换区时,就会存放在 slot 中。

交换区中的这些 slot 会被组织在一个叫做 swap_map 的数组中,数组中的索引就是 slot 在交换区中的 offset (这个位置信息很重要),数组中的值表示该 slot 总共被多少个进程同时引用。

什么意思呢 ? 比如现在系统中一共有三个进程同时共享一个物理内存页(内存中的概念),当这个物理内存页被 swap out 到交换区上时,就变成了 slot (内存页在交换区中的概念),现在物理内存页没了,这三个共享进程就只能在各自的页表中指向这个 slot,因此该 slot 的引用计数就是 3,对应在数组 swap_map 中的值也是 3 。

交换区中的第一个 slot 用于存储交换区的元信息,比如交换区对应底层各个磁盘块的坏块列表。因此笔者将其标注了红色,表示不能使用。

swap_map 数组中的值表示的就是对应 slot 被多少个进程同时引用,值为 0 表示该 slot 是空闲的,下次 swap out 的时候首先查找的就是空闲 slot 。 查找范围就是 lowest_bit 到 highest_bit 之间的 slot。当查找到空闲 slot 之后,就会将整个物理内存页回写到这个 slot 中。

c 复制代码
struct swap_info_struct {
	unsigned char *swap_map;	/* vmalloc'ed array of usage counts */
	unsigned int lowest_bit;	/* index of first free in swap_map */
	unsigned int highest_bit;	/* index of last free in swap_map */

但是这里会有一个问题就是交换区面向的是整个系统,而系统中会有很多进程,如果多个进程并发进行 swap 的时候,swap_map 数组就会面临并发操作的问题,这样一来就不得不需要一个全局锁来保护,但是这也导致了多个 CPU 只能串行访问,大大降低了并发度。

那怎么办呢 ? 想想 JDK 中的 ConcurrentHashMap,将锁分段呗,这样可以将锁竞争分散开来,大大提升并发度。

内核会将 swap_map 数组中的这些 slot,按照常量 SWAPFILE_CLUSTER 指定的个数,256 个 slot 分为一个 cluster。

c 复制代码
#define SWAPFILE_CLUSTER	256

每个 cluster 中包含一把 spinlock_t 锁,如果 cluster 是空闲的,那么 swap_cluster_info 结构中的 data 指向下一个空闲的 cluster,如果 cluster 不是空闲的,那么 data 保存的是该 cluster 中已经分配的 slot 个数。

c 复制代码
struct swap_cluster_info {
    spinlock_t lock;    /*
                 * Protect swap_cluster_info fields
                 * and swap_info_struct->swap_map
                 * elements correspond to the swap
                 * cluster
                 */
    unsigned int data:24;
    unsigned int flags:8;
};
#define CLUSTER_FLAG_FREE 1 /* This cluster is free */
#define CLUSTER_FLAG_NEXT_NULL 2 /* This cluster has no next cluster */
#define CLUSTER_FLAG_HUGE 4 /* This cluster is backing a transparent huge page */

这样一来 swap_map 数组中的这些独立的 slot,就被按照以 cluster 为单位重新组织了起来,这些 cluster 被串联在 cluster_info 链表中。

为了进一步利用 cpu cache,以及实现无锁化查找 slot,内核会给每个 cpu 分配一个 cluster ------ percpu_cluster,cpu 直接从自己的 cluster 中查找空闲 slot,近一步提高了 swap out 的吞吐。

当 cpu 自己的 percpu_cluster 用尽之后,内核则会调用 swap_alloc_cluster 函数从 free_clusters 中获取一个新的 cluster。

c 复制代码
struct swap_info_struct {
    struct swap_cluster_info *cluster_info; /* cluster info. Only for SSD */
    struct swap_cluster_list free_clusters; /* free clusters list */

    struct percpu_cluster __percpu *percpu_cluster; /* per cpu's swap location */
}

现在交换区的整体布局笔者就为大家介绍完了,可能大家这里有一点还是会比较困惑 ------ 你说来说去,这个 slot 到底是个啥 ?

哈哈,大家先别急,我们现在已经对进程的虚拟内存空间非常熟悉了,这里我们把交换区 swap_info_struct 与进程的内存空间 mm_struct 放到一起一对比就很清楚了。

首先进程虚拟内存空间中的虚拟内存别管说的如何天花乱坠,说到底还是要保存在真实的物理内存中的,虚拟内存与物理内存通过页表来关联起来。

同样的道理,别管交换区布局的如何天花乱坠,swap out 出来的数据说到底还是要保存在真实的磁盘中的,而交换区中是按照 slot 为单位进行组织管理的,磁盘中是按照磁盘块来组织管理的,大小都是 4K 。

交换区中的 slot 就好比于虚拟内存空间中的虚拟内存,都是虚拟的概念,物理内存页与磁盘块才是真实本质的东西。

虚拟内存是连续的,但其背后映射的物理内存可能是不连续,交换区中的 slot 也都是连续的,但磁盘中磁盘块的扇区地址却不一定是连续的。页表可以将不连续的物理内存映射到连续的虚拟内存上,内核也需要一种机制,将不连续的磁盘块映射到连续的 slot 中。

当我们使用 swapon 命令来初始化激活交换区时,内核会扫描交换区中各个磁盘块的扇区地址,以确定磁盘块与扇区的对应关系,然后搜集扇区地址连续的磁盘块,将这些连续的磁盘块组成一个块组,slot 就会一个一个的映射到这些块组上,块组之间的扇区地址是不连续的,但是 slot 是连续的。

slot 与连续的磁盘块组的映射关系保存在 swap_extent 结构中:

c 复制代码
/*
 * A swap extent maps a range of a swapfile's PAGE_SIZE pages onto a range of
 * disk blocks.  A list of swap extents maps the entire swapfile.  (Where the
 * term `swapfile' refers to either a blockdevice or an IS_REG file.  Apart
 * from setup, they're handled identically.
 *
 * We always assume that blocks are of size PAGE_SIZE.
 */
struct swap_extent {
    // 红黑树节点
    struct rb_node rb_node;
    // 块组内,第一个映射的 slot 编号
    pgoff_t start_page;
    // 映射的 slot 个数
    pgoff_t nr_pages;
    // 块组内第一个磁盘块
    sector_t start_block;
};

由于一个块组内的磁盘块都是连续的,slot 本来又是连续的,所以 swap_extent 结构中只需要保存映射到该块组内第一个 slot 的编号 (start_page),块组内第一个磁盘块在磁盘上的块号,以及磁盘块个数就可以了。

虚拟内存页类比 slot,物理内存页类比磁盘块,这里的 swap_extent 可以看做是虚拟内存区域 vma,进程的虚拟内存空间正是由一段一段的 vma 组成,这些 vma 被组织在一颗红黑树上。

交换区也是一样,它是由一段一段的 swap_extent 组成,同样也会被组织在一颗红黑树上。我们可以通过 slot 在交换区中的 offset,在这颗红黑树中快速查找出 slot 背后对应的磁盘块。

c 复制代码
struct swap_info_struct {
	struct rb_root swap_extent_root;/* root of the swap extent rbtree */

现在交换区内部的样子,我们已经非常清楚了,有了这些背景知识之后,我们在回过头来看本小节最开始提出的问题 ------ swp_entry_t 到底长什么样子。

10.2 一睹 swp_entry_t 真容

匿名内存页在被内核 swap out 到磁盘上之后,内存页中的内容保存在交换区的 slot 中,在 swap in 的场景中,内核需要根据 swp_entry_t 里的信息找到这个 slot,进而找到其对应的磁盘块,然后从磁盘块中读取出被 swap out 出去的内容。

这个就和交换区的布局有很大的关系,首先系统中存在多个交换区,这些交换区被内核组织在 swap_info 数组中。

c 复制代码
struct swap_info_struct *swap_info[MAX_SWAPFILES];

我们首先需要知道匿名内存页到底被 swap out 到哪个交换区里了,所以 swp_entry_t 里必须包含交换区在 swap_info 数组中的索引,而这个索引正是 swap_info_struct 结构中的 type 字段。

c 复制代码
struct swap_info_struct {
    // 该交换区在 swap_info 数组中的索引
    signed char type;  
}

在确定了交换区的位置后,我们需要知道匿名页被 swap out 到交换区中的哪个 slot 中,所以 swp_entry_t 中也必须包含 slot 在交换区中的 offset,这个 offset 就是 swap_info_struct 结构里 slot 所在 swap_map 数组中的下标。

c 复制代码
struct swap_info_struct {
    unsigned char *swap_map; 
}

所以总结下来 swp_entry_t 中需要包含以下三种信息:

第一, swp_entry_t 需要标识该页表项是一个 pte 还是 swp_entry_t,因为它俩本质上是一样的,都是 unsigned long 类型的无符号整数,是可以相互转换的。

c 复制代码
#define __pte_to_swp_entry(pte)	((swp_entry_t) { pte_val(pte) })
#define __swp_entry_to_pte(swp)	((pte_t) { (swp).val })

第 0 个比特位置 1 表示是一个 pte,背后映射的物理内存页存在于内存中。如果第 0 个比特位置 0 则表示该 pte 背后映射的物理内存页已经被 swap out 出去了,那么它就是一个 swp_entry_t,指向内存页在交换区中的位置。

第二,swp_entry_t 需要包含被 swap 出去的匿名页所在交换区的索引 type,第 2 个比特位到第 7 个比特位,总共使用 6 个比特来表示匿名页所在交换区的索引。

第三,swp_entry_t 需要包含匿名页所在 slot 的位置 offset,第 8 个比特位到第 57 个比特位,总共 50 个比特来表示匿名页对应的 slot 在交换区的 offset 。

c 复制代码
/*
 * Encode and decode a swap entry:
 *	bits 0-1:	present (must be zero)
 *	bits 2-7:	swap type
 *	bits 8-57:	swap offset
 *	bit  58:	PTE_PROT_NONE (must be zero)
 */
#define __SWP_TYPE_SHIFT	2
#define __SWP_TYPE_BITS		6
#define __SWP_OFFSET_BITS	50
#define __SWP_OFFSET_SHIFT	(__SWP_TYPE_BITS + __SWP_TYPE_SHIFT)

内核提供了宏 __swp_type 用于从 swp_entry_t 中将匿名页所在交换区编号提取出来,还提供了宏 __swp_offset 用于从 swp_entry_t 中将匿名页所在 slot 的 offset 提取出来。

c 复制代码
#define __swp_type(x)		(((x).val >> __SWP_TYPE_SHIFT) & __SWP_TYPE_MASK)
#define __swp_offset(x)		(((x).val >> __SWP_OFFSET_SHIFT) & __SWP_OFFSET_MASK)

#define __SWP_TYPE_MASK		((1 << __SWP_TYPE_BITS) - 1)
#define __SWP_OFFSET_MASK	((1UL << __SWP_OFFSET_BITS) - 1)

有了这两个宏之后,我们就可以根据 swp_entry_t 轻松地定位到匿名页在交换区中的位置了。

内核首先会通过 swp_type 从 swp_entry_t 提取出匿名页所在的交换区索引 type,根据 type 就可以从 swap_info 数组中定位到交换区数据结构 swap_info_struct 。

内核将定位交换区 swap_info_struct 结构的逻辑封装在 swp_swap_info 函数中:

c 复制代码
struct swap_info_struct *swp_swap_info(swp_entry_t entry)
{
	return swap_type_to_swap_info(swp_type(entry));
}

static struct swap_info_struct *swap_type_to_swap_info(int type)
{
	return READ_ONCE(swap_info[type]);
}

得到了交换区的 swap_info_struct 结构,我们就可以获取交换区所在磁盘分区底层的块设备 ------ swap_info_struct->bdev。

c 复制代码
struct swap_info_struct {
    // 如果该交换区是 swap partition 则指向该磁盘分区的块设备结构 block_device
    // 如果该交换区是 swap file 则指向文件底层依赖的块设备结构 block_device
    struct block_device *bdev;  /* swap device or bdev of swap file */
}

最后通过 swp_offset 定位匿名页所在 slot 在交换区中的 offset, 然后利用 offset 在红黑树 swap_extent_root 中查找其对应的 swap_extent。

c 复制代码
struct swap_info_struct {
    struct rb_root swap_extent_root;/* root of the swap extent rbtree */
}

前面我们提到过 swap file 背后所在的磁盘块不一定是连续的,而 swap file 中的 slot 却是连续的,内核需要用 swap_extent 结构来描述 slot 与磁盘块的映射关系。

所以对于 swap file 来说,我们找到了 swap_extent 也就确定了 slot 对应的磁盘块了。

c 复制代码
static sector_t map_swap_entry(swp_entry_t entry, struct block_device **bdev)
{
    struct swap_info_struct *sis;
    struct swap_extent *se;
    pgoff_t offset;
    // 通过 swap_info[swp_type(entry)]  获取交换区 swap_info_struct 结构
    sis = swp_swap_info(entry);
    // 获取交换区所在磁盘分区块设备
    *bdev = sis->bdev;
    // 获取匿名页在交换区的偏移 
    offset = swp_offset(entry);
    // 通过 offset 到红黑树 swap_extent_root 中查找对应的 swap_extent
    se = offset_to_swap_extent(sis, offset);
    // 获取 slot 对应的磁盘块
    return se->start_block + (offset - se->start_page);
}

而 swap partition 是一个没有文件系统的裸磁盘分区,其背后的磁盘块都是连续分布的,所以对于 swap partition 来说,slot 与磁盘块是直接映射的,我们获取到 slot 的 offset 之后,在乘以一个固定的偏移 2 ^ PAGE_SHIFT - 9 跳过用于存储交换区元信息的 swap header ,就可以直接获得磁盘块了。

这里有点像 《深入理解 Linux 虚拟内存管理》 一文中提到的内核虚拟内存空间中的直接映射区,虚拟内存与物理内存都是直接映射的,通过虚拟内存地址减去一个固定的偏移直接就可以获得物理内存地址了。

c 复制代码
static sector_t swap_page_sector(struct page *page)
{
    return (sector_t)__page_file_index(page) << (PAGE_SHIFT - 9);
}

pgoff_t __page_file_index(struct page *page)
{
    // 在 swap 场景中,swp_entry_t 的值会设置到 page 结构中的 private 字段中
    // 具体什么时候设置的,我们这里先不管,后面会说
    swp_entry_t swap = { .val = page_private(page) };
    return swp_offset(swap);
}

以上介绍的就是内核在 swap file 和 swap partition 场景下,如何获取 slot 对应的磁盘块 sector_t 的逻辑与实现。

有了 sector_t,内核接着就会利用 bdev_read_page 函数将 slot 对应在 sector 中的内容读取到物理内存页 page 中,这就是整个 swap in 的过程。

c 复制代码
/**
 * bdev_read_page() - Start reading a page from a block device
 * @bdev: The device to read the page from
 * @sector: The offset on the device to read the page to (need not be aligned)
 * @page: The page to read
 */
int bdev_read_page(struct block_device *bdev, sector_t sector,
			struct page *page)

swap_readpage 函数负责将匿名页中的内容从交换区中读取到物理内存页中来,这里也是 swap in 的核心实现:

c 复制代码
int swap_readpage(struct page *page, bool synchronous)
{
    struct bio *bio;
    int ret = 0;
    struct swap_info_struct *sis = page_swap_info(page);
    blk_qc_t qc;
    struct gendisk *disk;
    // 处理交换区是 swap file 的情况
    if (sis->flags & SWP_FS) {
        // 从交换区中获取交换文件 swap_file
        struct file *swap_file = sis->swap_file;
        // swap_file 本质上还是文件系统中的一个文件,所以它也会有 page cache
        struct address_space *mapping = swap_file->f_mapping;
        // 利用 page cache 中的 readpage 方法,从 swap_file 所在的文件系统中读取匿名页内容到 page 中。
        // 注意这里只是利用 page cache 的 readpage 方法从文件系统中读取数据,内核并不会把 page 加入到 page cache 中
        // 这里 swap_file 和普通文件的读取过程是不一样的,page cache 不缓存内存页。
        // 对于 swap out 的场景来说,内核也只是利用 page cache 的 writepage 方法将匿名页的内容写入到 swap_file 中。
        ret = mapping->a_ops->readpage(swap_file, page);
        if (!ret)
            count_vm_event(PSWPIN);
        return ret;
    }

    // 如果交换区是 swap partition,则直接从磁盘块中读取
    // 对于 swap out 的场景,内核调用 bdev_write_page,直接将匿名页的内容写入到磁盘块中
    ret = bdev_read_page(sis->bdev, swap_page_sector(page), page);

out:
    return ret;
}

swap_readpage 是内核 swap 机制的最底层实现,直接和磁盘打交道,负责搭建磁盘与内存之间的桥梁。虽然直接调用 swap_readpage 可以基本完成 swap in 的目的,但在某些特殊情况下会导致 swap 的性能非常糟糕。

比如下图所示,假设当前系统中存在三个进程,它们共享引用了同一个物理内存页 page。

当这个被共享的 page 被内核 swap out 到交换区之后,三个共享进程的页表会发生如下变化:

当 进程1 开始读取这个共享 page 的时候,由于 page 已经 swap out 到交换区了,所以会发生 swap 缺页异常,进入内核通过 swap_readpage 将共享 page 的内容从磁盘中读取进内存,此时三个进程的页表结构变为下图所示:

现在共享 page 已经被 进程1 swap in 进来了,但是 进程2 和 进程 3 是不知道的,它们的页表中还储存的是 swp_entry_t,依然指向 page 所在交换区的位置。

按照之前的逻辑,当 进程2 以及 进程3 开始读取这个共享 page 的时候,其实 page 已经在内存了,但是它们此刻感知不到,因为 进程2 和 进程3 的页表中存储的依然是 swp_entry_t,还是会产生 swap 缺页中断,重新通过 swap_readpage 读取交换区中的内容,这样一来就产生了额外重复的磁盘 IO。

除此之外,更加严重的是,由于 进程2 和 进程3 的 swap 缺页,又会产生两个新的内存页用来存放从 swap_readpage 中读取进来的交换区数据。

产生了重复的磁盘 IO 不说,还产生了额外的内存消耗,并且这样一来,三个进程对内存页就不是共享的了。

还有一种极端场景是一个进程试图读取一个正在被 swap out 的 page ,由于 page 正在被内核 swap out,此时进程页表指向该 page 的 pte 已经变成了 swp_entry_t。

进程在这个时候访问 page 的时候,还是会产生 swap 缺页异常,进程试图 swap in 这个正在被内核 swap out 的 page,但是此时 page 仍然还在内存中,只不过是正在被内核刷盘。

而按照之前的 swap in 逻辑,进程这里会调用 swap_readpage 从磁盘中读取,产生额外的磁盘 IO 以及内存消耗不说,关键是此刻 swap_readpage 出来的数据都不是完整的,这肯定是个大问题。

内核为了解决上面提到的这些问题,因此引入了一个新的结构 ------ swap cache 。

10.3 swap cache

有了 swap cache 之后,情况就会变得大不相同,我们在回过头来看第一个问题 ------ 多进程共享内存页。

进程1 在 swap in 的时候首先会到 swap cache 中去查找,看看是否有其他进程已经把内存页 swap in 进来了,如果 swap cache 中没有才会调用 swap_readpage 从磁盘中去读取。

当内核通过 swap_readpage 将内存页中的内容从磁盘中读取进内存之后,内核会把这个匿名页先放入 swap cache 中。进程 1 的页表将原来的 swp_entry_t 填充为 pte 并指向 swap cache 中的这个内存页。

由于进程1 页表中对应的页表项现在已经从 swp_entry_t 变为 pte 了,指向的是 swap cache 中的内存页而不是 swap 交换区,所以对应 slot 的引用计数就要减 1 。

还记得我们之前介绍的 swap_map 数组吗 ?slot 被进程引用的计数就保存在这里,现在这个 slot 在 swap_map 数组中保存的引用计数从 3 变成了 2 。表示还有两个进程也就是 进程2 和 进程3 仍在继续引用这个 slot 。

当进程2 发生 swap 缺页中断的时候进入内核之后,也是首先会到 swap cache 中查找是否现在已经有其他进程把共享的内存页 swap in 进来了,内存页 page 在 swap cache 的索引就是页表中的 swp_entry_t。由于这三个进程共享的同一个内存页,所以三个进程页表中的 swp_entry_t 都是相同的,都是指向交换区的同一位置。

由于共享内存页现在已经被 进程1 swap in 进来了,并存放在 swap cache 中,所以 进程2 通过 swp_entry_t 一下就在 swap cache 中找到了,同理,进程 2 的页表也会将原来的 swp_entry_t 填充为 pte 并指向 swap cache 中的这个内存页。slot 的引用计数减 1。

现在这个 slot 在 swap_map 数组中保存的引用计数从 2 变成了 1 。表示只有 进程3 在引用这个 slot 了。

当 进程3 发生 swap 缺页中断的之后,内核还是先通过 swp_entry_t 到 swap cache 中去查找,找到之后,将 进程 3 页表原来的 swp_entry_t 填充为 pte 并指向 swap cache 中的这个内存页,slot 的引用计数减 1。

现在 slot 的引用计数已经变为 0 了,这意味着所有共享该内存页的进程已经全部知道了新内存页的地址,它们的 pte 已经全部指向了新内存页,不在指向 slot 了,此时内核便将这个内存页从 swap cache 中移除。

针对第二个问题 ------ 进程试图 swap in 这个正在被内核 swap out 的 page,内核的处理方法也是一样,内核在 swap out 的时候首先会在交换区中为这个 page 分配 slot 确定其在交换区的位置,然后通过之前文章 《深入理解 Linux 物理内存管理》 中 介绍的匿名页反向映射机制找到所有引用该内存页的进程,将它们页表中的 pte 修改为指向 slot 的 swp_entry_t。

然后将匿名页 page 先是放入到 swap cache 中,慢慢地通过 swap_writepage 回写。当匿名页被完全回写到交换区中时,内核才会将 page 从 swap cache 中移除。

如果当内核正在回写的过程中,不巧有一个进程又要访问该内存页,同样也会发生 swap 缺页中断,但是由于此时没有回写完成,内存页还保存在 swap cache 中,内核通过进程页表中的 swp_entry_t 一下就在 swap cache 中找到了,避免了再次发生磁盘 IO,后面的过程就和第一个问题一样了。

上述查找 swap cache 的过程。内核封装在 __read_swap_cache_async 函数里,在 swap in 的过程中,内核会首先调用这里查看 swap cache 是否已经缓存了内存页,如果没有,则新分配一个内存页并加入到 swap cache 中,最后才会调用 swap_readpage 从磁盘中将所需内容读取到新内存页中。

c 复制代码
struct page *__read_swap_cache_async(swp_entry_t entry, gfp_t gfp_mask,
            struct vm_area_struct *vma, unsigned long addr,
            bool *new_page_allocated)
{
    struct page *found_page = NULL, *new_page = NULL;
    struct swap_info_struct *si;
    int err;
    // 是否分配新的内存页,如果内存页已经在 swap cache 中则无需分配
    *new_page_allocated = false;

    do {
        // 获取交换区结构 swap_info_struct
        si = get_swap_device(entry);
        // 首先根据 swp_entry_t 到 swap cache 中查找,内存页是否已经被其他进程 swap in 进来了
        found_page = find_get_page(swap_address_space(entry),
                       swp_offset(entry));
        // swap cache 已经缓存了,就直接返回,不必启动磁盘 IO
        if (found_page)
            break;
        // 如果 swap cache 中没有,则需要新分配一个内存页
        // 用来存储从交换区中 swap in 进来的内容
        if (!new_page) {
            new_page = alloc_page_vma(gfp_mask, vma, addr);
            if (!new_page)
                break;      /* Out of memory */
        }
        // swap 没有完成时,内存页需要加锁,禁止访问
        __SetPageLocked(new_page);
        __SetPageSwapBacked(new_page);
        // 将新的内存页先放入 swap cache 中
        // 在这里会将 swp_entry_t 设置到 page 结构的 private 属性中
        err = add_to_swap_cache(new_page, entry, gfp_mask & GFP_KERNEL);
    } while (err != -ENOMEM);

    return found_page;
}

前面我们提到,Linux 系统中同时允许多个交换区存在,内核将这些交换区组织在 swap_info 数组中。

c 复制代码
struct swap_info_struct *swap_info[MAX_SWAPFILES];

内核会为系统中每一个交换区分配一个 swap cache,被内核组织在一个叫做 swapper_spaces 的数组中。交换区的 swap cache 在 swapper_spaces 数组中的索引也是 swp_entry_t 中存储的 type 信息,通过 swp_type 来提取。

c 复制代码
// 一个交换区对应一个 swap cache
struct address_space *swapper_spaces[MAX_SWAPFILES] __read_mostly;

这里我们可以看到,交换区的 swap cache 和文件的 page cache 一样,都是 address_space 结构来描述的,而对于 swap file 来说,因为它本质上是文件系统里的一个文件,所以 swap file 既有 swap cache 也有 page cache 。

这里大家需要区分 swap file 的 swap cache 和 page cache,前面在介绍 swap_readpage 函数的时候,笔者也提过,swap file 的 page cache 在 swap 的场景中是不会缓存内存页的,内核只是利用 page cache 相关的操作函数 ------ address_space->a_ops ,从 swap file 所在的文件系统中读取或者写入匿名页,匿名页是不会加入到 page cache 中的。

而交换区是针对整个系统来说的,系统中会存在很多进程,当发生 swap 的时候,系统中的这些进程会对同一个 swap cache 进行争抢,所以为了近一步提高 swap 的并行度,内核会将一个交换区中的 swap cache 分裂多个出来,将竞争的压力分散开来。

这样一来,一个交换就演变出多个 swap cache 出来,swapper_spaces 数组其实是一个 address_space 结构的二维数组。每个 swap cache 能够管理的匿名页个数为 2^SWAP_ADDRESS_SPACE_SHIFT 个,涉及到的内存大小为 4K * SWAP_ADDRESS_SPACE_PAGES ------ 64M。

c 复制代码
/* One swap address space for each 64M swap space */
#define SWAP_ADDRESS_SPACE_SHIFT	14
#define SWAP_ADDRESS_SPACE_PAGES	(1 << SWAP_ADDRESS_SPACE_SHIFT)

通过一个给定的 swp_entry_t 查找对应的 swap cache 的逻辑,内核定义在 swap_address_space 宏中。

  1. 首先内核通过 swp_type 提取交换区在 swapper_spaces 数组中的索引(一维索引)。

  2. 通过 swp_offset >> SWAP_ADDRESS_SPACE_SHIFT(二维索引),定位 slot 具体归哪一个 swap cache 管理。

c 复制代码
#define swap_address_space(entry)			    \
	(&swapper_spaces[swp_type(entry)][swp_offset(entry) \
		>> SWAP_ADDRESS_SPACE_SHIFT])

struct page * lookup_swap_cache(swp_entry_t entry)  
{          
    struct swap_info_struct *si = get_swap_device(entry);
    // 通过 swp_entry_t 定位 swap cache
    // 根据 swp_offset 在 swap cache 中查找内存页
    page = find_get_page(swap_address_space(entry), swp_offset(entry));        
    return page;  
}

当我们通过 swapon 命令来初始化并激活一个交换区的时候,内核会在 init_swap_address_space 函数中为交换区初始化 swap cache。

c 复制代码
int init_swap_address_space(unsigned int type, unsigned long nr_pages)
{
    struct address_space *spaces, *space;
    unsigned int i, nr;
    // 计算交换区包含的 swap cache 个数
    nr = DIV_ROUND_UP(nr_pages, SWAP_ADDRESS_SPACE_PAGES);
    // 为交换区分配 address_space 数组,用于存放多个 swap cache
    spaces = kvcalloc(nr, sizeof(struct address_space), GFP_KERNEL);
    // 挨个初始化交换区中的 swap cache
    for (i = 0; i < nr; i++) {
        space = spaces + i;
        // 将 a_ops 指定为 swap_aops
        space->a_ops = &swap_aops;
        /* swap cache doesn't use writeback related tags */
        // swap cache 不会回写
        mapping_set_no_writeback_tags(space);
    }
    // 保存交换区中的 swap cache 个数
    nr_swapper_spaces[type] = nr;
    // 将初始化好的 address_space 数组放入 swapper_spaces 数组中(二维数组)
    swapper_spaces[type] = spaces;

    return 0;
}

// 交换区中的 swap cache 个数
static unsigned int nr_swapper_spaces[MAX_SWAPFILES] __read_mostly;

struct address_space *swapper_spaces[MAX_SWAPFILES] __read_mostly;

这里我们可以看到,对于 swap cache 来说,内核会将 address_space-> a_ops 初始化为 swap_aops。

c 复制代码
static const struct address_space_operations swap_aops = {
	.writepage	= swap_writepage,
	.set_page_dirty	= swap_set_page_dirty,
#ifdef CONFIG_MIGRATION
	.migratepage	= migrate_page,
#endif
};

10.4 swap 预读

现在我们已经清楚了当进程虚拟内存空间中的某一段 vma 发生 swap 缺页异常之后,内核的 swap in 核心处理流程。但是整个完整的 swap 流程还没有结束,内核还需要考虑内存访问的空间局部性原理。

当进程访问某一段内存的时候,在不久之后,其附近的内存地址也将被访问。对应于本小节的 swap 场景来说,当进程地址空间中的某一个虚拟内存地址 address 被访问之后,那么其周围的虚拟内存地址在不久之后,也会被进程访问。

而那些相邻的虚拟内存地址,在进程页表中对应的页表项也都是相邻的,当我们处理完了缺页地址 address 的 swap 缺页异常之后,如果其相邻的页表项均是 swp_entry_t,那么这些相邻的 swp_entry_t 所指向交换区的内容也需要被内核预读进内存中。

这样一来,当 address 附近的虚拟内存地址发生 swap 缺页的时候,内核就可以直接从 swap cache 中读到了,避免了磁盘 IO,使得 swap in 可以快速完成,这里和文件的预读机制有点类似。

swap 预读在 Linux 内核中由 swapin_readahead 函数负责,它有两种实现方式:

第一种是根据缺页地址 address 周围的虚拟内存地址进行预读,但前提是它们必须属于同一个 vma,这个逻辑在 swap_vma_readahead 函数中完成。

第二种是根据内存页在交换区中周围的磁盘地址进行预读,但前提是它们必须属于同一个交换区,这个逻辑在 swap_cluster_readahead 函数中完成。

c 复制代码
struct page *swapin_readahead(swp_entry_t entry, gfp_t gfp_mask,
                struct vm_fault *vmf)
{
    return swap_use_vma_readahead() ?
            swap_vma_readahead(entry, gfp_mask, vmf) :
            swap_cluster_readahead(entry, gfp_mask, vmf);
}

在本小节介绍的 swap 缺页场景中,内核是按照缺页地址周围的虚拟内存地址进行预读的。在函数 swap_vma_readahead 的开始,内核首先调用 swap_ra_info 方法来计算本次需要预读的页表项集合。

预读的最大页表项个数由 page_cluster 决定,但最大不能超过 2 ^ SWAP_RA_ORDER_CEILING

c 复制代码
#ifdef CONFIG_64BIT
#define SWAP_RA_ORDER_CEILING	5
// 最大预读窗口
max_win = 1 << min_t(unsigned int, READ_ONCE(page_cluster),
			     SWAP_RA_ORDER_CEILING);

page_cluster 的值可以通过内核参数 /proc/sys/vm/page-cluster 来调整,默认值为 3,我们可以通过设置 page_cluster = 0来禁止 swap 预读。

当要 swap in 的内存页在交换区的位置已经接近末尾了,则需要减少预读页的个数,防止预读超出交换区的边界。

如果预读的页表项不是 swp_entry_t,则说明该页表项是一个空的还没有进行过映射或者页表项指向的内存页还在内存中,这种情况下则跳过,继续预读后面的 swp_entry_t。

c 复制代码
/**
 * swap_vma_readahead - swap in pages in hope we need them soon
 * @entry: swap entry of this memory
 * @gfp_mask: memory allocation flags
 * @vmf: fault information
 *
 * Returns the struct page for entry and addr, after queueing swapin.
 *
 * Primitive swap readahead code. We simply read in a few pages whoes
 * virtual addresses are around the fault address in the same vma.
 *
 * Caller must hold read mmap_sem if vmf->vma is not NULL.
 *
 */
static struct page *swap_vma_readahead(swp_entry_t fentry, gfp_t gfp_mask,
                       struct vm_fault *vmf)
{
    struct vm_area_struct *vma = vmf->vma;
    struct vma_swap_readahead ra_info = {0,};
    // 获取本次要进行预读的页表项
    swap_ra_info(vmf, &ra_info);
    // 遍历预读窗口 ra_info 中的页表项,挨个进行预读
    for (i = 0, pte = ra_info.ptes; i < ra_info.nr_pte;
         i++, pte++) {
        // 获取要进行预读的页表项
        pentry = *pte;
        // 页表项为空,表示还未进行内存映射,直接跳过
        if (pte_none(pentry))
            continue;
        // 页表项指向的内存页仍然在内存中,跳过
        if (pte_present(pentry))
            continue;
        // 将 pte 转换为 swp_entry_t
        entry = pte_to_swp_entry(pentry);
        if (unlikely(non_swap_entry(entry)))
            continue;
        // 利用 swp_entry_t 先到 swap cache 中去查找
        // 如果没有,则新分配一个内存页并添加到 swap cache 中,这种情况下 page_allocated = true
        // 如果有,则直接从swap cache 中获取内存页,也就不需要预读了,page_allocated = false
        page = __read_swap_cache_async(entry, gfp_mask, vma,
                           vmf->address, &page_allocated);

        if (page_allocated) {
            // 发生磁盘 IO,从交换区中读取内存页的内容到新分配的 page 中
            swap_readpage(page, false);
        }
    }
}

这样一来,经过 swap_vma_readahead 预读之后,缺页内存地址 address 周围的页表项所指向的内存页就全部被加载到 swap cache 中了。

当进程下次访问 address 周围的内存地址时,虽然也会发生 swap 缺页异常,但是内核直接从 swap cache 中就可以读取到了,避免了磁盘 IO。

10.5 还原 do_swap_page 完整面貌

当我们明白了前面介绍的这些背景知识之后,再回过头来看内核完整的 swap in 过程就很清晰了

  1. 首先内核会通过 pte_to_swp_entry 将进程页表中的 pte 转换为 swp_entry_t

  2. 通过 lookup_swap_cache 根据 swp_entry_t 到 swap cache 中查找是否已经有其他进程将内存页 swap 进来了。

  3. 如果 swap cache 没有对应的内存页,则调用 swapin_readahead 启动预读,在这个过程中,内核会重新分配物理内存页,并将这个物理内存页加入到 swap cache 中,随后通过 swap_readpage 将交换区的内容读取到这个内存页中。

  4. 现在我们需要的内存页已经 swap in 到内存中了,后面的流程就和普通的缺页处理一样了,根据 swap in 进来的内存页地址重新创建初始化一个新的 pte,然后用这个新的 pte,将进程页表中原来的 swp_entry_t 替换掉。

  5. 为新的内存页建立反向映射关系,加入 lru active list 中,最后 swap_free 释放交换区中的资源。

c 复制代码
vm_fault_t do_swap_page(struct vm_fault *vmf)
{
    // 将缺页内存地址 address 对应的 pte 转换为 swp_entry_t
    entry = pte_to_swp_entry(vmf->orig_pte);  
    // 首先利用 swp_entry_t 到 swap cache 查找,看内存页已经其他进程被 swap in 进来
    page = lookup_swap_cache(entry, vma, vmf->address);
    swapcache = page;
    // 处理匿名页不在 swap cache 的情况
    if (!page) {
        // 通过 swp_entry_t 获取对应的交换区结构
        struct swap_info_struct *si = swp_swap_info(entry);
        // 针对 fast swap storage 比如 zram 等 swap 的性能优化,跳过 swap cache
        if (si->flags & SWP_SYNCHRONOUS_IO &&
                __swap_count(entry) == 1) {
            /* skip swapcache */
            // 当只有单进程引用这个匿名页的时候,直接跳过 swap cache
            // 从伙伴系统中申请内存页 page,注意这里的 page 并不会加入到 swap cache 中
            page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma,
                            vmf->address);
            if (page) {
                __SetPageLocked(page);
                __SetPageSwapBacked(page);
                set_page_private(page, entry.val);
                // 加入 lru 链表
                lru_cache_add_anon(page);
                // 直接从 fast storage device 中读取被换出的内容到 page 中
                swap_readpage(page, true);
            }
        } else {
            // 启动 swap 预读
            page = swapin_readahead(entry, GFP_HIGHUSER_MOVABLE,
                        vmf);
            swapcache = page;
        }

        // 因为涉及到了磁盘 IO,所以本次缺页异常属于 FAULT_MAJOR 类型
        ret = VM_FAULT_MAJOR;
        count_vm_event(PGMAJFAULT);
        count_memcg_event_mm(vma->vm_mm, PGMAJFAULT);
    } 

    // 现在之前被换出的内存页已经被内核重新 swap in 到内存中了。
    // 下面就是重新设置 pte,将原来页表中的 swp_entry_t 替换掉
    vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd, vmf->address,
            &vmf->ptl);
    // 增加匿名页的统计计数
    inc_mm_counter_fast(vma->vm_mm, MM_ANONPAGES);
    // 减少 swap entries 计数
    dec_mm_counter_fast(vma->vm_mm, MM_SWAPENTS);
    // 根据被 swap in 进来的新内存页重新创建 pte
    pte = mk_pte(page, vma->vm_page_prot);
    // 用新的 pte 替换掉页表中的 swp_entry_t
    set_pte_at(vma->vm_mm, vmf->address, vmf->pte, pte);
    vmf->orig_pte = pte;

    // 建立新内存页的反向映射关系
    do_page_add_anon_rmap(page, vma, vmf->address, exclusive);
    // 将内存页添加到 lru 的 active list 中
    activate_page(page);
    // 释放交换区中的资源
    swap_free(entry);
    // 刷新 mmu cache
    update_mmu_cache(vma, vmf->address, vmf->pte);
    return ret;
}

总结

本文我们介绍了 Linux 内核如何通过缺页中断将进程页表从 0 到 1 一步一步的完整构建出来。从进程虚拟内存空间布局的角度来讲,缺页中断主要分为两个方面:

  • 内核态缺页异常处理 ------ do_kern_addr_fault,这里主要是处理 vmalloc 虚拟内存区域的缺页异常,其中涉及到主内核页表与进程页表内核部分的同步问题。

  • 用户态缺页异常处理 ------ do_user_addr_fault,其中涉及到的主内容是如何从 0 到 1 一步一步构建完善进程页表体系。

总体上来讲引起缺页中断的原因分为两大类:

  • 第一类是缺页虚拟内存地址背后映射的物理内存页不在内存中

  • 第二类是缺页虚拟内存地址背后映射的物理内存页在内存中。

第一类缺页中断的原因涉及到三种场景:

  1. 缺页虚拟内存地址 address 在进程页表中间页目录对应的页目录项 pmd_t 是空的。

  2. 缺页地址 address 对应的 pmd_t 虽然不是空的,页表也存在,但是 address 对应在页表中的 pte 是空的。

  3. 虚拟内存地址 address 在进程页表中的页表项 pte 不是空的,但是其背后映射的物理内存页被内核 swap out 到磁盘上了。

第二类缺页中断的原因涉及到两种场景:

  1. NUMA Balancing。

  2. 写时复制了(Copy On Write, COW)。

最后我们介绍了内核整个 swap in 的完整过程,其中涉及到的重要内容包括交换区的布局以及在内核中的组织结构,swap cache 与 page cache 之间的区别,swap 预读机制。

好了,今天的内容到这里就结束了,感谢大家的收看,我们下篇文章见~~~~

相关推荐
王燕龙(大卫)28 分钟前
linux下共享内存的3种使用方式
linux·运维·服务器
沈艺强37 分钟前
伊犁-linux root 密码忘记咋办
linux·运维·服务器
枫叶丹440 分钟前
【在Linux世界中追寻伟大的One Piece】IP分片和组装的具体过程
linux·网络·tcp/ip
做技术的Pandaer1 小时前
随笔 程序运行的基本原理
java·操作系统
很透彻3 小时前
vscode连Linux虚拟机(超简单)
linux·运维·服务器
小嵌同学3 小时前
SPI驱动学习六(SPI_Master驱动程序)
linux·驱动开发·学习·嵌入式·c·spi
¥ 多多¥7 小时前
数据结构:内存的使用
linux·c语言·开发语言·数据结构
咕噜Yuki06097 小时前
博睿谷IT认证-订阅试学习
linux·学习·redhat·rhce·红帽认证·ocm认证
冰红茶兑滴水7 小时前
Linux 线程互斥
linux·c++
元素之窗9 小时前
如何在 CentOS 中管理用户、组和服务状态
linux·运维·centos