页高速缓存(cache)是linux内核实现磁盘缓存,主要用来减少对磁盘的IO操作,通过把磁盘的数据缓存在物理内存中,把对磁盘的访问变为对物理内存的访问。
现代操作系统存在高速缓存的两个因素:访问磁盘的速度远远低于访问内存的速度。数据一旦被访问就很有可能在短期内被再次访问(临时局部性原理)。
缓存手段
页高速缓存是由内存中的物理页面构成,其内容对应磁盘上的物理块,高速缓存大小可以动态调整,可以通过占用空闲内存进行扩张,也可以自我收缩以减轻内存的使用压力。当内核开始一个读操作,会首先检查需要的数据是否在页高速缓存中,如果在内存中,则直接访问内存,如果不在则,内核必须调度块IO操作从磁盘读取数据,然后将数据放入缓存中,系统并不一定是缓存整个文件,有可能是缓存部分内容,具体缓存谁取决于谁被访问。
写缓存,通常缓存一般有三种策略:
1 不缓存(nowrite),高速缓存不去缓存任何写操作,直接跳过缓存写入磁盘,同时也使缓存的数据失效。
2 写操作自动更新内存缓存,同时也更新磁盘文件,也称为写通透,因为写操作会立刻穿透缓存到磁盘中,这对于保存缓存一致性有好处。
3 linux采用的回写策略,程序的写操作直接写入缓存中,但是不会立即和后端磁盘进行同步,而是将高速缓存中被写入数据的页面标记成dirty,并且将页面加入脏链表中,由一个后台进程定时把脏页面写入磁盘中。
缓存既然可以被分配,那肯定也会被回收了,linux的缓存回收是通过选择干净的页进行简单的替换,如果缓存中没有足够的干净页面,内核将强制的进行回写操作,以便腾出更多的干净页。而最难的是决定什么页应该被回收,回收算法有两种,如下:
最近最少使用算法(LRU),LRU回收策略跟踪每个页面的访问踪迹,以便能回收最老时间戳的页面,该策略的良好效果源自缓存的数据越久未被访问,则以后被访问的可能行越不大,但是对于很多很多文件被访问一次后不再访问,LRU则会效率底下。
双链策略,linux实际使用的是一个经过改进的LRU,称为双链策略,它维护两个链表:活跃链表和非活跃链表。活跃链表中的不会被换出,而非活跃链表中的会被换出。两个链表都被伪LRU规则维护:页面从尾部加入,从头部移除。并且两个链表需要维护平衡,如果活跃链表过多,则会把活跃链表表头的页面加入非活跃链表的尾部,以便能被回收。双链表解决的传统LRU算法中对仅一次的访问困境。这种链表也称为LRU/2,更多的是LRU/n,表示有n个链表。
linux页高速缓存
页高速缓存的是内存页面,缓存的页来自正规文件、块设备、内存映射文件等。在执行一个IO操作前,内核会检查数据是否已经在高速缓存中了。页高速缓存中的页可能包含了多个不连续的物理磁盘块,也正是由于页面中映射的磁盘块不一定连续,所以在页高速缓存中检测特定数据是否已被缓存就变得不那么容易了。linux页高速缓存的目标是缓存任何基于页的对象,包括各种类型的文件和各种内存映射。linux高速缓存使用address_space结构体管理缓存项和页IO操作。文件可以有多个虚拟地址,但是在物理内存中只能有一份。
struct address_space { struct inode *host; /* owning inode */ struct radix_tree_root page_tree; /* radix tree of all pages */ spinlock_t tree_lock; /* page_tree lock */ unsigned int i_mmap_writable; /* VM_SHARED ma count */ struct prio_tree_root i_mmap; /* list of all mappings */ struct list_head i_mmap_nonlinear; /* VM_NONLINEAR ma list */ spinlock_t i_mmap_lock; /* i_mmap lock */ atomic_t truncate_count; /* truncate re count */ unsigned long nrpages; /* total number of pages */ pgoff_t writeback_index; /* writeback start offset */ struct address_space_operations *a_ops; /* operations table */ unsigned long flags; /* gfp_mask and error flags */ struct backing_dev_info *backing_dev_info; /* read-ahead information */ spinlock_t private_lock; /* private lock */ struct list_head private_list; /* private list */ struct address_space *assoc_mapping; /* associated buffers */ };
i_mmap字段是一个优先搜索树,它的搜索范围包含了在address_sapce中私有的和共享的页面。
nrpages反应了address_space空间的大小,也就是页总数。address_space结构往往会和某些内核对象关联。通常情况下,会与一个索引节点(inode)关联,这时host域就会指向该索引节点。如果关联对象不是一个索引节点的话,比如address_space和swapper关联时,这是host域会被置为NULL。
a_ops域指向地址空间对象中的操作函数表,这与VFS对象及其操作函数表关系类似。由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 (*writepages) (struct address_space *, struct writeback_control *); int (*set_page_dirty) (struct page *); int (*readpages) (struct file *, struct address_space *,struct list_head *, unsigned); int (*prepare_write) (struct file *, struct page *, unsigned, unsigned); int (*commit_write) (struct file *, struct page *, unsigned, unsigned); sector_t (*bmap)(struct address_space *, sector_t); int (*invalidatepage) (struct page *, unsigned long); int (*releasepage) (struct page *, int); int (*direct_IO) (int, struct kiocb *, const struct iovec *,loff_t, unsigned long); };因为在任何页IO操作前内核都要检查页是否已经在页高速缓存中了,所以这种检查必须迅速,高效。否则得不偿失了。前边已经说过,也高速缓存通过两个参数address_space对象和一个偏移量进行搜索。每个address_space对象都有唯一的基树(radix-tree),它保存在page_tree结构体中。基树是一个二叉树,只要指定了文件偏移量,就可以在基树中迅速检索到希望的数据,页高速缓存的搜索函数find_get_page()要调用函数radix_tree_lookup(),该函数会在指定基树中搜索指定页面。
在linux2.6之前,内核是通过全局散列表进行检索的,对于一个给定的值,会返回一个双向链表的入口对应于这个所给定的值,但是散列表有四个问题:
1 使用全局保护锁,锁争用严重,导致性能受损
2 散列表包含所有的缓存页,但是搜索只要和当前文件相关联的页,所以散列表包含的页面比搜索需要的页面大很多
3 如果搜索失败,执行速度比希望的要慢很多,因为搜索需要遍历整个链表
4 散列表消耗更多的内存
缓冲区高速缓存
独立的磁盘块通过块IO缓冲也要被存入页高速缓存,一个缓冲是一个物理磁盘块在内存里的表示,缓冲的作用是映射内存中的页面到磁盘块,这样页高速缓存在块IO操作时也减少了磁盘访问,这个缓存通常称为缓冲区高速缓存,块IO操作一次操作一个单独的磁盘块,普遍的块IO操作是读写i节点,通过缓存,磁盘块映射到他们相关的页内存,并缓存到页高速缓存中。
flusher线程
在页高速缓存的影响下,写操作会被延迟,而内存中的脏数据最终必须被写入磁盘,以下情况发生时脏数据会被写入磁盘:
1 当空闲内存低于一个特定的阈值时,内核将脏页面写回磁盘以便释放内存。
2 当脏页面在内存中驻留的时间超过一个特定的阈值时,内核必须将脏页面写回磁盘。
3 用户进程调用sync()和fsync()系统调用时,内核会执行写回操作。
在2.6内核中由一群内核线程执行这三种工作,首先flusher在系统空闲内存低于一个特定阈值时会将脏页面写回磁盘,这个特定的内存阈值可以通过dirty_background_ratio_sysctl系统调用设置。当内存空闲阈值比dirty_background_ratio还低时,内核调用flusher_threads()唤醒多个flusher线程,flusher线程调用bdi_writeback_all()将脏页面写回磁盘。为了达到第二个目标,flusher后台程序会被周期性的唤醒,将内存中驻留时间过长的脏页面写回磁盘,在系统启动时,内核会初始化一个定时器,让他周期性的唤醒flusher线程,随后使其运行函数wb_writeback(),把所有驻留时间超过dirty_expire_interval的脏页面写回磁盘,然后定时器再次被初始化为dirty_expire_interval秒后唤醒flusher线程。
在2.6内核之前,flusher的工作是由bdflush和kupdated两个线程共同完成,bdflush内核线程在后台执行脏页面回写操作,因为只有在内存过低或者缓冲数量过大时bdflush才刷新缓冲,所以需要kupdated线程周期性的回写脏数据,bdflush和flusher的区别是:系统中只有一个bdflush线程,而flusher线程的数量是根据磁盘数量变化的,bdflush基于缓冲,将脏缓冲写入磁盘,flusher基于页面,它将脏页面写回磁盘,实际操作对象是页面而不是块,管理简单。但是2.6内核中bdflush和kupdated被pdflush替代了,pdflush线程数目是动态的,具体多少取决于系统的IO负载,pdflush与任何任务都无关,它是面向系统中的所有磁盘的全局任务,但是这样带来的问题是pdflush线程容易在拥塞的磁盘上绊住,flusher线程在2.6.32内核中取代了pdflush线程,针对每个磁盘独立的执行回写操作是其特性。
避免拥塞-使用多个线程
bdflush最主要的一个缺点是只有一个线程,在回写任务很重时容易阻塞在某个设备的已拥塞请求队列中,而导致其他请求队列的任务无法处理。pdflush线程是动态变化的,每个线程尽可能的从每个超级块的脏页面链表中回收数据,pdflush避免了因为一个磁盘忙而导致其余磁盘饥饿的情况,在常规情况下这种策略效果不错,但是假如每个pdflush线程在同一个拥塞队列上挂起了怎么办?这时pdflush采用拥塞回避策略,主动尝试从那些没有拥塞的队列回写,从而防止欺负某一个忙碌的设备。flusher线程则是和具体块设备相关联,每个给定的线程从每个给定设备的脏页面链表回收数据,并回写到对应的磁盘,由于每个磁盘对应一个线程,所以不需要复杂的拥塞避免策略,降低了磁盘饥饿的风险。