深入理解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了。

读写操作

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。

这篇文章讲到,
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。

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_favise(),前者主要配合基于文件的mmap映射使用。advise如果是NORMAL,那内核会做适量的预读;如果是RANDOM,那内核就不做预读;如果是SEQUENTIAL,那内核会做大量的预读。

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

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

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 write back,和硬件cache的write back原理是一样的,区别仅在于CPU的cache是由硬件维护一致性,而page cache需要由软件来维护一致性,这种page cache被称为syncable的。

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

  • 从空间的层面,当系统中"dirty"的内存大于某个阈值时。该阈值以在总共的“可用内存”中的占比"dirty_background_ratio"(默认为10%)或者绝对的字节数"dirty_background_bytes"给出,谁最后被写入就以谁为准,另一个的值随即变为0(代表失效)。这里所谓的“可用内存”,包括了free pages和reclaimable pages。

此外,还有"dirty_ratio"(默认为20%)和"dirty_bytes",它们的意思是当"dirty"的内存达到这个数量(屋里太脏),进程自己都看不过去了,宁愿停下手头的write操作(被阻塞),先去把这些"dirty"的writeback了(把屋里打扫干净)。而如果"dirty"的程度介于这个值和"background"的值之间(10% - 20%),就交给后面要介绍的专门负责writeback的background线程去做就好了(专职的清洁工)。

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

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

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

image.png

centisecs是0.01s,因此上图所示系统的的dirty_background_ratio是10%,dirty_writeback_ interval是5s,dirty_expire_interval是30s。

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

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

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

执行线程

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

image.png

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

image.png

这种方法在理论上是很优秀的,然而现实的情况是外部I/O和CPU的速度差异巨大,但I/O系统的其他部分并没有都使用拥塞控制,因此pdflush单独使用复杂的拥塞算法的效果并不明显,可以说是“独木难支”。于是在更后来的内核实现中(2.6.32版本),干脆化繁为简,直接一个块设备对应一个thread,这种内核线程被称为flusher threads。

image.png

无论是内核周期性扫描,还是用户手动触发,flusher threads的write back都是间隔一段时间才进行的,如果在这段时间内系统掉电了(power failure),那还没来得及write back的数据修改就面临丢失的风险,这是page cache机制存在的一个缺点。

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

你可能感兴趣的:(深入理解linux中的page cache)