Linux内核设计与实现 第十六章 页高速缓存与页回写

页高速缓存(cache) 是Linux内核实现磁盘缓存。它主要用来减少对磁盘的I/0操作。
具体地讲,是通过把磁盘中的数据缓存到物理内存中,把对磁盘的访问变为对物理内存的访问。这一章将页回写:将页高速缓存中的变更数据刷新回磁盘的操作。

磁盘高速缓存之所以在任何现代操作系统中尤为重要源自两个因素:
第一,访问磁盘的速度要远远低于(差好几个数量级)访问内存的速度一ms 和ns的差距,因此,从内存访问数据比从磁盘访问速度更快,若从处理器的L1和L2高速缓存访问则更快。
第二,数据一旦被访问,就很有可能在短期内再次被访问到。这种在短时期内集中访问同一片数据的原理称作临时局部原理(temporal locality)。
临时局部原理能保证:如果在第一次访问数据时缓存它,那就极有可能在短期内再次被高速缓存命中(访问到高速缓存中的数据)。正是由于内存访问要比磁盘访问快得多,再加.上数据一次被访问后更可能再次被访问的特点,所以磁盘的内存缓存将给系统存储性能带来质的飞跃。

16.1缓存手段

页高速缓存是由内存中的物理页面组成的,其内容对应磁盘上的物理块。
页高速缓存大小的动态调整:它可以通过占用空闲内存以扩张大小,也可以自我收缩以缓解内存使用压力。
我们称正被缓存的存储设备为后备存储,因为缓存背后的磁盘无疑才是所有缓存数据的归属。

当内核开始-一个读操作(比如,进程发起一个read()系统调用),它首先会检查需要的数据是否在页高速缓存中。如果在,则放弃访问磁盘,而直接从内存中读取。这个行为称作缓存命中。
如果数据没有在缓存中,称为缓存未命中,那么内核必须调度块I/O操作从磁盘去读取数据。然后内核将读来的数据放入页缓存中,于是任何后续相同的数据读取都可命中缓存了。

注意,系统并不一定要将整个文件都缓存。缓存可以持有某个文件的全部内容,也可以存储另-些文件的页或者几页。到底该缓存谁取决于谁被访问到

1)写缓存

进程写磁盘时,比如执行write()系统调用,缓存如何被使用呢?
通常来讲,缓存一般被实现成下面三种策略之一-:
第一种策略称为
不缓存(nowrite), 也就是说高速缓存不去缓存任何写操作。
当对一个缓存中的数据片进行写时,将直接跳过缓存,写到磁盘,同时也使缓存中的数据失效。
那么如果后续读操作进行时,需要再重新从磁盘中读取数据。不过这种策略很少使用,因为该策略不但不去缓存写操作,而且需要额外费力去使缓存数据失效。

第二种策略,
写操作将自动更新内存缓存,同时也更新磁盘文件。这种方式,通常称为写透缓存(write-through cache),因为写操作会立刻穿透缓存到磁盘中。
这种策略对保持缓存一致性很有好处——缓存数据时刻和后备存储保持同步,所以不需要让缓存失效,同时它的实现也最简单。

第三种策略,
也是Linux所采用的,称为“回写”日。
在这种策略下,程序执行写操作直接写到缓存中,后端存储不会立刻直接更新,而是将页高速缓存中被写入的页面标记成“脏”,并且被加入到脏页链表中。
然后由一个进程(回写进程)周期行将脏页链表中的页写回到磁盘,从而让磁盘中的数据和内存中最终一致。最后清理“脏”页标识。
注意这里“脏页”这个词可能引起混淆,因为实际上脏的并非页高速缓存中的数据(它们是干干净净的),而是磁盘中的数据(它们已过时了)。也许更好的描述应该是“未同步”吧。尽管如此,我们说缓存内容是脏的,而不是说磁盘内容。
回写策略通常认为要好于写透策略,因为通过延迟写磁盘,方便在以后的时间内,合并更多的数据和再一次刷新。当然,其代价是实现复杂度高了许多。

2)缓存回收

