【读书笔记】Linux内核设计与实现--页高速缓存和页回写

页高速缓存(cache)是Linux内核实现磁盘缓存。主要用来减少对磁盘的 I/O 操作。具体的讲是通过把磁盘中的数据缓存到物理内存中,把对磁盘的访问变为对物理内存的访问

磁盘高速缓存之所以在任何现代操作系统中尤为重要有如下原因:

  1. 访问磁盘的速度要远远低于访问内存的速度–ms和ns的差距;
  2. 数据一旦被访问,就很有可能在短期内再次被访问到(临时局部原理(temporal locality))。

1.缓存手段

页高速缓存是由内存中的物理页组成的,其内容对应磁盘上的物理块

Q:何为缓存命中?
A:当内核开始一个读操作(read系统调用),它首先会检查需要的数据是否在页高速缓存中,如果在,则放弃访问磁盘,而直接从内存中读取–该行为称为缓存命中(即直接从内存中读取数据)。

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

1.1 写缓存

缓存实现策略 说明
不缓存(nowrite) 高速缓存不去缓存任何写操作,直接跳过缓存,写磁盘,同时也使缓存中的数据失效,后续读操作进行也需重新从磁盘中读取数据
写透缓存(write-through cache) 写操作将自动更新内存缓存,同时也更新磁盘文件。写操作会立刻穿透缓存到磁盘中。缓存数据时刻和后备存储保持同步,不需要让缓存失效
回写-Linux方式 写操作直接写到缓存中,后端存储不会立刻直接更新,而是将页高速缓存中被写入的页面标记成”脏“,并且被加入到脏页链表中;然后由一个进程(回写进程)周期性将脏页链表中的页写回到次哦,从而让磁盘中的数据和内存中最终一致。最后清理”脏“标识

ps:
Linux策略方式(回写)中的"脏页"理解成磁盘中的数据已经过时了,并非是页高速缓存中的数据是脏的,可以理解成高速缓存中的数据和磁盘中的数据未同步。
回写策略通常认为要好于写透策略,因为通过延迟写磁盘,方便在以后的时间内合并更多的数据和再一次刷新。

1.2 缓存回收–缓存回收策略

缓存回收策略就是决定缓存中什么内容将被清除的策略。
Linux的缓存回收是通过选择干净页(不脏)进行简单替换。
如果缓冲中没有足够的干净页面,内核将强制地进行回写操作,以腾出更多的干净可用页。

ps:
最难的事情在于决定什么页应该回收。

理想的回收策略应该是回收那些以后最不可能使用的页面,当然要知道以后的事情你必须是先知,因此,这样理想的回收策略被称为预测算法。(太理想,无法真正实现)

1.最近最少使用–LRU算法
缓存回收策略通过所访问的数据特性,尽力追求预测效率
最成功的的算法称为最近最少使用算法–LRU算法
LRU回收策略需要跟踪每个页面的访问踪迹(或者至少按照访问时间为序的页链表),以便能回收最老时间戳的页面(或者回收排序链表头所指的页面)。
该策略的良好效果来源于缓存的数据越久未被访问,则越不大可能近期再被访问,而最近被访问的最优可能再次访问

ps:但是,LRU策略并非是放之四海而皆准的法则,对于许多文件被访问一次,再不被访问的情景,LRU尤其失败。将这些页面放在LRU链的顶端显然不是最优,当然,内核并没有办法知道一个文件只会被访问一次,但是能够知道过去访问了多少次。

2.双链策略–修改过的LRU,LUR/2->LUR/n
Linux实现的是一个修改过的LRU,维护两个链表:活跃链表非活跃链表
处于活跃链表上的页面被认为是“热”的且不会被换出,而在非活跃链表上的页面则是可以被换出的。
两个链表都被伪LRU规则维护:页面从尾部加入,从头部移除,如同队列。
双链表策略解决了传统LRU算法中对仅一次访问的窘境。

PS:现在可知道页缓存如何构建(通过读和写),如何在写时被同步(通过回写)以及旧数据如何被回收来容纳新数据(通过双链表)。

2.Linux页高速缓存

页高速缓存缓存的是内存页面
缓存中的页来自对正规文件、块设备文件和内存映射文件的读写。如此一来,页高速缓存就包含了最近被访问过的文件的数据块。

