Linux中的Page Cache

Linux中的Page Cache [一]

CPU如果要访问外部磁盘上的文件,需要首先将这些文件的内容拷贝到内存中,由于硬件的限制,从磁盘到内存的数据传输速度是很慢的,如果现在物理内存有空余,干嘛不用这些空闲内存来缓存一些磁盘的文件内容呢,这部分用作缓存磁盘文件的内存就叫做page cache。

用户进程启动read()系统调用后,内核会首先查看page cache里有没有用户要读取的文件内容,如果有(cache hit),那就直接读取,没有的话(cache miss)再启动I/O操作从磁盘上读取,然后放到page cache中,下次再访问这部分内容的时候,就又可以cache hit,不用忍受磁盘的龟速了(相比内存慢几个数量级)。

和CPU里的硬件cache是不是很像?两者其实都是利用的局部性原理,只不过硬件cache是CPU缓存内存的数据,而page cache是内存缓存磁盘的数据,这也体现了memory hierarchy分级的思想。

相对于磁盘,内存的容量还是很有限的,所以没必要缓存整个文件,只需要当文件的某部分内容真正被访问到时,再将这部分内容调入内存缓存起来就可以了,这种方式叫做demand paging(按需调页),把对需求的满足延迟到最后一刻,很懒很实用。

page cache中那么多的page frames,怎么管理和查找呢?这就要说到之前的文章提到的address_space结构体,一个address_space管理了一个文件在内存中缓存的所有pages。这个address_space可不是进程虚拟地址空间的address space,但是两者之间也是由很多联系的。

这篇文章讲到,mmap映射可以将文件的一部分区域映射到虚拟地址空间的一个VMA,如果有5个进程,每个进程mmap同一个文件两次(文件的两个不同部分),那么就有10个VMAs,但address_space只有一个。

每个进程打开一个文件的时候,都会生成一个表示这个文件的struct file,但是文件的struct inode只有一个,inode才是文件的唯一标识,指向address_space的指针就是内嵌在inode结构体中的。在page cache中,每个page都有对应的文件,这个文件就是这个page的owner,address_space将属于同一owner的pages联系起来,将这些pages的操作方法与文件所属的文件系统联系起来。

来看下address_space结构体具体是怎样构成的:

struct address_space { 
    struct inode            *host;              /* Owner, either the inode or the block_device */ 
    struct radix_tree_root  page_tree;          /* Cached pages */ 
    spinlock_t              tree_lock;          /* page_tree lock */ 
    struct prio_tree_root   i_mmap;             /* Tree of private and shared mappings */ 
    struct spinlock_t       i_mmap_lock;        /* Protects @i_mmap */       
    unsigned long           nrpages;            /* total number of pages */
    struct address_space_operations   *a_ops;   /* operations table */ 
    ...
}
  • host指向address_space对应文件的inode。
  • address_space中的page cache之前一直是用radix tree的数据结构组织的,tree_lock是访问这个radix tree的spinlcok(现在已换成xarray)。
  • i_mmap是管理address_space所属文件的多个VMAs映射的,用priority search tree的数据结构组织,i_mmap_lock是访问这个priority search tree的spinlcok。
  • nrpages是address_space中含有的page frames的总数。
  • a_ops是关于page cache如何与磁盘(backing store)交互的一系列operations。

从Radix Tree到XArray

Radix tree的每个节点可以存放64个slots(由RADIX_TREE_MAP_SHIFT设定,小型系统为了节省内存可以配置为16),每个slot的指针指向下一层节点,最后一层slot的指针指向struct page(关于struct page请参考这篇文章),因此一个高度为2的radix tree可以容纳64个pages,高度为3则可以容纳4096个pages。

如何在radix tree中找到一个指定的page呢?那就要回顾下struct page中的mappingindex域了,mapping指向page所属文件对应的address_space,进而可以找到address_space的radix tree,index既是page在文件内的offset,也可作为查找这个radix tree的索引,因为radix tree就是按page的index来组织struct page的。

具体的查找方法和使用VPN做索引的page table(参考这篇文章)以及使用PPN做索引的sparse section查找(参考这篇文章)都是类似的。这里是用page index中的一部分bits作为radix tree第一层的索引,另一部分bits作为第二层的索引,以此类推。因为一个radix tree节点存放64个slots,因此一层索引需要6个bits,如果radix tree高度为2,则需要12个bits。

内核中具体的查找函数是find_get_page(mapping, offset),如果在page cache中没有找到,就会触发page fault,调用__page_cache_alloc()在内存中分配若干物理页面,然后将数据从磁盘对应位置copy过来,通过add_to_page_cache()-->radix_tree_insert()放入radix tree中。在将一个page添加到page cache和从page cache移除时,需要将page和对应的radix tree都上锁。

