Linux内核设计与实现(十)| 页高速缓存和页回写

文章目录

  • 页高速缓存和页回写
    • 1.缓存手段
      • 1.1 写缓存
      • 1.2 缓存回收
    • 2.Linux页高速缓存
      • 2.1 address_space对象
      • 2.2 address_space操作
      • 2.3 基树
      • 2.4 以前的页散列表
    • 3.缓冲区高速缓存
    • 4.flusher线程
      • 4.1 膝上型计算机模式
      • 4.2 历史上的bdflush、kupdated和pdflush
      • 4.3 避免拥塞的方法:使用多线程

页高速缓存和页回写

  • 页高速缓存

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

  • 页回写

将页高速缓存中的变更数据刷新回磁盘的操作

  • 为什么有缓存

磁盘高速缓存之所以在任何现代操作系统中尤为重要源自两个因素:

  1. 第一,访问磁盘的速度要远远低于(差好几个数量级)访问内存的速度——ms和ns 的差距,因此,从内存访问数据比从磁盘访问速度更快,若从处理器的L1和L2高速缓存访问则更快。
  2. 第二,数据一旦被访问,就很有可能在短期内再次被访问到。这种在短时期内集中访问同一片数据的原理称作临时局部原理(temporal locality)。

临时局部原理能保证:

如果在第一次访问数据时缓存它,那就极有可能在短期内再次被高速缓存命中(访问到高速缓存中的数据)。正是由于内存访问要比磁盘访问快得多,再加上数据一次被访问后更可能再次被访问的特点,所以磁盘的内存缓存将给系统存储性能带来质的飞跃。

1.缓存手段

  • 概述

页高速缓存是由内存中的物理页面组成的,其内容对应磁盘上的物理块。页高速缓存大小能动态调整——它可以通过占用空闲内存以扩张大小,也可以自我收缩以缓解内存使用压力。

我们称正被缓存的存储设备为后备存储,因为缓存背后的磁盘无疑才是所有缓存数据的归属。

  • 缓存查询的过程

当内核开始一个读操作(比如,进程发起一个read()系统调用),它首先会检查需要的数据是否在页高速缓存中。如果在,则放弃访问磁盘,而直接从内存中读取。这个行为称作缓存命中。如果数据没有在缓存中,称为缓存未命中,那么内核必须调度块IO操作从磁盘去读取数据。然后内核将读来的数据放入页缓存中,于是任何后续相同的数据读取都可命中缓存了。注意,系统并不一定要将整个文件都缓存。缓存可以持有某个文件的全部内容,也可以存储另一些文件的一页或者几页。到底该缓存谁取决于谁被访问到。

1.1 写缓存

  • 概述

上面解释了在读操作过程中页高速缓存的作用,那么在进程写磁盘时,比如执行write()系统调用,缓存如何被使用呢?通常来讲,缓存一般被实现成下面三种策略之一:

①.不缓存(nowrite)

不缓存,也就是说高速缓存不去缓存任何写操作。当对一个缓存中的数据片进行写时,将直接跳过缓存,写到磁盘,同时也使缓存中的数据失效。那么如果后续读操作进行时,需要再重新从磁盘中读取数据。不过这种策略很少使用,因为该策略不但不去缓存写操作,而且需要额外费力去使缓存数据失效。

②.写操作将自动更新内存缓存

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

③.回写

第三种策略,也是Linux所采用的,称为“回写”。在这种策略下,程序执行写操作直接写到缓存中,后端存储(磁盘)不会立刻直接更新,而是将页高速缓存中被写入的页面标记成“脏”,并且被加人到脏页链表中。然后由一个进程(回写进程)周期行将脏页链表中的页写回到磁盘,从而让磁盘中的数据和内存中最终一致。最后清理“脏”页标识。

注意