缓存算法最后涉及的重要内容是缓存中的数据如何清除;或者是为更重要的缓存项腾出位置;或者是收缩缓存大小,腾出内存给其他地方使用。
这个工作,也就是决定缓存中什么内容将被清除的策略,称为缓存回收策略。
Linux的缓存回收是通过选择干净页(不脏=缓存磁盘数据同步)进行简单替换。如果缓存中没有足够的干净页面,内核将强制地进行回写操作,以腾出更多的干净可用页。最难的事情在于决定什么页应该回收。
理想的回收策略应该是回收那些以后最不可能使用的页面。当然要知道以后的事情你必须是先知。也正是这个原因,理想的回收策略称为预测算法。但这种策略太理想了,无法真正实现。

a、最近最少使用(Least recently used)算法
缓存回收策略通过所访问的数据特性,尽量追求预测效率。最成功的算法(特别是对于通用目的的页高速缓存)称作最近最少使用算法,简称LRU。
LRU 回收策略需要跟踪每个页面的访问踪迹(或者至少按照访问时间为序的页链表),以便能回收最老时间戳的页面(或者回收排序链表头所指的页面)。
该策略的良好效果源自于缓存的数据,越久未被访问,则越不大可能近期再被访问,而最近被访问的最有可能被再次访问。
对仅一次访问的窘境:LRU策略并非是放之四海而皆准的法则,对于许多文件被访问一次,再不被访问的情景,LRU尤其失败。将这些页面放在LRU链的顶端显然不是最优,当然, 内核并没办法知道-一个文件只会被访问一次,但是它却知道过去访问了多少次。

b、双链策咯
Linux实现的是一个修改过的LRU,也称为双链策略。
和以前的最近最少使用(Least recently used)算法不同,Linux 维护的不再是一个LUR链表,而是维护两个链表:活跃链表和非活跃链表。处于活跃链表上的页面被认为是“热”的且不会被回收,而在非活跃链表上的页面则是可以被回收。
在活跃链表(不可回收链表)中的页面必须在其被访问时,就处于非活跃链表(可回收链表)中。
两个链表都被伪LRU规则维护:页面从尾部加入,从头部移除,如同队列。
两个链表需要维持平衡:如果活跃链 表变得过多而超过了非活跃链表,那么活跃链表的头页面将被重新移回到非活跃链表中,以便能再被回收。
双链表策略解决了传统LRU算法中对仅一次访问的窘境。而且也更加简单的实现了伪LRU语义。这种双链表方式也称作LUR/2。更普遍的是n个链表,故称LRU/n.

c、页高速缓存如何帮助系统
我们现在知道页缓存如何构建(通过读和写),如何在写时被同步(通过回写)以及旧数据如何被回收来容纳新数据(通过双链表)。
现在让我们看看真实世界应用场景中,页高速缓存如何帮助系统。假定你在开发一个很大的软件工程(比如Linux内核)那么你将有大量的源文件被打开,只要你打开读取源文件,这些文件就将被存储在页高速缓存中。
只要数据被缓存,那么从一个文件跳到另一个文件将瞬间完成。当你编辑文件时,存储文件也会瞬间完成,因为写操作只需要写到内存,而不是磁盘。
当你编译项目时,缓存的文件将使得编译过程更少访问磁盘,所以编译速度也就更快了。如果整个源码树太大了,无法一次性放入内存,那么其中一部分必须被回收,由于双链表策略,任何回收的文件都将处于非活跃链表,而且不大可能是你正在编译的文件。幸运的是, 在你没在编译的时候,内核会执行页回写,刷新你所修改文件的磁盘副本。
由此可见,缓存将极大地提高系统性能。为了看到差别,对比一下缓存冷(cache cold)时( 也就是说重启后,编译你的大软件工程的时间)和缓存热(cache warm)时的差别吧。

16.2 Linux页高速缓存(Page Cache)

故名思义,页高速缓存中缓存的最小单元就是内存页。
缓存中的页来自对正规文件、块设备文件和内存映射文件的读写。如此一来,页高速缓存就包含了最近被访问过的文件的数据块。
在接下来的章节里,我们将剖析具体的数据结构,以及内核如何使用具体的数据结构管理缓存。

1)address_ space对象