Linux中radix tree的每个slot除了存放指针,还存放着标志page和磁盘文件同步状态的tag。如果page cache中一个page在内存中被修改后没有同步到磁盘,就说这个page是dirty的,此时tag就是PAGECACHE_TAG_DIRTY。如果正在同步,tag就是PAGECACHE_TAG_WRITEBACK。

只要下一层中有一个slot指向的page是dirty的,那么上一层的这个slot的tag就是"dirty"的(就像一滴墨水一样,放入清水后,清水也就不再完全清澈了)。前面介绍struct page中的flags时提到,flags可以是PG_dirty或PG_writeback,既然struct page中已经有了标识同步状态的信息,为什么这里radix tree还要再加上tag来标记呢?

因为page数量众多,内核不可能为每一个page维护一个timer,因此在判断是否应该writeback时,是以inode为单位的,而一个inode对应的address space中,有很多是dirty的page,很多是clean的,clean的page不需要writeback。

在遍历radix tree的过程中,如果去逐一比对每个page的"PG_dirty"标志位,将做很多无用功。而当slot也加上"dirty"标志位后,那么如果slot是clean的,就没有必要再扫描其下一层的slot和page了,这样可以减少开销。

想想进程虚拟地址空间中管理VMA的red black tree(参考这篇文章),这个叫radix tree长的跟我们平时见到的树好像不太一样啊,它的每一个节点更像是一个指针数组吧。所以啊,现在address_space中radix tree已经被xarray取代了(参考这篇文章)。

Reverse Mapping

如果要回收page cache中一个页面,可不仅仅是释放掉那么简单,别忘了Linux中进程和内核都是使用虚拟地址的,多少个PTE页表项还指向这个page呢,回收之前,需要将这些PTE中P标志位设为0(not present),同时将page的物理页面号PFN也全部设成0,要不然下次PTE指向的位置存放的就是无效的数据了。可是struct page中好像并没有一个维护所有指向这个page的PTEs组成的链表。

前面的文章说过,struct page数量极其庞大,如果每个page都有这样一个链表,那将显著增加内存占用,而且PTE中的内容是在不断变化的,维护这一链表的开销也是很大的。那如何找到这些PTE呢?

从虚拟地址映射到物理地址是正向映射,而通过物理页面寻找映射它的虚拟地址,则是reverse mapping(逆向映射)。page的确没有直接指向PTE的反向指针,但是page所属的文件是和VMA有mmap线性映射关系的啊,通过page在文件中的offset/index,就可以知道VMA中的哪个虚拟地址映射了这个page。

在代码中的实现是这样的:

__vma_address(struct page *page, struct vm_area_struct *vma)
{
    pgoff_t pgoff = page_to_pgoff(page);
    return vma->vm_start + ((pgoff - vma->vm_pgoff) << PAGE_SHIFT);
}

映射了某个address_space中至少一个page的所有进程的所有VMAs,就共同构成了这个address_space的priority search tree(PST)。

PST是一种糅合了radix tree和heap的数据结构,具体实现较为复杂,现在已经被基于augmented rbtree的interval tree所取代,详情请参考这篇文章

对比一下,一个进程所含有的所有VMAs是通过链表和红黑树组织起来的,一个文件所对应的所有VMA是通过基于红黑树的interval tree组织起来的。因此,一个VMA被创建之后,需要通过vma_link()插入到这3种数据结构中。

__vma_link_list(mm, vma, prev, rb_parent);
__vma_link_rb(mm, vma, rb_link, rb_parent);
__vma_link_file(vma); 

Linux中的Page Cache [二]

Buffer Cache

Buffer cache是指磁盘设备上的raw data(指不以文件的方式组织)以block为单位在内存中的缓存,早在1975年发布的Unix第六版就有了它的雏形,Linux最开始也只有buffer cache。事实上,page cache是1995年发行的1.3.50版本中才引入的。不同于buffer cache以磁盘的block为单位,page cache是以内存常用的page为单位的,位于虚拟文件系统层(VFS)与具体的文件系统之间。

在很长一段时间内,buffer cache和page cache在Linux中都是共存的,但是这会存在一个问题:一个磁盘block上的数据,可能既被buffer cache缓存了,又因为它是基于磁盘建立的文件的一部分,也被page cache缓存了,这时一份数据在内存里就有两份拷贝,这显然是对物理内存的一种浪费。更麻烦的是,内核还要负责保持这份数据在buffer cache和page cache中的一致性。所以,现在Linux中已经基本不再使用buffer cache了。

读写操作

上文提到,address_space中的a_ops定义了关于page和磁盘文件交互的一系列操作,它是由struct address_space_operations包含的一组函数指针组成的,其中最重要的就是readpage()和writepage()。