这里“脏页”这个词可能引起混淆,因为实际上脏的并非页高速缓存中的数据(它们是干干净净的),而是磁盘中的数据(它们已过时了)。也许更好的描述应该是“未同步”吧。尽管如此,我们说缓存内容是脏的,而不是说磁盘内容。回写策略通常认为要好于写透策略,因为通过延迟写磁盘,方便在以后的时间内合并更多的数据和再一次刷新。当然,其代价是实现复杂度高了许多。

就跟mysql的缓冲池一样的想要了解的可以点击InnoDB的Buffer Pool

1.2 缓存回收

  • 概述

缓存算法最后涉及的重要内容是缓存中的数据如何清除;或者是为更重要的缓存项腾出位置;或者是收缩缓存大小,腾出内存给其他地方使用。这个工作,也就是决定缓存中什么内容将被清除的策略,称为缓存回收策略。

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

①.最近最少使用(LRU)

看到这别忘记再去力扣复习一下手撕LRU

缓存回收策略通过所访问的数据特性,尽量追求预测效率。最成功的算法(特别是对于通用目的的页高速缓存)称作最近最少使用算法,简称LRU。LRU回收策略需要跟踪每个页面的访问踪迹(或者至少按照访问时间为序的页链表);越久没被访问的数据也就越不可能被近期访问

缺点

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

②.双链策略

Linux实现的是一个修改过的LRU,也称为双链策略。和以前不同,Linux维护的不再是一个LRU链表,而是维护两个链表:活跃链表和非活跃链表

处于活跃链表上的页面被认为是“热”的且不会被换出,而在非活跃链表上的页面则是可以被换出的。在活跃链表中的页面必须在其被访问时就处于非活跃链表中

两个链表都被伪LRU规则维护:页面从尾部加入,从头部移除,如同队列。两个链表需要维持平衡——如果活跃链表变得过多而超过了非活跃链表,那么活跃链表的头页面将被重新移回到非活跃链表中,以便能再被回收。双链表策略解决了传统LRU算法中对仅一次访问的窘境。而且也更加简单的实现了伪LRU语义。这种双链表方式也称作LRU/2。更普遍的是n个链表,故称LRU/n。

  • 实际应用中的缓存如何影响工程

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

2.Linux页高速缓存

  • 概述

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

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

2.1 address_space对象

  • 概述

在页高速缓存中的页可能包含了多个不连续的物理磁盘块(例如X86的物理页是4MB,文件系统的块是512KB,所以需要8个块才能填满页面)。也正是由于页面中映射的磁盘块不一定连续,所以在页高速缓存中检查特定数据是否已经被缓存是件颇为困难的工作。因为不能用设备名称和块号来做页高速缓存中的数据的索引,要不然这将是最简单的定位办法。

Linux页高速缓存的目标是缓存任何基于页的对象,这包含各种类型的文件和各种类型的内存映射。

  • address_space

虽然Linux页高速缓存可以通过扩展inode结构体支持页IO操作,但这种做法会将页高速缓存局限于文件。为了维持页高速缓存的普遍性(不应该将其绑定到物理文件或者inode结构体),Linux页高速缓存使用了一个新对象管理缓存项和页IO操作。这个对象是addiress_space结构体。该结构体是前面介绍的虚拟地址vm_area_struct的物理地址对等体。

当一个文件可以被10个vm_area_struct结构体标识(比如有5个进程,每个调用mmap()映射它两次),那么这个文件只能有一个address_space数据结构——也就是文件可以有多个虚拟地址但是只能在物理内存有一份。

  • 结构体
  • i_mmap:该属性是一个优先搜索树,搜索范围就是在address_space中所有共享的与私有的映射页面
  • i_map:再次强调,一个被缓存的文件只有有一个address_space,但是会有多个VMA结构体,所以物理页到虚拟页是一对多的关系,而i_map属性可以高效的找到被关联的缓存文件
  • nrpages:address_space中页的全部数量
  • host:如果address_space与索引节点关联,此时该属性就指向索引节点
    Linux内核设计与实现(十)| 页高速缓存和页回写_第1张图片