System V,曾经也被称为AT&T System V,是Unix操作系统众多版本中的一支。一共发行了4个System V的主要版本:版本1、2、3和4。最成功的版本是System V Release 4,或者称为SVR4
逻辑地址空间和物理内存之间的转换称为 内存映射。
一个已经被打开的文件的管理结构体 vnode。

在页高速缓存中的页可能包含了多个不连续的物理磁盘块导。也正是由于页面中映射的磁盘块不一定连续,所以在页高速缓存中检查特定数据是否已经被缓存是件颇为困难的工作。
因为不能用设备名称和块号来做页高速缓存中的数据的索引,要不然这将是最简单的定位办法。
另外,Linux页高速缓存对被缓存的页面范围定义非常宽泛。

实际上,在最初System V Release 4引入页高速缓存时,仅仅只用作缓存文件系统数据,所以SVR4的页高速缓存使用它的等价文件对象(称为 vnode结构体)管理页高速缓存。
Linux 页高速缓存的目标是缓存任何基于页的对象,这包含各种类型的文件和各种类型的 内存映射。 虽然Linux页高速缓存可以通过扩展inode结构体(见第13章)支持页I/O操作,但这种做法会将页高速缓存局限于文件。
为了维持页高速缓存的普遍性(不应该将其绑定到物理文件或者inode结构体),Linux 页高速缓存使用了一个新对象管理缓存项和页I/O操作。这个对象.是address_ space 结构体。

//内核的内存描述符结构体mm_struct:表示进程的地址空间
struct mm_struct {
struct vm_area_struct * mmap; //[内存区域]链表
........
........
........
}

该结构体是第15章介绍的虛拟地址vm_ area_ struct 的物理地址对等体。当一个文件可以被10个vm area struct 结构体标识(比如有5个进程,每个调用mmap()映射它两次),那么这个文件只能有一个address_ space 数据结构—— 也就是文件可以有多个虚拟地址,但是只能在物理内存有一份。
与Linux内核中其他结构一样,address_ space 也是文不对题,也许更应该叫它page_ cache_ entity 或者physical pages_ of_ a fle.

该结构定义在文件 中,下面给出具体形式:

//address_ space 结构体:Linux的页高速缓存用address_ space结构体管理缓存项和页I/O操作。
struct address_ space
{
struct inode   *host ;                        /*address_space 结构往往会和某些内核对象关联。通常情况下,它会与一个索引节点(inode)关联,这时host域就会指向该索引节点;如果关联对象不是一个索引节点的话,比如address_space和swapper关联时,host 域会被置为NULL。*/
struct radix_ tree_ root   page_ tree;        /*包含全部页面的radix树*/
spinlock_ t   tree_ _1ock;                    /*保护page_ tree的自旋锁*/
unsigned int   i_ mmap_ writable;             /* VM_ SHARED 计数*/
struct prio_ tree_ root  i_ mmap ;            /*i_mmap字段是一个优先搜索树,它的搜索范围包含了在address_space中所有共享的与私有的映射页面。优先搜索树是一种巧妙地将堆与radix树结合的快速检索树。*/
struct list_ head  i_ mmap_ nonl inear ;      /* VM NONLINEAR链表*/
spinlock_七 i mmap_ 1ock ;                    /*保护i_ mmap的自旋锁*/
atomic_ t  truncate count ;                   /*截断计数*/
unsigned 1ong  nrpages ;                      /*address_ space结构体管理的缓存项的页总数*/
pgoff_ t  wri teback_ index;                  /*回写的起始偏移*/
struct address space_ operations *a_ ops;     /*操作表*/
unsigned long  flags;                         /* gfp_ mask掩码与错误标识*/
struct backing_ dev_ info *backing_ dev_ info;/*预读信息*/
spinlock_ t   private_ 1ock;                  /*私有address_ space锁*/
struct list_ head   private_ list;            /*私有address_ space 链表*/
struct address_ space   *assoc_ mapping ;     /*相关的缓冲*/
};

回忆早些提到的:一个被缓存的文件只和一个address_ space 结构体相关联,但它可以有多个vm_ area_ struct结构体——物理页到虚拟页是个一对多的映射。i_ map字段可帮助内核高效地找到关联的被缓存文件(即address_ space)。

2)address_ space操作

