第六章 PageCache ⼯作原理
PageCache 是我们服务器端开发者经常接触的概念。它的存在极⼤地提升了⽂件操作的速度。但是同时⼜有很多的线上性能问题是由于 PageCache 使⽤不当造成的。所以我认为,我们有必要深⼊地理解⼀些 PageCache 的内部⼯作原理。这样才能更好地驾驭它。
下⾯是⼀段简单的访问磁盘⽂件的代码。这段代码中,虽然通过 fread 访问磁盘⽂件中的内容。但并不是每次调⽤都会触发磁盘 IO,⽽是⼤概率会被内存中的 PageCache 兜住。
今天我们就来看下内核中 PageCache 的实现核⼼原理。
6.1 PageCache 相关定义
int main() {
// 打开⽂件
fd = open(filename, O_RDONLY);
// 通过 fread 读取⽂件内容
fread(..., fd);
...
}6.1.1 ⽂件中的 PageCache
每⼀个进程都是⼀个 task_struct,在这个 task_struct 内通过 *file 字段保存着⼀个⽂件描述符数组。数组中每⼀个元素对应的都是该进程打开的⽂件句柄。
每个⽂件描述符都指向⼀个内核中的 struct file 内核对象。对于磁盘上的⽂件,struct file 会指向⼀个 struct inode 内核对象。inode 内核对象中有⼀个 struct address_space 类型的 *i_mapping 成员。address_space 管理着当前磁盘⽂件在内存中对应的缓存,这也就是我们⽇常所说的 PageCache。
我们来看下它的源码。
// file:include/linux/fs.h
struct inode {
...
struct address_space *i_mapping;
...
}// file:include/linux/fs.h
struct address_space {
struct inode *host;
struct xarray i_pages;
...
struct rb_root_cached i_mmap;
...
const struct address_space_operations *a_ops;
...
}在成员 address_space 的核⼼数据结构 struct xarray i_pages 是⼀棵 xarray 树。该树是基数树 radix tree 的⼀种实现。管理着所有与本⽂件相关的⽂件缓存⻚。
内核版本说明
4.15 之后的内核在 pid 的管理上,也采⽤的是基数树。参⻅我之前的另⼀篇⽂章《为什么新版内核将进程pid管理从bitmap替换成了radix-tree?》
这棵基数树的作⽤是快速判断⼀个⽂件内容中的某⼀块是否已经在内存中存在。如果已经存在,则直接在内存中就可以完成读取。
6.1.2 系统中的 PageCache
在《深⼊理解Linux进程与内存》中,我们提到过 Linux 是通过 node 和 zone 的⽅式来管理所有的物理内存。由于分配内存时有不同的需求,所以 node 被细分成了 zone,在 zone 中通过伙伴系统来管理所有的空闲⻚⾯。
除了空闲⻚⾯外,所有的 PageCache 中的⻚⾯也需要管理起来。在 Linux 4.8 版本以前,LRU 链表也是在 zone 中进⾏管理的。但这样带来的⼀个问题是各个 zone 中 page ⽼化程度不同步。
在 4.8 版本之后,LRU 就搬到 node 中实现了。node 在内核中的结构体名字叫 pglist_data。在每个 node 中存在⼀个 struct lruvec 对象。该对象是 LRU 算法的核⼼实现,⽤来保存整个系统中的所有缓存⻚。
// file:include/linux/xarray.h
struct xarray {
...
void __rcu * xa_head;
...
}//file:include/linux/xarray.h
struct xa_node {
...
unsigned char shift;
void __rcu *slots[XA_CHUNK_SIZE];
union {
unsigned long tags[XA_MAX_MARKS][XA_MARK_LONGS];
...
};
}其中 lruvec 是⼀组 LRU 链表。
//file:include/linux/mmzone.h
typedef struct pglist_data {
struct zone node_zones[MAX_NR_ZONES];
int node_id;
...
struct lruvec __lruvec;
...
}// file:include/linux/mmzone.h
struct lruvec {
// 5个双向链表的头
struct list_head lists[NR_LRU_LISTS];
...
#ifdef CONFIG_MEMCG
struct pglist_data *pgdat;
#endif
}enum lru_list {
LRU_INACTIVE_ANON = LRU_BASE,
LRU_ACTIVE_ANON = LRU_BASE + LRU_ACTIVE,
LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE,
LRU_ACTIVE_FILE = LRU_BASE + LRU_FILE + LRU_ACTIVE,
LRU_UNEVICTABLE,
NR_LRU_LISTS
};之所以 Linux 划分成了多个 LRU 链表,是因为作⽤的不同。
根据⻚⾯的使⽤场景的不同分成 匿名⻚ 和 ⽂件⻚。通过 malloc 等⽅式申请的内存位于匿名⻚链表中,通过 read 读取⽂件使⽤的⻚⾯位于⽂件⻚链表中。
另外再根据最近是否有被访问过再次划分为 INACTIVE 和 ACTIVE 两类。对于⾸次被访问的⽂件会被加⼊到 INACTIVE 链表,多次访问过就会被转到 ACTIVE 中。这是为了避免需要多次使⽤的缓存⻚被误淘汰。
当系统中空闲内存紧张的时候,就会通过 LRU 算法从 lruvec 中选择⻚⾯淘汰掉。
6.2 磁盘 Buffered I/O
为了更好理解 O,我们直接看⼀张整体的 Linux IO 栈架构图。
[Image 39 on Page 52] [Image 264 on Page 53] [Image 271 on Page 54] [Image 276 on Page 55] [Image 285 on Page 57] [Image 293 on Page 59]
从这张图中,我们⼤概可以看到 PageCache 是存在于 VFS 接⼝和具体的⽂件系统中间的⼀层。这⼀层存在的主要⽬的是⽤内存来缓存磁盘中的数据,以减少对磁盘的实际请求。这么做的根本原因是磁盘不仅访问延时⽐较⾼,带宽吞吐也不咋地。(虽然现在的 SSD 已经有了⻓⾜的进步,但还是赶不上内存性能)
我们在应⽤程序中,通过 fread 读取⽂件中的内容时都会使⽤到 PageCache。在使⽤ fread 的时候,内核会进⼊到 read 系统调⽤。
在该系统调⽤中执⾏的内核函数链路较深,我们就不展开了。和 PageCache 相关的关键函数是 filemap_get_pages。
// file:fs/ext4/file.c
static int filemap_get_pages(struct kiocb *iocb, struct iov_iter *iter,
struct folio_batch *fbatch)6.2.1 读 PageCache
在 filemap_get_pages 中先回从 PageCache 中读取。在 filemap_get_read_batch 是通过 address_space 下的 xarray 基数树 i_pages 成员来判断是否在内存中。
6.2.2 写 PageCache
如果 PageCache 中不存在,则再调⽤ page_cache_sync_readahead 发出⼀个同步预读请求,发起磁盘 IO。在 page_cache_sync_readahead 中会完成以下两件重要的事情。
第⼀件事情是发起磁盘 IO 读取到数据后,要把数据放到拷⻉到 PageCache 中,并添加到 xarray 基数树中管理起来,⽅便下次再次访问时快速判断。
{
...
struct address_space *mapping = filp->f_mapping;
// 从 PageCache 中读取
filemap_get_read_batch(mapping, index, last_index - 1, fbatch);
// 发出⼀个同步预读请求,从磁盘中读取
page_cache_sync_readahead(mapping, ra, filp, index,
last_index - index);
...
}// file:mm/filemap.c
static void filemap_get_read_batch(struct address_space *mapping,
pgoff_t index, pgoff_t max, struct folio_batch *fbatch)
{
XA_STATE(xas, &mapping->i_pages, index);
struct folio *folio;
...
for (folio = xas_load(&xas); folio; folio = xas_next(&xas)) {
...
}
}第⼆件事情是要把新申请的 PageCache ⻚⾯加⼊到当前 node 的 LRU 链表中,以供系统在内存紧张的时候选择淘汰。其调⽤路径是 page_cache_sync_ra -> page_cache_sync_ra -> ...,经过多次调⽤后进⼊到 folio_add_lru 中。
在前⾯ 2.2 节我们注意到 LRU 链表是个全局的数据结构。这样进程多的时候,锁竞争会⽐较严重。所以在 folio_batch_add_and_move 函数中,并不是直接把⻚⾯添加到 LRU 链表中,⽽是先加⼊到⼀个 PerCPU 的缓存中。直到该缓存积累到⼀定数量后,才⼀次性把缓存中的所有缓存⻚全部加⼊到 LRU 中。
//file:mm/swap.c
void folio_add_lru(struct folio *folio)
{
...
// 增加⻚refcount计数
folio_get(folio);
// 获取per cpu lru cache结构
fbatch = this_cpu_ptr(&cpu_fbatches.lru_add);
// 将⻚⾯加⼊per cpu lru cache或者lru_active_anon链
folio_batch_add_and_move(fbatch, folio, lru_add_fn);
...
}在 folio_batch_move_lru -> lru_add_fn -> lruvec_add_folio -> folio_lru_list 中先判断⼀下要加⼊哪个 LRU 链表。可⻅对于⾸次加⼊的⽂件⻚,先加⼊到了 LRU_INACTIVE_FILE 链表中。
// file:mm/swap.c
static void folio_batch_add_and_move(struct folio_batch *fbatch,
struct folio *folio, move_fn_t move_fn)
{
// ⻚会先加⼊fbatch如果数量不够多就直接返回
if (folio_batch_add(fbatch, folio) && !folio_test_large(folio) &&
!lru_cache_disabled())
return;
// 数量较多时再把整个fbatch都加⼊lru链
folio_batch_move_lru(fbatch, move_fn);
}// file:include/linux/mm_inline.h
static __always_inline enum lru_list folio_lru_list(struct folio *folio)
{
enum lru_list lru;
...
lru = folio_is_file_lru(folio) ? LRU_INACTIVE_FILE : LRU_INACTIVE_ANON;
if (folio_test_active(folio))
lru += LRU_ACTIVE;
return lru;
}本章总结
PageCache 在内核的实现中,有两套⾮常重要的数据结构。
第⼀套是 struct file -> inode -> address_space。在这套数据结构中定义了⼀个基数树,⽅便⽤户进程快速查找所需的⽂件⻚是否已经缓存到内存中了。
第⼆套是 node 中的 LRU 链表。内核会通过 LRU 链表将整个系统中的所有 Page ⻚⾯都管理起来。当空闲物理⻚不⾜的时候,会使⽤该 LRU 链表来选择淘汰哪些⻚⾯以释放物理内存。
我们在应⽤程序中通过 fread 等函数访问⽂件时,内核中触发了 read 系统调⽤。
在 read 系统调⽤中,会先从 struct file 下的 inode PageCache 中读取。在 filemap_get_read_batch 是通过 address_space 下的 xarray 基数树 i_pages 成员来判断是否在内存中。如果在内存中,则直接就返回了。
如果 read 系统调⽤在 PageCache 中没有找到⻚⾯(可能未加载,或者已经被淘汰),则需要重新发起磁盘 IO。读取到的数据既要加⼊到 inode 的 xarray 基数树中,也需要添加到 node 中的 LRU 链表中。
加入知识星球
有想继续加⼊知识星球的同学微信扫描下⾯的⼆维码即可加⼊。另外在公众号后台发送「星球优惠券」可以获取开发内功修炼读者的专属优惠券。