2.2 address_space操作

  • 概述

我们就以内核读取一个页面的例子了解所要涉及的那些操作。因为所有的页IO操作都要执行下面这些步骤,这就保证了所有的页IO操作必然都是通过页高速缓存进行的。因此,内核也总是试图先通过页高速缓存来满足所有的读请求。如果在页高速缓存中未搜索到需要的页,则内核将从磁盘读入需要的页,然后将该页加入到页高速缓存中对于写操作,页高速缓存更像是-一个存储平台,所有要被写出的页都要加入页高速缓存中。

页面的读操作

  1. 首先Linux内核试图在缓存中找需要的数据:find_get_page()负责完成这个任务

一个address_space对象和一个偏移量会传给find _getpage()方法,用于在页高速缓存中搜索需要的数据:mapping是指定的地址空间也就是address_space结构体,index是文件中的指定位置

page = find_get_page ( mapping ,index) ;
  1. 如果搜索的页不在缓存中,则会返回NULL,并且内核分配一个新页面。然后将之前搜索的页面加入缓存中
struct page *page ;
int error;

/*分配页…*/
page = page_cache_alloc_cold(mapping) ;
if( ! page)
	/*内存分配出错*/
/*……然后将其加入到页面调整缓存*/
error = add_to_page_cache_lru(page , mapping,index,GFP_KERNEL);
if (error)
	/*页面被加入到页面高速缓存时,出错*/
  1. 最后需要的数据从磁盘被读入,再加入缓存,然后返回用户
error= mapping->a_ops->readpage(file,page) ;

如果是写操作,后续还有几步
4. 写操作和读操作有少许不同。对于文件映射来说,当页被修改了,VM仅仅需要调用:

setPageDirty (page) ;
  1. 内核会在晚些时候通过writepage()方法把页写出。对特定文件的写操作比较复杂

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

2.3 基树

  • 概述

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

  • 基树

页高速缓存通过两个参数address_space对象加上一个偏移量进行搜索。每个address_space对象都有唯一的基树(radix tree),它保存在page_tree结构体中。基树是一个二叉树,只要指定了文件偏移量,就可以在基树中迅速检索到希望的页。页高速缓存的搜索函数find_get_page()要调用函数radix_tree_lookup(),该函数会在指定基树中搜索指定页面。

2.4 以前的页散列表

  • 概述

在以前的版本中查缓存不是通过检索基树而是检索散列表。对于给定的一个键值,该散列表会返回一个双向链表的入口对应于这个所给定的值。如果需要的页贮存在缓存中,那么链表中的一项就会与其对应。否则,页就不在页面高速缓存中,散列函数返回NULL。

  • 缺点
  • 由于使用单个的全局锁保护散列表,所以即使在中等规模的机器中,锁的争用情况也会相当严重,造成性能受损。
  • 由于散列表需要包含所有页高速缓存中的页,可是搜索需要的只是和当前文件相关的那些页,所以散列表包含的页面相比搜索需要的页面要大得多。
  • 如果散列搜索失败(也就是给定的页不在页高速缓存中),执行速度比希望的要慢得多,这是因为检索必须遍历指定散列键值对应的整个链表。
  • 散列表比其他方法会消耗更多的内存。

3.缓冲区高速缓存

  • 概述

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

块I/O操作一次操作一个单独的磁盘块。普遍的块IO操作是读写i节点。内核提供了bread()函数实现从磁盘读一个块的底层操作。通过缓存,磁盘块映射到它们相关的内存页,并缓存到页高速缓存中。

  • 缓冲和页缓存的联系

以前