在执行一个 I/O 操作前(如read),内核会检查数据是否已经在页高速缓存中了,如果所需要的数据确实在高速缓存中,那么内核可以从内存中迅速的返回需要的页,而不再需要从相对较慢的磁盘上读取数据。

2.1 address_space对象

在页高速缓存中的页可能包含了多个不连续的物理磁盘块。
Linux页高速缓存的目标缓存任何基于页的对象,包含各种类型的文件和各种类型的内存映射。

Linux页高速缓存使用了一个新对象(address_space结构体)管理缓存项和页 I/O 操作。
address_space结构体是虚拟地址 vm_area_struct 的物理地址对等体。即:
当一个文件可以被10个vm_area_struct结构体标识(如有5个进程,每个调用mmap()映射它两次),那么这个文件只能有一个address_space数据结构–也就是文件可以有多个虚拟地址,但只能在物理内存有一份。
address_space结构定义在文件<linux/fs.h>中,形式如下:

struct address_space {
	struct inode		*host;		/* owner: inode, block_device 拥有节点 */
	struct radix_tree_root	page_tree;	/* radix tree of all pages 包含全部页面的radix树 */
	spinlock_t		tree_lock;	/* and lock protecting it 保护page_tree的自旋锁 */
	unsigned int		i_mmap_writable;/* count VM_SHARED mappings VM_SHARED 计数 */
	struct rb_root		i_mmap;		/* tree of private and shared mappings 私有映射链表 */
	struct list_head	i_mmap_nonlinear;/*list VM_NONLINEAR mappings VM_NONLINEAR 链表 */
	struct mutex		i_mmap_mutex;	/* protect tree, count, list */
	/* Protected by tree_lock together with the radix tree */
	unsigned long		nrpages;	/* number of total pages 页总数 */
	pgoff_t			writeback_index;/* writeback starts here 回写的起始偏移 */
	const struct address_space_operations *a_ops;	/* methods 操作表 */
	unsigned long		flags;		/* error bits/gfp mask gfp_mask 掩码与错误标识 */
	struct backing_dev_info *backing_dev_info; /* device readahead, etc 预读信息 */
	spinlock_t		private_lock;	/* for use by the address_space 私有address_space锁 */
	struct list_head	private_list;	/* ditto 私有address_space 链表 */
	void			*private_data;	/* ditto */
} __attribute__((aligned(sizeof(long))));

i_mmap 字段是一个优先搜索树(一种巧妙地将堆与radix树结合的快速检索树)。它的搜索范围包含了在address_space中所有共享的与私有的映射页面。
i_mmap字段可帮助内核高效地找到关联的被缓存文件–因为一个被缓存的文件只和一个address_space结构体相关联,但它可以有多个vm_area_struct结构体,即一物理页到虚拟页是个一对多的映射;
nrpages 字段描述 address_space 页总数;
host 域:在address_space结构往往会和某些内核对象关联。通常与一个索引节点(inode)关联,host域就会指向该索引节点;如果关联对象不是一个inode,则host域会被设置为NULL。

2.2 address_space操作–address_space_operations操作表

a_ops 域指向地址空间对象中的操作函数表。定义在文件<linux/fs.h>中,由address_space_operations结构体来表示:

struct address_space_operations {
	int (*writepage)(struct page *page, struct writeback_control *wbc);
	int (*readpage)(struct file *, struct page *);

	/* Write back some dirty pages from this mapping. */
	int (*writepages)(struct address_space *, struct writeback_control *);

	/* Set a page dirty.  Return true if this dirtied it */
	int (*set_page_dirty)(struct page *page);

	int (*readpages)(struct file *filp, struct address_space *mapping,
			struct list_head *pages, unsigned nr_pages);

	int (*write_begin)(struct file *, struct address_space *mapping,
				loff_t pos, unsigned len, unsigned flags,
				struct page **pagep, void **fsdata);
	int (*write_end)(struct file *, struct address_space *mapping,
				loff_t pos, unsigned len, unsigned copied,
				struct page *page, void *fsdata);

