1、Buffer Cache和Page Cache
一句话解释:Page Cache用于缓存文件的页数据,Buffer Cache用于缓存块设备(磁盘)的块数据。但由于磁盘都是由文件系统管理的,所以会导致数据会被缓存两次,因此现在Linux已经不再使用Buffer Cache。
2、读写操作
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,适用于用户使用自己设备提供的缓存机制的场景,比如某些数据库应用。
3、回写与同步
Page cache毕竟是为了提高性能占用的物理内存,随着越来越多的磁盘数据被缓存到内存中,page cache也变得越来越大,如果一些重要的任务需要被page cache占用的内存,内核将回收page cache以支持这些需求。
一个文件通常由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。
参考:
https://www.cnblogs.com/gmpy/p/12657801.html
Linux中的Page Cache [二] - 知乎
内存脏页参数介绍_vm.dirty_ratio_kwdecsdn的博客-CSDN博客