所有的页I/O操作必然都是通过页高速缓存进行的。
存取速度慢的、需要存放自己的数据A到内存的存储器,形成提高数据A的读写速度的缓冲区的,叫后备存储
每个后备存储都有自己的address_ space_ operation结构体,用于描述自己如何与页高速缓存交互。
address_ space_ operation结构体的所有成员都是函数指针,address_ space_ operation结构体也是某后备存储如何与页高速缓存交互的函数表。
操作函数表定义在文件中,由address_ space_ operations 结构体来表示:

struct address_space_operations{
int (*writepage) (struct page *, struct writeback control *);
int (*readpage) (struct file *struct page *) ;
int (*sync_page) (struct page *) ;
int (*wri tepages) (struct address_ space *struct writeback control *);
int (*set_page_dirty) (struct page *);
int (*readpages) (struct file *struct address_ space * ,struct list_head *, unsigned) ;
int (*write_begin) (struct file *, struct address_space *mapping ,1off_t pos, unsigned len, unsigned flags,
struct page **pagep, void **fsdata) ;
int (*write_end) (struct file *, struct address_ space *mapping,loff_t pos, unsigned 1en, unsigned copied,
struct page *page, void *fsdata) ;
sector_t (*bmap) (struct address_space *, sector_t) ;
int (* invalidatepage) (struct page *, unsigned 1ong) ;
int (*releasepage) (struct page *int) ;
int (*direct_I0) (int, struct kiocb *const struct lovec *,1off_t, unsigned 1ong) ;
int (*get_xip_mem) (struct address_space *, pgoff_t, int,vold **unsigned 1ong *) ; .
int (*migratepage) (struct address_space *struct page *struct page *);
int (*launder_page) (struct page *) ;
int (*is_partially_uptodate) (struct page *,read_descriptor_t *,unsigned 1ong) ;
int (*error_remove_page) (struct address_space *,struct page *) ;
};

因此,内核也总是试图先通过页高速缓存来满足所有的读请求。
如果在页高速缓存中未搜索到需要的页,则内核将从后备存储读入需要的页,然后将该页加入到页高速缓存中;
对于写操作,页高速缓存更像是一个存储平台,所有要被写出的页都要加入页高速缓存中。将页高速缓存中被写入的页面标记成“脏”,并且被加入到脏页链表中。脏页链表中不同步的也将延迟很短时间后再自动同步。

3)基树

因为在任何页I/O操作前内核都要检查页是否已经在页高速缓存中了,所以这种频繁进行的检查必须迅速、高效,否则搜索和检查页高速缓存的开销可能抵消页高速缓存带来的好处(至少在缓存命中率很低的时候,搜索的开销足以抵消以内存代替磁盘进行检索数据带来的好处)。

正如在16.2.2节所看到的,页高速缓存通过两个参数address_space对象加上一个偏移量进行搜索。每个address space对象都有唯一的基树(radix tee),它保存在page_ tree 结构体中。
基树是一个二叉树,只要指定了文件偏移量,就可以在基树中迅速检索到希望的页。
页高速缓存的搜索函数find
get page0要调用函数radix_ tree_ lookup(), 该函数会在指定基树中搜索指定页面。
基树核心代码的通用形式可以在文件lib/radix-tree.c中找到。另外,要想使用基树,需要包含头文件

4)以前的页散列表

在2.6版本以前,内核页高速缓存不是通过基树检索,而是通过一一个维护了系统中所有页的全局散列表进行检索。对于给定的一一个键值,该散列表会返回一个双向链表的入口对应于这个所给定的值。
如果需要的页贮存在缓存中,那么链表中的一项就会与其对应。否则,页就不在页面高速缓存中,散列函数返回NULL。
全局散列表主要存在四个问题:
●由于使用单个的全局锁保护散列表,所以即使在中等规模的机器中,锁的争用情况也会相当严重,造成性能受损。
●由于散列表需要包含所有页高速缓存中的页,可是搜索需要的只是和当前文件相关的那些页,所以散列表包含的页面相比搜索需要的页面要大得多。
●如果散列搜索失败(也就是给定的页不在页高速缓存中),执行速度比希望的要慢得多,这是因为检索必须遍历指定散列键值对应的整个链表。
●散列表比其他方法会消耗更多的内存。
2.6版本内核中引入基于基树的页高速缓存来解决这些问题。