缓冲和页高速缓存并非天生就是统一的,2.4内核的主要工作之一就是统一它们。在更早的内核中,有两个独立的磁盘缓存:页高速缓存和缓冲区高速缓存。前者缓存页面,后者缓存缓冲区,这两个缓存并没有统一。一个磁盘块可以同时存于两个缓存中,这导致必须同步操作两个缓冲中的数据,而且浪费了内存,去存储重复的缓存项。

现在

今天我们只有一个磁盘缓存,即页高速缓存。虽然如此,内核仍然需要在内存中使用缓冲来表示磁盘块,幸好,缓冲是用页映射块的,所以它正好在页高速缓存中。

4.flusher线程

  • 概述

该线程就是负责将脏页刷新到硬盘的线程

  • 三种发生脏页写回的情况

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

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

在以前的版本完成上面三种工作的是两个独立的内核线程分别完成的;而现在则是由一群内核线程(flusher)执行这三种任务

  • flusher线程的工作过程
  • 首先flusher会在系统空闲内存到达某一阈值开始将脏页写回磁盘(后台回写例程),这个阈值参数为dirty_background_ratio,空闲内存低于这个参数则会调用flusher_threads()唤醒一个或多个flusher线程
  • 然后线程会进一步调用bdi_writeback_all()这个函数开始工作,需要传入一个参数:写回页数

线程工作停止条件

如果没有到达这两个条件之前就停止工作,可能是线程把所有的脏页都写回了

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

为了满足停止的第二个条件,fluster线程后台例程会被周期性的唤醒(注意即使空闲内存很多也回被唤醒),会将那些超时的脏页写回

此时如果发生一些断电,系统崩溃等就会造成脏页丢失,那么周期性同步页缓存和磁盘就非常重要;所以在系统初始化的时候会启动一个定时器,让其周期性的唤醒flusher,随后使其运行函数wb_writeback()。该函数将把所有驻留时间超过dirty_expire_interval ms的脏页写回。然后定时器将再次被初始化为dirty_expire_centisecs秒后唤醒flusher线程。总而言之,flusher线程周期性地被唤醒并且把超过特定期限的脏页写回磁盘。

  • 页回写相关参数
    Linux内核设计与实现(十)| 页高速缓存和页回写_第2张图片

4.1 膝上型计算机模式

  • 概述

膝上型计算机模式是一种特殊的页回写策略,该策略主要意图是将硬盘转动的机械行为最小化,允许硬盘尽可能长时间地停滞,以此延长电池供电时间。该模式可通过/proc/sys/vm/laptop_mode文件进行配置。通常,上述配置文件内容为0,也就是说膝上型计算机模式关闭,如果需要启用膝上型计算机模式,则向配置文件中写入1。

但是在其坏处则是系统崩溃或者其他错误会使得数据丢失。

  • Linux的自适应

多数Linux发布版会在计算机接上电池或拔掉电池时,自动开启或禁止膝上型计算机模式以及其他需要的回写可调节开关。因此机器可在使用电池电源时自动进入膝上型计算机模式,而在插上交流电源时恢复到常规的页回写模式。

  • 与传统之间的区别

膝上型计算机模式的页回写行为与传统方式相比只有一处变化。除了当缓存中的页面太旧时要执行回写脏页以外,flusher还会找准磁盘运转的时机,把所有其他的物理磁盘IO、刷新脏缓冲等通通写回到磁盘,以便保证不会专门为了写磁盘而去主动激活磁盘运行。

为了做到上面说到的fluster将磁盘运转考虑进去,往往需要把这两个参数dirty_expire_interval和dirty_writeback_interval设置的更大,因为磁盘移动本身就不是很频繁

4.2 历史上的bdflush、kupdated和pdflush

  • 概述

前面我们说到在flusher线程出现之前,是由两个线程来进行脏页回写工作的,就是bdflush和kupdated两个线程

  • bdflush

当可用内存过低时,bdflush内核线程在后台执行脏页回写操作。类似flusher,它也有一组阙值参数,当系统中空闲内存消耗到特定阈值以下时,bdflush线程就被wakeup_bdflush()函数唤醒。