struct address_space_operations {
    int (*writepage)(struct page *page, struct writeback_control *wbc);
    int (*readpage)(struct file *, struct page *);
    /* Set a page dirty.  Return true if this dirtied it */
    int (*set_page_dirty)(struct page *page);
    int (*releasepage) (struct page *, gfp_t);
    void (*freepage)(struct page *);
    ...
}

之所以使用函数指针的方式,是因为不同的文件系统对此的实现会有所不同,比如在ext3中,page-->mapping-->a_ops-->writepage调用的就是ext3_writeback_writepage()。

struct address_space_operations ext3_writeback_aops = {         
    .readpage       = ext3_readpage,                 
    .writepage      = ext3_writeback_writepage,
    .releasepage    = ext3_releasepage,
    ...
}

readpage()会阻塞直到内核往用户buffer里填充满了请求的字节数,如果遇到page cache miss,那要等的时间就比较长了(取决于磁盘I/O的速度)。既然访问一次磁盘那么不容易,那干嘛不一次多预读几个page大小的内容过来呢?

是否采用预读(readahead)要看对文件的访问是连续的还是随机的,如果是连续访问,自然会对性能带来提升,如果是随机访问,预读则是既浪费磁盘I/O带宽,又浪费物理内存。

那内核怎么能预知进程接下来对文件的访问是不是连续的呢?看起来只有进程主动告知了,可以采用的方法有madvise()和posix_fadvise(),前者主要配合基于文件的mmap映射使用。advise如果是NORMAL,那内核会做适量的预读;如果是RANDOM,那内核就不做预读;如果是SEQUENTIAL,那内核会做大量的预读。

预读的page数被称作预读窗口(有点像TCP里的滑动窗口),其大小直接影响预读的优化效果。进程的advise毕竟只是建议,内核在运行过程中会动态地调节预读窗口的大小,如果内核发现一个进程一直使用预读的数据,它就会增加预读窗口,它的目标(或者说KPI吧)就是保证在预读窗口中尽可能高的命中率(也就是预读的内容后续会被实际使用到)。

Page cache缓存最近使用的磁盘数据,利用的是“时间局部性”原理,依据是最近访问到的数据很可能接下来再访问到,而预读磁盘的数据放入page cache,利用的是“空间局部性”原理,依据是数据往往是连续访问的。

同readpage()一样,writepage()的时候,如果要访问的内容不在page cache中,也要先从磁盘拷贝,类似于硬件cache中的write allocate机制。

Page cache这种内核提供的缓存机制并不是强制使用的,如果进程在open()一个文件的时候指定flags为O_DIRECT,那进程和这个文件的数据交互就直接在用户提供的buffer和磁盘之间进行,page cache就被bypass了,借用硬件cache的术语就是uncachable,这种文件访问方式被称为direct I/O,适用于用户使用自己设备提供的缓存机制的场景,比如某些数据库应用。

回写与同步

Page cache毕竟是为了提高性能占用的物理内存,随着越来越多的磁盘数据被缓存到内存中,page cache也变得越来越大,如果一些重要的任务需要被page cache占用的内存,内核将回收page cache以支持这些需求。

以elf文件为例,一个elf镜像文件通常由text(code)和data组成,这两部分的属性是不同的,text是只读的,调入内存后不会被修改,page cache里的内容和磁盘上的文件内容始终是一致的,回收的时候只要将对应的所有PTEs的P位和PFN清0,直接丢弃就可以了, 不需要和磁盘文件同步,这种page cache被称为discardable的。

而data是可读写的,当data对应的page被修改后,硬件会将PTE中的Dirty位置1(参考这篇文章),Linux通过SetPageDirty(page)设置这个page对应的struct page的flags为PG_Dirty(参考这篇文章),而后将PTE中的Dirty位清0。

在之后的某个时间点,这些修改过的page里的内容需要同步到外部的磁盘文件,这一过程就是page writeback,和硬件cache的writeback原理是一样的,区别仅在于CPU的cache是由硬件维护一致性,而page cache需要由软件来维护一致性,这种page cache被称为syncable的。

触发条件

那什么时候才会触发page的writeback呢?分下面几种情况:

  • 从空间的层面,当系统中"dirty"的内存大于某个阈值时。该阈值以在dirtyable memory中的占比"dirty_background_ratio"(默认为10%),或者绝对的字节数"dirty_background_bytes"(2.6.29内核引入)给出。如果两者同时设置的话,那么以"bytes"为更高优先级。