5)包含全部页面的radix树

radix树通过long型的位操作来查询各个节点, 存储效率高,并且可以快速查询。
首先是 radix树节点的定义

/* 源码参照 lib/radix-tree.c */
struct radix_tree_node {
    unsigned int       height;                                          /* radix树的高度 */
    unsigned int       count;                                           /* 当前节点的子节点数目 */
    struct rcu_head    rcu_head;                                        /* RCU 回调函数链表 */
    void               *slots[RADIX_TREE_MAP_SIZE];                     /* 节点中的slot数组 */
    unsigned long      tags[RADIX_TREE_MAX_TAGS][RADIX_TREE_TAG_LONGS]; /* slot标签 */
};

弄清楚 radix_tree_node 中各个字段的含义,也就差不多知道 radix树是怎么一回事了。

  • height 表示的整个 radix树的高度(即叶子节点到树根的高度), 不是当前节点到树根的高度
  • count 这个比较好理解,表示当前节点的子节点个数,叶子节点的 count=0
  • rcu_head RCU发生时触发的回调函数链表
  • slots 每个slot对应一个子节点(叶子节点)
  • tags 标记子节点是否 dirty 或者 wirteback

Linux内核设计与实现 第十六章 页高速缓存与页回写_第1张图片
如图有一层根目录,四层数据。第四层的数据的路径用4*2位二进制数表示。

16.3缓冲区高速缓存(Buffer Cache)

缓冲区高速缓存:独立的磁盘块通过块I/O缓冲被存入页高速缓存,Buffer Cache的数据仅仅是文件系统的数据。
缓冲区高速缓存实现上没有作为独立缓存,而是作为页高速缓存的一部分。
缓冲和页高速缓存并非天生就是统一的,2.4 内核的主要工作之一就是统一它们。在更早的内核中,有两个独立的磁盘缓存:页高速缓存和缓冲区高速缓存。

16.4 flusher线程

16.4 flusher线程
a)
由于页高速缓存的缓存作用,写操作实际上会被延迟。当页高速缓存中的数据比后台存储的数据更新时,该数据就称作脏数据。在内存中累积起来的脏页最终必须被写回磁盘。
在以下3种情况发生时,脏页被写回磁盘:

  • 当空闲内存低于一个特定的阈值时,内核必须将脏页写回磁盘以便释放内存,因为只有干净(不脏的)内存才可以被回收。当内存干净后,内核就可以从缓存清理数据,然后收缩缓存,最终释放出更多的内存。
  • 当脏页在内存中驻留时间超过一个特定的阈值时,内核必须将超时的脏页写回磁盘,以确保脏页不会无限期地驻留在内存中。
  • 当用户进程调用sync()和fsync()系统调用时,内核会按要求执行回写动作。
int fsync(int filedes);//强制完成由filedes 文件描述符指定的文件的所有排队I/O 操作 ,同步文件的状态。fsync会确保一直到写磁盘操作结束才会返回
int sync();//将所有修改过的缓冲区排入写队列,然后就返回了,sync并不等实际的写磁盘的操作结束。所以它的返回并不能保证数据的安全性。通常会有一个update系统守护进程每隔30s调用一次sync。

b)
上面三种工作的目的完全不同。实际上,在旧内核中,这是由两个独立的内核线程(请看后面章节)分别完成的。但是在2.6内核中,由一群内核线程(flusher 线程)执行这三种工作。
首先,fusher 线程在系统中的空闲内存低于一个特定的阈值时,将脏页刷新写回磁盘。该后台回写例程的目的在于——在可用物理内存过低时,释放脏页以重新获得内存。这个特定的内存
阈值可以通过dirty_background_ratio sysctl系统调用设置。当空闲内存比阈值dirty_ background
ratio还低时,内核便会调用函数fusher_threads()唤醒一个或多个fusher线程,随后fusher线程进一步调用函数bdi_ writeback all()开始将脏页写回磁盘。
该函数需要一个参数——试图写回的页面数目。函数连续地写出数据,直到满足以下两个条件:

  • 已经有指定的最小数目的页被写出到磁盘。
  • 空闲内存数已经回升,超过了阈值dirty_background_ratio。