bdflush与flusher的区别

  1. 第一个区别是系统中只有一个bdflush后台线程,而fusher线程的数目却是根据磁盘数量变化的
  2. 第二个区别是bdfush线程基于缓冲,它将脏缓冲写回磁盘。相反,flusher线程基于页面,它将整个脏页写回磁盘。当然,页面可能包含缓冲,但是实际I/O操作对象是整页,而不是块。因为页在内存中是更普遍和普通的概念,所以管理页相比管理块要简单。
  • kupdated和pdflush

因为只有在内存过低和缓冲数量过大时,bdflush例程才刷新缓冲,所以 kupdated例程被引入,以便周期地写回脏页。它和 pdflush线程的wb_writeback()函数提供同样的服务。我们具体会讲讲pdflush

pdflush与flusher的区别

  • pdflush线程数目是动态的,默认是2个到8个,具体多少取决于系统IO的负载Pdflush线程与任何任务都无关,它们是面向系统所有磁盘的全局任务。这样做的好处是实现简单,可带来的问题是,pdflush线程很容易在拥塞的磁盘上绊住,而现代硬件发生拥塞更是家常便饭。

采用每个磁盘一个刷新线程可以使得I/O操作同步执行,简化了拥塞逻辑,也提升了性能

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

  • bdflush线程的缺点

我们上面讲到bdflush线程,该线程被淘汰的重要原因就是仅仅包含一个线程,所以页很多时会造成拥塞,这是因为单一的线程有可能堵塞在某个设备的已拥塞请求队列(正在等待将请求提交给磁盘的IO请求队列)上,而其他设备的请求队列却没法得到处理。

其核心原因就是磁盘的吞吐量十分有限,所有如果单一线程执行回写任务,那么这个线程很可能为了等一个磁盘而让其他磁盘一直在等待,所以我们需要多线程了完成这个任务

  • flusher线程

2.6内核通过使用多个flusher线程来解决上述问题。每个线程可以相互独立地将脏页刷新回磁盘,而且不同的flusher线程处理不同的设备队列。

  • pdflush的线程策略的优点既是缺点——拥塞避免

pdflush线程策略中,线程数是动态变化的每一个线程试图尽可能忙地从每个超级块的脏页链表中回收数据,并且写回到磁盘。pdflush方式避免了因为一个忙磁盘,而使得其余磁盘饥饿的状况

通常情况下这样是不错的,但是如果每个pdflush线程在同一个拥塞的队列上挂起了又该如何呢?在这种情况下,多个pdflush线程可能并不比一个线程更好,就浪费的内存而言就要多许多。为了减轻上述影响,pdflush线程采用了拥塞回避策略:它们会主动尝试从那些没有拥塞的队列回写页。从而,pdflush线程将其工作调度开来,防止了仅仅欺负某一个忙碌设备。

缺点

这种方式效果确实不错,但是拥塞回避并不完美。在现代操作系统中,因为I/O总线技术和计算机其他部分相比发展要缓慢得多,所以拥塞现象时常发生——处理器发展速度遵循摩尔定理,但是硬盘驱动器则仅仅比20年前快一点点。要知道,目前除了pdflush 以外,I/O系统中还没有其他地方使用这种拥塞回避处理。不过在很多情况下,pdflush确实可以避免向特定盘回写的时间和期望时间相比太久。

  • 总结

当前flusher线程模型(自2.6.32内核系列以后采用)和具体块设备关联,所以每个给定线程从每个给定设备的脏页链表收集数据,并写回到对应磁盘。回写于是更趋于同步了,而且由于每个磁盘对应一个线程,所以线程也不需要采用复杂的拥塞避免策略,因为一个磁盘就一个线程操作。该方法提高了I/O操作的公平性,而且降低了饥饿风险。

你可能感兴趣的:(Linux,linux,服务器,运维)