此外,还有"dirty_ratio"(默认为20%)和"dirty_bytes"[注1],它们的意思是当"dirty"的内存达到这个数量(屋里太脏),进程自己都看不过去了,宁愿停下手头的write操作(被阻塞,同步),先去把这些"dirty"的writeback了(把屋里打扫干净)。

而如果"dirty"的程度介于这个值和"background"的值之间(10% - 20%),就交给后面要介绍的专门负责writeback的background线程去做就好了(专职的清洁工,异步)。

  • 从时间的层面,即周期性的扫描(扫描间隔用"dirty_writeback_interval"表示,以毫秒为单位),发现存在最近一次更新时间超过某个阈值的pages(该阈值用"dirty_expire_interval"表示, 以毫秒为单位)。

要想知道page的最近更新时间,最简单的方法当然是每个page维护一个timestamp,但这个开销太大了,而且全部扫描一次也会非常耗时,因此具体实现中不会以page为粒度,而是按照inode中记录的dirtying-time来算。

  • 用户主动发起sync()/msync()/fsync()调用时。

可通过/proc/sys/vm文件夹查看或修改以上提到的几个参数:

centisecs是0.01s,因此上图所示系统的的"dirty_writeback_interval"是5s,"dirty_expire_interval"是30s

来对比下硬件cache的writeback机制。对于硬件cache,writeback会在两种情况触发:

  • 内存有新的内容需要换入cache时,替换掉一个老的cache line。你说为什么page cache不也这样操作,而是要周期性的扫描呢?

替换掉一个cache line对CPU来说是很容易的,直接靠硬件电路完成,而替换page cache的操作本身也是需要消耗内存的(比如函数调用的堆栈开销),如果这个外部backing store是个网络上的设备,那么还需要先建立socket之类的,才能通过网络传输完成writeback,那这内存开销就更大了。所以啊,对于page cache,必须未雨绸缪,不能等内存都快耗光了才来writeback。

  • 以x86为例,软件调用WBINVD指令刷新整个cache,调用CLFLUSH刷新指定的cache line(参考这篇文章),分别类似于page cache的sync()和msync()。
执行线程

2.4内核中用的是一个叫bdflush的线程来专门负责writeback操作,因为磁盘I/O操作很慢,而现代系统通常具备多个块设备(比如多个disk spindles),如果bdflush在其中一个块设备上等待I/O操作的完成,可能会需要很长的时间,此时其他块设备还闲着呢,这时单线程模式的bdflush就成为了影响性能的瓶颈。而且,bdflush是没有周期扫描功能的,因此它需要配合kupdated线程一起使用。

于是在2.6内核中,bdflush和它的好搭档kupdated一起被pdflush(page dirty flush)取代了。 pdflush是一组线程,根据块设备的I/O负载情况,数量从最少2个到最多8个不等。如果1秒内都没有空闲的pdflush线程可用,内核将创建一个新的pdflush线程,反之,如果某个pdflush线程的空闲时间已经超过1秒,则该线程将被销毁。一个块设备可能有多个可以传输数据的队列,为了避免在队列上的拥塞(congestion),pdflush线程会动态的选择系统中相对空闲的队列。

这种方法在理论上是很优秀的,然而现实的情况是外部I/O和CPU的速度差异巨大,但I/O系统的其他部分并没有都使用拥塞控制,因此pdflush单独使用复杂的拥塞算法的效果并不明显,可以说是“独木难支”。

于是在更后来的内核实现中(2.6.32版本),干脆化繁为简,直接一个块设备对应一个thread,这种内核线程被称为flusher threads,线程名为"writeback",执行体为"wb_workfn",通过workqueue机制实现调度。

无论是内核周期性扫描,还是用户手动触发,flusher threads的writeback都是间隔一段时间才进行的,如果在这段时间内系统掉电了(power failure),那还没来得及writeback的数据修改就面临丢失的风险,这是page cache机制存在的一个缺点。writeback越频繁,数据因意外丢失的风险越低,但同时I/O压力也越大。

前面介绍的O_DIRECT设置并不能解决这个问题,O_DIRECT只是绕过了page cache,但它并不等待数据真正写到了磁盘上。open()中flags参数使用O_SYNC才能保证writepage()会等到数据可靠的写入磁盘后再返回,适用于某些不容许数据丢失的关键应用。O_SYNC模式下可以使用或者不使用page cache,如果使用page cache,则相当于硬件cache的write through机制。

注1:

在面向服务器应用的RHEL-8中,latency profile的"dirty_background_ratio"和"dirty_ratio"分别被设置为3%和10%,而throughput profile则将这个2个参数分别设置为10%和40%。

笔者对此调整的理解是:更频繁的writeback会影响到业务的throughput,但保持内存中有更多的clean page,可以避免在内存紧张时,试图获取内存的应用陷入direct reclaim,影响latency。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值