c)
上述条件确保了flusher线程操作可以减轻系统中内存不足的压力。回写操作不会在达到这两个条件前停止,除非刷新者线程写回了所有的脏页,没有剩下的脏页可再被写回了。

为了满足第二个目标(即剩下足够的空闲内存),flusher线程后台例程会被周期性唤醒(和空闲内存是否过低无关),将那些在内存中驻留时间过长的脏页写出,确保内存中不会有长期存在的脏页。
如果系统发生崩溃,由于内存处于混乱之中,所以那些在内存中还没来得及写回磁盘的脏页就会丢失,所以周期性同步页高速缓存和磁盘非常重要。

在系统启动时,内核初始化一个定时器,让它周期地唤醒fusher线程,随后使其运行函数wb_writeback0)。
该函数将把所有驻留时间超过dirty_expire_interval ms的脏页写回。然后定时器将再次被初始化为dirty_expire_centisecs 秒后唤醒fusher线程。
总而言之,fusher线程周期性地被唤醒并且把超过特定期限的脏页写回磁盘。

flusher线程的实现代码在文件mm/page-writeback.c和mm/backing-dev.c中,回写机制的实现代码在文件fs/fs-writeback.c 中。

系统管理员可以在/proc/sys/vsm中设置回写相关的参数,也可以通过sysctl系统调用设置它们。sysctl 使用/proc/sys目录下的文件来修改内核参数。
页回写中涉及的一些阀值可以在 /proc/sys/vm 中找到
下表中列出的是与 pdflush(flusher 线程的一种实现) 相关的一些阀值

阀值 描述
dirty_ background_ ratio 占全部内存的百分比。当内存中空闲页达到这个比例时, pdfush 线程开始回写脏页
dirty_ expire_ interval 该数值以百分之一-秒为单位, 它描述超时多久的数据将被周期性执行的pdfush线程写出
dirty_ ratio 占全部内存百分比,当一个进程产生的脏页达到这个比例时,就开始被写出
dirty_ writeback interval 该数值以百分之一秒为单位,它描述pdfush线程的运行频率
laptop_ mode 一个布尔值,用于控制膝上型计算机模式,具体请见后续内容

flusher线程的实现方法随着内核的发展也在不断的变化着。下面介绍几种在内核发展中出现的比较典型的实现方法。

  • 膝上型计算机模式
      这种模式的意图是将硬盘转动的机械行为最小化,允许硬盘尽可能长时间的停滞,以此延长电池供电时间。
      该模式通过 /proc/sys/vm/laptop_mode 文件来设置。(0 - 关闭该模式 1 - 开启该模式)

  • bdflush 和 kupdated (2.6版本前 flusher 线程的实现方法)
      bdflush 内核线程在后台运行,系统中只有一个 bdflush 线程,当内存消耗到特定阀值以下时,bdflush 线程被唤醒
      kupdated 周期性的运行,写回脏页。
       bdflush 存在的问题:
       整个系统仅仅只有一个 bdflush 线程,当系统回写任务较重时,bdflush 线程可能会阻塞在某个磁盘的I/O上,
       导致其他磁盘的I/O回写操作不能及时执行。

  • pdflush (2.6版本引入)
      pdflush 线程数目是动态的,取决于系统的I/O负载。它是面向系统中所有磁盘的全局任务的。
       pdflush 存在的问题:
       pdflush的数目是动态的,一定程度上缓解了 bdflush 的问题。但是由于 pdflush 是面向所有磁盘的,
       所以有可能出现多个 pdflush 线程全部阻塞在某个拥塞的磁盘上,同样导致其他磁盘的I/O回写不能及时执行。

  • flusher线程 (2.6.32版本后引入)
       flusher线程改善了上面出现的问题:
       首先,flusher 线程的数目不是唯一的,这就避免了 bdflush 线程的问题
       其次,flusher 线程不是面向所有磁盘的,而是每个 flusher 线程对应一个磁盘,这就避免了 pdflush 线程的问题

你可能感兴趣的:(《Linux内核设计与实现,》阅读笔记,linux,服务器,运维)