	/* Unfortunately this kludge is needed for FIBMAP. Don't use it */
	sector_t (*bmap)(struct address_space *, sector_t);
	void (*invalidatepage) (struct page *, unsigned long);
	int (*releasepage) (struct page *, gfp_t);
	void (*freepage)(struct page *);
	ssize_t (*direct_IO)(int, struct kiocb *, const struct iovec *iov,
			loff_t offset, unsigned long nr_segs);
	int (*get_xip_mem)(struct address_space *, pgoff_t, int,
						void **, unsigned long *);
	/*
	 * migrate the contents of a page to the specified target. If sync
	 * is false, it must not block.
	 */
	int (*migratepage) (struct address_space *,
			struct page *, struct page *, enum migrate_mode);
	int (*launder_page) (struct page *);
	int (*is_partially_uptodate) (struct page *, read_descriptor_t *,
					unsigned long);
	void (*is_dirty_writeback) (struct page *, bool *, bool *);
	int (*error_remove_page)(struct address_space *, struct page *);

	/* swapfile support */
	int (*swap_activate)(struct swap_info_struct *sis, struct file *file,
				sector_t *span);
	void (*swap_deactivate)(struct file *file);
};

这些方法指针指向那些指定缓存对象实现的页 I/O 操作。
操作表readpage()–读页到缓存和writepage()–更新缓存数据两个方法最为重要。

Q:一个页面的读操作所包含的步骤?
A:

  1. linux 内核试图在页高速缓存中找到需要的数据:find_get_page()方法负责完成这个检查动作。
  2. 如果搜索的页并没在高速缓存中,find_get_page()将会返回一个NULL,并且内核将分配一个新页面,然后将之前搜索的页加入到页高速缓存中;
  3. 需要的数据从磁盘被读入,再被加入页高速缓存,然后返回给用户。

Q:通常写操作路径要包含的步骤?
A:
写操作和读操作有少许不同,对于文件映射来说,当页被修改了,VM仅仅需要调用:

SetPageDirty(page);

内核会在晚些时候通过writepage()方法把页写出。对特定文件的些操作比较复杂,它的代码在文件mm/filemap.c中,通常写操作路径要包含以下步骤:

page = __grab_cache_page(mapping,index,&cached_page,&lru_pvec);
status = a_ops->prepare_write(file,page,offset,offset+bytes);
page_fault = filemap_copy_from_user(page,offset,buf,bytes);
status = a_ops->commit_write(file,page,offset,offset+bytes);

首先,在页高速缓存中搜索需要的页。如果需要的页不在高速缓存中,那么内核在高速缓存中新分配一空闲项;下一步,内核创建一个写请求;接着数据被从用户空间拷贝到了内核缓冲;最后将数据写入磁盘。

ps:
因为所有的页 I/O 操作都要执行以上步骤,保证了所有的页 I/O 操作必然通过页高速缓存进行的。
因此内核总是试图先通过页高速缓存来满足所有的读请求。如果在页高速缓存中未搜索到需要的页,则内核将从磁盘读入需要的页,然后将该页加入到页高速缓存中;
对于写操作,页高速缓存更像是一个存储平台,所有要被写出的页都要加入页高速缓存中。

2.3 基树–保证了频繁的检查会迅速和高效

任何页 I/O 操作前内核都要检查页是否已经在页高速缓冲中情况下,基树的使用保证了这种频繁进行的检查迅速、高效。否则搜索和检查页高速缓存的开销可能抵消页高速缓存带来的好处。

基树保存在page_tree结构体中,它是一个二叉树,只要指定了文件偏移量就可以在基树中迅速检索到希望的页。

基树核心代码的通用形式可以在文件lib/radix-tree.c中找到,使用基树,需要包含头文件<linux/radix_tree.h>。

2.4 以前的页散列表–新版本引入了基于基树的页高速缓存来避免相应弊端

被丢弃的页散列表,需要了解的可参考本书16.2.4章节。

3.缓冲区高速缓存

独立的磁盘块通过块 I/O 缓冲也要被存入页高速缓存。
一个缓冲是一个物理磁盘块在内存里的表示。
缓冲的作用就是映射内存中页面到磁盘块,这样一来页高速缓存在块 I/O 操作时也减少了磁盘访问,因为它缓存磁盘块和减少块 I/O 操作。 该缓存通常被称为缓冲区高速缓存。(虽然实现上它没有作为独立缓存,而是作为页高速缓存的一部分)

