Linux内核系列:页缓存

什么是页缓存

我们知道文件一般存放在硬盘(机械硬盘或固态硬盘)中,CPU 并不能直接访问硬盘中的数据,而是需要先将硬盘中的数据读入到内存中,然后才能被 CPU 访问。

由于读写硬盘的速度比读写内存要慢很多(DDR4 内存读写速度是机械硬盘500倍,是固态硬盘的200倍),所以为了避免每次读写文件时,都需要对硬盘进行读写操作,Linux 内核使用 页缓存(Page Cache) 机制来对文件中的数据进行缓存。

为了提升对文件的读写效率,Linux 内核会以页大小(4KB)为单位,将文件划分为多数据块。当用户对文件中的某个数据块进行读写操作时,内核首先会申请一个内存页(称为 页缓存)与文件中的数据块进行绑定。如下图所示:

如上图所示,当用户对文件进行读写时,实际上是对文件的 页缓存 进行读写。所以对文件进行读写操作时,会分以下两种情况进行处理:

当从文件中读取数据时,如果要读取的数据所在的页缓存已经存在,那么就直接把页缓存的数据拷贝给用户即可。否则,内核首先会申请一个空闲的内存页(页缓存),然后从文件中读取数据到页缓存,并且把页缓存的数据拷贝给用户。
当向文件中写入数据时,如果要写入的数据所在的页缓存已经存在,那么直接把新数据写入到页缓存即可。否则,内核首先会申请一个空闲的内存页(页缓存),然后从文件中读取数据到页缓存,并且把新数据写入到页缓存中。对于被修改的页缓存,内核会定时把这些页缓存刷新到文件中。

页缓存实现

在 Linux 内核中,使用 file 对象来描述一个被打开的文件,其中有个名为 f_mapping 的字段,定义如下;

1
2
3
4
struct file {
...
struct address_space *f_mapping;
};

上面代码可以看出,f_mapping 字段的类型为 address_space 结构,其定义如下:

1
2
3
4
5
6
struct address_space {
struct inode *host; /* owner: inode, block_device */
struct radix_tree_root page_tree; /* radix tree of all pages */
rwlock_t tree_lock; /* and rwlock protecting it */
...
};

address_space 结构其中的一个作用就是用于存储文件的 页缓存,下面介绍一下各个字段的作用:

host:指向当前 address_space 对象所属的文件 inode 对象(每个文件都使用一个 inode 对象表示)。
page_tree:用于存储当前文件的 页缓存。
tree_lock:用于防止并发访问 page_tree 导致的资源竞争问题。
从 address_space 对象的定义可以看出,文件的 页缓存 使用了 radix树 来存储。

  • radix树:又名基数树,它使用键值(key-value)对的形式来保存数据,并且可以通过键快速查找到其对应的值。内核以文件读写操作中的数据 偏移量 作为键,以数据偏移量所在的 页缓存 作为值,存储在 address_space 结构的 page_tree 字段中。(其实简单点讲,radix树可以理解为压缩过的trie树,也就是把只有一个子孩子的节点合并到它的父节点中.)
    下图展示了上述各个结构之间的关系:

左上角是一个file结构体,里面的mapping对应一个address_space,映射到一个radix树,radix存储的是文件偏移量对应的页缓存。如上图共有64个分叉,高度为2。Linux(2.6.7) 内核中的分叉为 64(2^6),树高为 6(32位系统)或者 11(64位系统),用来快速定位 32 位或者 64 位偏移,radix tree 中的每一个叶子节点指向文件内相应偏移所对应的Cache项。radix树为稀疏树提供了有效的存储,代替固定尺寸数组提供了键值到指针的快速查找。

读文件

现在我们来分析一下读取文件数据的过程,用户可以通过调用 read 系统调用来读取文件中的数据,其调用链如下:

1
2
3
4
5
6
7
read()
└→ sys_read()
└→ vfs_read()
└→ do_sync_read()
└→ generic_file_aio_read()
└→ do_generic_file_read()
└→ do_generic_mapping_read()

从上面的调用链可以看出,read 系统调用最终会调用 do_generic_mapping_read 函数来读取文件中的数据,其实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
void
do_generic_mapping_read(struct address_space *mapping,
struct file_ra_state *_ra,
struct file *filp,
loff_t *ppos,
read_descriptor_t *desc,
read_actor_t actor)
{
struct inode *inode = mapping->host;
unsigned long index;
struct page *cached_page;
...

cached_page = NULL;
index = *ppos >> PAGE_CACHE_SHIFT;
...

for (;;) {
struct page *page;
...

find_page:
// 1. 查找文件偏移量所在的页缓存是否存在
page = find_get_page(mapping, index);
if (!page) {
...
// 2. 如果页缓存不存在, 那么跳到 no_cached_page 进行处理
goto no_cached_page;
}
...

page_ok:
...
// 3. 如果页缓存存在, 那么把页缓存的数据拷贝到用户应用程序的内存中
ret = actor(desc, page, offset, nr);
...
if (ret == nr && desc->count)
continue;
goto out;
...

readpage:
// 4. 从文件读取数据到页缓存中
error = mapping->a_ops->readpage(filp, page);
...
goto page_ok;
...

no_cached_page:
if (!cached_page) {
// 5. 申请一个内存页作为页缓存
cached_page = page_cache_alloc_cold(mapping);
...
}

// 6. 把新申请的页缓存添加到文件页缓存中
error = add_to_page_cache_lru(cached_page, mapping, index, GFP_KERNEL);
...
page = cached_page;
cached_page = NULL;
goto readpage;
}

out:
...
}

do_generic_mapping_read 函数的实现比较复杂,经过精简后,上面代码只留下最重要的逻辑,可以归纳为以下几个步骤:

  • 通过调用 find_get_page 函数查找要读取的文件偏移量所对应的页缓存是否存在,如果存在就把页缓存中的数据拷贝到应用程序的内存中。
  • 否则调用 page_cache_alloc_cold 函数申请一个空闲的内存页作为新的页缓存,并且通过调用 add_to_page_cache_lru 函数把新申请的页缓存添加到文件页缓存和 LRU 队列中(后面会介绍)。
  • 通过调用 readpage 接口从文件中读取数据到页缓存中,并且把页缓存的数据拷贝到应用程序的内存中。
    从上面代码可以看出,当页缓存不存在时会申请一块空闲的内存页作为页缓存,并且通过调用 add_to_page_cache_lru 函数把其添加到文件的页缓存和 LRU 队列中。

写文件

写操作也是一样,待写入的buffer在内核空间不能直接访问,必须要先拷贝至内核空间对应的主存,再写回磁盘中(延迟写回),也是需要两次数据拷贝。