正如我们所了解的,内核不断用包含块设备数据的页填充页高速缓存。只要进程修改了数据,相应的页就被标记为脏页,即把它的PG_dirty标志置位。
Unix系统允许把脏缓冲区写入块设备的操作延迟执行,因为这种策略可以显著地提高系统的性能。对高速缓存中的页的几次写操作可能只需对相应的磁盘块进行一次缓慢的物理更新就可以满足。此外,写操作没有读操作那么紧迫,因为进程通常是不会由于延迟写而挂起,而大部分情况都因为延迟读而挂起。正是由于延迟写,使得任一物理块设备平均为读请求提供的服务将多于写请求。
一个脏页可能直到最后一刻(即直到系统关闭时)都一直逗留在主存中。然而,从延迟写策略的局限性来看,它有两个主要的缺点:
(1)如果发生了硬件错误或电源掉电的情况,那么就无法再获得RAM的内容,因此从系统启动以来对文件进行的很多修改就丢失了。
(2)页高速缓存的大小(由此存放它所需的RAM的大小)就可能要很大——至少要与所访问块设备的大小相同。
因此,在下列条件下把脏页刷新(写入)到磁盘:
(1)页高速缓存变得太满,但还需要更多的页,或者脏页的数量已经太多。
(2)自从页变成脏页以来已过去太长时间。
(3)进程请求对块设备或者特定文件任何待定的变化都进行刷新。通过调用sync()、fsync()或fdatasync()系统调用来实现。
缓冲区页的引入使问题更加复杂。与每个缓冲区页相关的缓冲区首部使内核能够了解每个独立块缓冲区的状态。如果至少有一个缓冲区首部的BH_Dirty标志被置位,就应该设置相应缓冲区页的PG_dirty标志。当内核选择要刷新的缓冲区页时,它扫描相应的缓冲区首部,并只把脏块的内容有效地写到磁盘。一旦内核把缓冲区的所有脏页刷新到磁盘,就把页的PG_dirty标记清0。
早期版本的Linux使用bdflush内核线程系统地扫描页高速缓存以搜索要刷新的脏页,并且使用另一个内核线程kupdate来保证所有的页不会“脏”太长的时间。Linux 2.6用一组通用内核线程pdflush代替上述两个线程。
这些内核线程结构灵活,它们作用于两个参数:一个指向线程要执行的函数的指针和一个函数要用的参数。系统中pdflush内核线程的数量是要动态调整的:pdflush线程太少时就创建,太多时就杀死。因为这些内核线程所执行的函数可以阻塞,所以创建多个而不是一个pdflush内核线程可以改善系统性能。
根据下面的原则控制pdflush线程的产生和消亡:
- 必须有至少两个,最多八个pdflush内核线程。
- 如果到最近的1s期间没有空闲pdflush,就应该创建新的pdflush。
- 如果最近一次pdflush变为空闲的时间超过了1s,就应该删除一个pdflush。
所有的pdflush内核线程都有pdflush_work描述符。空闲pdflush内核线程的描述符都集中在pdflush_list链表中;在多处理器系统中,pdflush_lock自旋锁保护该链表不会被并发访问。nr_pdflush_threads变量(可以从文件/proc/sys/vm/nr_pdflush_threads中读出这个变童的值)存放pdflush内核线程(空闲的或忙的)的总数。最后,last_empty_jifs变量存放pdflush线程的pdflush_list链表变为空的时间(以jiffies表示)。
类型 |
字段 |
说明 |
struct task_struct * |
who |
指向内核线程描述符的指针 |
void(*)(unsigned long) |
fn |
内核线程所执行的回调函数 |
unsigned long |
arg0 |
给回调函数的参数 |
struct list head |
list |
pdflush_list链表的链接 |
unsigned long |
when_i_went_to_sleep |
当内核线程可用时的时间(以jiffies表示) |
所有pdflush内核线程都执行函数__pdflush,它本质上循环执行一直到内核线程死亡。我们不妨假设pdflush内核线程是空闲的,而进程正在TASK_INTERRUPTIBLE状态睡眠。一但内核线程被唤醒,__pdflush()就访问其pdflush_work描述符,并执行字段fn中的回调函数,把arg0字段中的参数传递给该函数。当回调函数结束时__pdflush()检查last_empty_jifs变量的值:如果不存在空闲pdflush内核线程的时间已经超过1s,而且pdflush内核线程的数量不到8个,函数__pdflush()就创建另外一个内核线程。相反,如果pdflush_list链表中的最后一项对应的pdflush内核线程空闲时间超过了1s,而且系统中有两个以上的pdflush内核线程,函数__pdflush()就终止:就像在“内核线程”博文中所描述的,相应的内核线程执行_exit()系统调用,并因此而被撤消。否则,如果系统中pdflush内核线程不多于两个,__pdflush()就把内核线程的pdflush_work描述符重新插入到pdflush_list链表中,并使内核线程睡眠。
pdflush_operation()函数用来激活空闲的pdflush内核线程。该函数作用于两个参参数:一个指针fn,指向必须执行的函数;以及参数argO。函数执行下面的步骤:
1. 从pdflush_list链表中获取pdf指针,它指向空闲pdflush内核线程的pdflush_work描述符。如果链表为空,就返回-1。如果链表中仅剩一个元素,就把jiffies的值赋给变量last_empty_jifs。
2. 把参数fn和arg0分别赋给pdf->fn和pdf->arg0。
3. 调用wake_up_process()唤醒空闲的pdflush内核线程,即pdf->who。
把哪些工作委托给Pdflush内核线程来完成呢?其中一些工作与脏数据的刷新相关。尤其是,pdflush通常执行下面的回调函数之一:
- background_writeout():系统地扫描页高速缓存以搜索要刷新的脏页(参见下节“搜索要刷新的脏页”)。
- wb_kupdate():检查页高速缓存中是否有“脏”了很长时间的页(参见稍后“回写陈旧的脏页”)。
所有的基树都可能有要刷新的脏页。为了得到所有这些页,就要彻底搜索与在磁盘上有映像的索引节点相应的所有address_space对象。由于页高速缓存可能有大量的页,如果用一个单独的执行流来扫描整个高速缓存,会令CPU和磁盘长时间繁忙。因此,Linux使用一种复杂的机制把对页高速缓存的扫描划分为几个执行流。
wakeup_bdflush()函数接收页高速缓存中应该刷新的脏页数量作为参数;0值表示高速缓存中的所有脏页都应该写回磁盘。该函数调用pdflush_operation()唤醒pdflush内核线程(参见上一节),并委托它执行回调函数background_writeout(),后者有效地从页高速缓存获得指定数量的脏页,并把它们写回磁盘。
当内存不足或用户显式地请求刷新操作时执行wakeup_bdflush()函数。特别是在下述情况下会调用该函数:
- 用户态进程发出sync()系统调用
- grow_buffers()函数分配一个新缓冲区页时失败
- 页框回收算法调用free_more_memory()或try_to_free_pages()
- menmpool_alloc()函数分配一个新的内存池元素时失败
此外,执行background_writeout()回调函数的pdflush内核线程是由满足以下两个条件的进程唤醒的:一是对页高速缓存中的页内容进行了修改,二是引起脏页部分增加到超过某个脏背景阈值 (background threshold)。背景阈值通常设置为系统中所有页的10%,不过可以通过修改文件/proc/sys/vm/dirty_background_ratio来调整这个值。
background_writeout()函数依赖于作为双向通信设备的writeback_control结构:一方面,它告诉辅助函数writeback_modes()要做什么;另一方面,它保存写回磁盘的页的数量的统计值。下面是这个结构最重要的字段:
sync_mode:表示同步模式:WB_SYNC_ALL表示如果遇到一个上锁的索引节点,必须等待而不能略过它;WB_SYNC_HOLD表示把上锁的索引节点放入稍后涉及的链表中;WB_SYNC_NONE表示简单地略过上锁的索引节点。
bdi:如果不为空,就指向backing_dev_info结构。此时,只有属于基本块设备的脏页将会被刷新。
older_than_this:如果不为空,就表示应该略过比指定值还新的索引节点。
nr_to_write:当前执行流中仍然要写的脏页的数量。
nonblocking:如果这个标志被置位,就不能阻塞进程。
background_writeout()函数只作用于一个参数nr_pages,表示应该刷新到磁盘的最少页数。它本质上执行下述步骤:
1. 从每CPU变量page_state中读当前页高速缓存中页和脏页的数量。如果脏页所占的比例低于给定的阈值,而且已经至少有nr_pages页被刷新到磁盘,该函数就终止。这个阈值通常大约是系统中总页数的40%,可以通过写文件/proc/sys/vm/dirty_ratio来调整这个值。
2. 调用writeback_inodes()尝试写1024个脏页(见下面)。
3. 检查有效写过的页的数量,并减少需要写的页的个数。
4. 如果已经写过的页少于1024页,或略过了一些页,则可能块设备的请求队列处于拥塞状态:此时,background_writeout()函数使当前进程在特定的等待队列上睡眠100ms,或使当前进程睡眠到队列变得不拥塞。
5. 返回到第1步。
writeback_inodes()函数只作用于一个参数,就是指针wbc,它指向writeback_control描述符。该描述符的nr_to_write字段存有要刷新到磁盘的页数。函数返回时,该字段存有要刷新到磁盘的剩余页数,如果一切顺利,则该字段的值被赋为0。
我们假设writeback_inodes()函数被调用的条件为:指针wbc->bdi和wbc->older_than_this被置为NULL,WB_SYNC_NONE同步模式和wbc->nonblocking标志置位(这些值都由background_writeout()函数设置)。函数writeback_inodes()扫描在super_blocks变量中建立的超级块链表。当遍历完整个链表或刷新的页数达到预期数量时,就停止扫描。对每个超级块sb,函数执行下述步骤:
1. 检查sb->s_dirty或sb->s_io链表是否为空:第一个链表集中了超级块的脏索引节点,而第二个链表集中了等待被传输到磁盘的索引节点(见下面)。如果两个链表都为空,说明相应文件系统的索引节点没有脏页,因此函数处理链表中的下一个超级块。
2. 此时,超级块有脏索引节点。对超级块sb调用sync_sb_inodes(),该函数执行下面的操作:
a) 把sb->s_dirty的所有索引节点插入sb->s_io指向的链表,并清空脏索引节点链表。
b) 从sb->s_io获得下一个索引节点的指针。如果该链表为空,就返回。
c) 如果sync_sb_inodes()函数开始执行后,索引节点变为脏节点,就略过这个索引节点的脏页并返回。注意,sb->s_io链表中可能残留一些脏索引节点。
d) 如果当前进程是pdflush内核线程,sync_sb_inodes()就检查运行在另一个CPU上的pdflush内核线程是否已经试图刷新这个块设备文件的脏页。这是通过一个原子测试和对索引节点的backing_dev_info的BDI_pdflush标志的设置操作来完成的。本质上,它对同一个请求队列上有多个pdflush内核线程是毫无意义的。
e) 把索引节点的引用计数器加1。
f) 调用__writeback_single_inode()回写与所选择的索引节点相关的脏缓冲区:
i. 如果索引节点被锁定,就把它移到脏索引节点链表中(inode->i_sb->s_dirty)并返回0。(因为我们假定wbc->sync_mode字段不等于WB_SYNC_ALL,所以函数不会因为等待索引结点解锁而阻塞。)
ii. 使用索引节点地址空间的writepages方法,或者在没有这个方法的情况下使用mpage_writepages()函数来写wbc->nr_to_write个脏页。该函数调用find_get_pages_tag()函数快速获得索引节点地址空间的所有脏页(参见本章前面“基树的标记”一节),细节将在下一章描述。
iii. 如果索引节点是脏的,就用超级块的write_inode方法把索引节点写到磁盘。实现该方法的函数通常依靠submit_bh()来传输一个数据块。
iv. 检查索引节点的状态。如果索引节点还有脏页,就把索引节点移回sb->s_dirty链表,如果索引节点引用计数器为0,就把索引节点移到mode_unused链表中;否则就把索引节点移到inode_in_use链表中。
v. 返回在第2f(ii)步所调用的函数的错误代码。
g) 回到sync_sb_modes()函数中。如果当前进程是pdflush内核线程,就把在第2d步设置的BDI_pdflush标志清0。
h) 如果略过了刚处理的索引节点中的一些页,那么该索引节点包括锁定的缓冲区:把sb->s_io链表中的所有剩余索引节点移回到sb->s_dirty链表中,以后讲重新处理它们。
i) 把索引节点的引用计数器减1。
j) 如果wbc->nr_to_write大于0,则回到第2b步搜索同一个超级块的其他脏索引节点。否则,sync_sb_inodes()函数终止。
3. 回到writeback_inodes()函数中。如果wbc->nr_to_write大于0,就跳转到1步,并继续处理全局链表中的下一个超级块。否则,就返回。
如前所述,内核试图避免当一些页很久没有被刷新时发生饥饿危险。因此,脏页在保留一定时间后,内核就显式地开始进行1/O数据的传输,把脏页的内容写到磁盘。
回写陈旧脏页的工作委托给了被定期唤醒的pdflush内核线程。在内核初始化期间page_writeback_init()函数建立wb_timer动态定时器,以便定时器的到期时间发生在dirty_writeback_centisecs文件中所规定的几百分之一秒之后(通常是500分之一秒,不过可以通过修改/proc/sys/vm/dirty_writeback_centisecs文件调整这个值)。定时器函数wb_timer_fn()本质上调用pdflush_operation()函数,传递给它的参数是回调函数wb_kupdate()的地址。
wb_kupdate()函数遍历页高速缓存搜索陈旧的脏索引节点,它执行下面的步骤:
1. 调用sync_supers()函数把脏的超级块写到磁盘中(参见下一节)。虽然这与页高速缓存中的页刷新没有很密切的关系,但对sync_supers()的调用确保了任何超级块脏的时间通常不会超过5s。
2. 把当前时间减30s所对应的值(用jiffies表示)的指针存放在writeback_control描述符的older_than_this字段中。允许一个页保持脏状态的最长时间是30s。
3. 根据每CPU变量page_state确定当前在页高速缓存中脏页的大概数量。
4. 反复调用writeback_inodes(),直到写入磁盘的页数等于上一步所确定的值,或直到把所有保持脏状态时间超过30s的页都写到磁盘。如果在循环的过程中一些请求队列变得拥塞,函数就可能去睡眠。
5. 用mod_timer()重新启动wb_timer动态定时器:一旦从调用该函数开始经历过文件dirty_writeback_centisecs中规定的几百分之一秒时间后,定时器到期(或者如果本次执行的时间太长,就从现在开始1s后到期)。
我们下面来简要介绍用户应用程序把脏缓冲区刷新到磁盘会用到的三个系统调用:
sync()
允许进程把所有的脏缓冲区刷新到磁盘。
fsync()
允许进程把属于特定打开文件的所有块刷新到磁盘。
fdatasync()
与fsync()非常相似,但不刷新文件的索引节点块。
4.1 sync()系统调用
sync()系统调用的服务例程sys_sync()调用一系列辅助函数:
wakeup_bdflush(0);
sync_inodes(0);
sync_supers();
sync_filesystems(0);
sync_filesystems(1);
sync_inodes(1);
正如上一节所描述的,wakeup_bdflush()启动pdflush内核线程,把页高速缓存中的所有脏页刷新到磁盘。
sync_inodes()函数扫描超级块的链表以搜索要刷新的脏索引节点;它作用于参数wait,该参数表示在执行完刷新之前函数是否必须等待。函数扫描当前已安装的所有文件系统的超级块;对于每个包含脏索引节点的超级块,sync_inodes()首先调用sync_sb_inodes()刷新相应的脏页,然后调用sync_blockdev()显式刷新该超级块所在块设备的脏缓冲区页。这一步之所以能完成是因为许多磁盘文件系统的write_inode超级块方法仅仅把磁盘索引节点对应的块缓冲区标记为“脏”;函数sync_blockdev()确保把sync_sb_inodes()所完成的更新有效地写到磁盘。
函数sync_supers()把脏超级块写到磁盘,如果需要,也可以使用适当的write_super超级块操作。最后,sync_filesystems()为所有可写的文件系统执行sync_fs超级块方法。该方法只不过是提供给文件系统的一个“钩子”,在需要对每个同步执行一些特殊操作时使用,只有像Ext3这样的日志文件系统使用这个方法。
注意,sync_inodes()和sync_filesystems()都是被调用两次,一次是参数wait等于0时,另一次是wait等于1时。这样做的目的是:首先,它们把未上锁的索引节点快速刷新到磁盘;其次,它们等待所有上锁的索引节点被解锁,然后把它们逐个地写到磁盘。
4.2 fsync()和fdatasync()系统调用
系统调用fsync()强制内核把文件描述符参数fd所指定文件的所有脏缓冲区写到磁盘中(如果需要,还包括存有索引节点的缓冲区)。相应的服务例程获得文件对象的地址,并随后调用fsync方法。通常这个方法以调用函数__writeback_single_inode()结束,该函数把与被选中的索引节点相关的脏页和索引节点本身都写回磁盘
系统调用fdatasync()与fsync()非常相似,但是它只把包含文件数据而不是那些包含索引节点信息的缓冲区写到磁盘。由于Linux 2.6没有提供专门的fdatasync()文件方法,该系统调用使用fsync方法,因此与fsync()是相同的。