4.flusher线程

由于页高速缓存的缓存作用,写操作实际上会被延迟。
当页高速缓存中数据比后台存储的数据更新时,该数据被称作脏数据
在内存中累积起来的脏页最终必须被写回磁盘。

实现将数据写回磁盘的角色就是flusher线程。

Q:脏页何时被写回磁盘?
A:

  1. 当空闲内存低于一个特定的阈值时,内核必须将脏页写回磁盘以便释放内存,因为只有干净(不脏的)内存才可以被回收。当内存干净后,内核就可以从缓存清理数据,然后收缩缓存,最终释放出更多的内存;
  2. 当脏页在内存中驻留时间超过一个特定的阈值时,内核必须将超时的脏页写回磁盘,以确保脏页不会无限期地驻留在内存中;
  3. 当用户进程调用sync()和fsync()系统调用时,内核会按要求执行回写动作。

Q:数据写回磁盘的时候,什么时候停止?
A:

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

ps:回写操作不会再达到这两个条件前停止,除非刷新者线程写回了所有的脏页,没有剩下的脏页可再被写回了。

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

系统管理员可以在/proc/sys/vm 中设置回写相关的参数,也可以通过sysctl系统调用设置。
可参考本书16.4章节或者此博客:Linux 内核参数说明

4.1 膝上型计算机模式

膝上型计算机模式是一种特殊的页回写策略,该策略的主要意图是将硬盘转动的机械行为最小化,允许硬盘尽可能长时间的停滞,以此延长电池供电时间。

该模式可通过/proc/sys/vm/laptop_mode文件进行配置。

该策略模式除了当缓存中的页面太旧时要执行回写脏页以外,flusher还会找准磁盘运转的时机,把所有其他的物理磁盘 I/O 、刷新脏缓存等通过协会到磁盘,以保证不会专门为了写磁盘而去主动激活磁盘运行。

因为关闭磁盘驱动器是节电的重要手段,膝上模式可以延长膝上计算机依靠电池的续航能力。其坏处则是系统崩溃或者其他错误会使数据丢失。

4.2 历史上的bdflush、kupdated和pdflush

在2.6版本前,flusher线程的工作分别由bdflush和kupdated两个线程共同完成。
当可用内存过低时,bdflush 内核线程在后台执行脏页回写操作。
系统中只有一个bdflush后台线程(flusher线程的数目是根据磁盘数量变化的)。
bdflush线程基于缓冲,它将脏缓冲写回磁盘(flusher线程基于页面,它将整个脏页写回磁盘)。

ps:页面可能包含缓冲,但实际 I/O 操作对象是整页,而不是块(缓冲却是块)。

在2.6内核中,bdflush 和 kupdated 让路给 pdflush(page dirty flush)。
pdflush 线程的执行和如今的flusher线程类似。
区别在于pdflush线程数目是动态的,默认是2个到8个,具体多少取决于系统 I/O 的负载。

在线程2.6.32 内核系列中flusher线程取代了pdflush线程,因为flusher线程针对每个磁盘独立执行回写操作。

4.3 避免拥塞的方法:使用多线程

使用bdflush线程最主要的一个缺点就是:bdflush仅仅包含一个线程,因此很有可能在页回写任务很重时,造成拥塞。

为了避免出现这种情况,内核需要多个回写线程并发执行,这样单个设备队列的拥塞就不会成为系统瓶颈了。

pdflush线程策略中,线程数是动态变化的。每一个线程试图尽可能地从每个超级块的脏页链表中回收数据,并回写到磁盘,但是如果每个pdflush线程在同一个拥塞的队列上挂起,这样多个pdflush线程可能并不比一个线程更好,因此,pdflush线程采取了拥塞回避策略:它们会主动尝试从那些没有拥塞的队列回写页。从而,pdflsuh线程将其工作调度开来,防止仅仅欺负某一个忙碌设备。

当前flusher线程模型(自2.3.32内核系列以后采用)和具体块设备关联,所以每个给定线程从每个给定设备的脏页链表收集数据,并写回到对应磁盘,这样回写更趋于同步了,而且由于每个磁盘对应一个线程,所以线程也不需要采用复杂的拥塞避免策略,因为一个磁盘就一个线程操作

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章