深入理解Linux虚拟内存管理(二)(中)

简介: 深入理解Linux虚拟内存管理(二)

深入理解Linux虚拟内存管理(二)(上):https://developer.aliyun.com/article/1597756

8.4 指定大小的高速缓存

  对于小块内存分配,由于物理页面分配器不再适用,所以 Linux 保留有两套高速缓存系统。一套高速缓存由 DMA 使用,而另一套在通常情况下使用。人们称呼它们为 size-N 高速缓存和 size-N(DMA) 高速缓存,可以通过 /proc/slabinfo 看到。每种指定大小的高速缓存的信息都存放在一个 struct cache_sizes 中,它们也具有 cache_sizes_t 的类型,在 mm/slab.c 中定义如下:

// mm/slab.c
/* Size description struct for general caches. */
typedef struct cache_sizes {
  // 内存块的大小。
  size_t     cs_size;
  // 常规内存使用的高速缓存块。
  kmem_cache_t  *cs_cachep;
  // DMA 使用的高速缓存块。
  kmem_cache_t  *cs_dmacachep;
} cache_sizes_t;

   由于这些高速缓存的数量是有限的,所以在编译时期系统会初始化一个静态数组 cache_sizes。它的值在 4 KB 机器上以 32 B 作为起始,如果页面更大,它将会从 64 开始。

// mm/slab.c
static cache_sizes_t cache_sizes[] = {
#if PAGE_SIZE == 4096
  {    32,  NULL, NULL},
#endif
  {    64,  NULL, NULL},
  {   128,  NULL, NULL},
  {   256,  NULL, NULL},
  {   512,  NULL, NULL},
  {  1024,  NULL, NULL},
  {  2048,  NULL, NULL},
  {  4096,  NULL, NULL},
  {  8192,  NULL, NULL},
  { 16384,  NULL, NULL},
  { 32768,  NULL, NULL},
  { 65536,  NULL, NULL},
  {131072,  NULL, NULL},
  {     0,  NULL, NULL}
};

   很显然,这是一个以 0 结尾的数组,它从 252172 的指数个缓冲区组成。当系统启动时,这个数组就必须被初始化,然后用于描述每一种指定大小的高速缓存。

8.4.1 kmalloc()

  由于存在指定大小的高速缓存,slab 分配器可以提供一个新的分配函数 kmalloc(),每当需要较少内存缓冲区时就调用该函数。当系统接收到一个这样的请求时,系统会选择适当的指定大小的缓冲区,并且从它当中分配一个对象。因为大部分困难的工作在分配高速缓存时已经完成,所以如图 8.16 所示的调用过程非常简单。

8.4.2 kfree()

   既然有一个 kmalloc() 函数用来分配小块内存对象,那么就有一个 kfree() 用来释放小块内存对象。和 kmalloc() 函数一样,实际的工作在对象释放的时候完成 (见 8.3.3 小节),所以如图 8.17 所示的函数调用图也很简单。


8.5 per-CPU 对象高速缓存

 slab 分配器致力于的一个任务就是提升硬件和高速缓存的使用效率。一般来讲,高性能计算 [CS98] 的一个目标就是尽可能长时间地使用同一个 CPU 上的数据。Linux 通过 per-CPU 尝试将对象保留在同一个 CPU 高速缓存中来实现这个任务,per-CPU 高速缓存在系统中可以简单地称为每个 CPU 的 cpucache。


  当对象被分配或者被释放时,它们都被放置在 cpucache 中。在没有空闲对象时,系统就将一批对象放置到这个池中。在池变得过于庞大时,系统就移除其中一半的对象,放到全局高

速缓存中。这样,就可以尽可能长时间地使用同一个 CPU 上的硬件高速缓存。


  这种方法的第二个好处是在访问 CPU 池时不需要获得自旋锁,这是因为我们已经保证不会有另外的 CPU 访问这些局部数据。这点很重要,如果没有高速缓存,那么就必须在每次分配和释放时都要获得自旋锁,这是不必要的开销。

8.5.1 描述 per-CPU 对象高速缓存

   每一个高速缓存描述符都有一个指针指向一个 CPU 高速缓存数组,它在高速缓存中描述如下:

// mm/slab.c
/*
 * cpucache_t
 *
 * Per cpu structures
 * The limit is stored in the per-cpu structure to reduce the data cache
 * footprint.
 */
typedef struct cpucache_s {
  // 在 cpucache 中可用的空闲对象的数量。
  unsigned int avail;
  // 能够存在的空闲对象的总数量。
  unsigned int limit;
} cpucache_t;

   对于一个给定的高速缓存和处理器,系统为 cpucache 提供了一个 cc_data() 宏作为辅助,它的定义如下:

// mm/slab.c
#define cc_data(cachep) \
  ((cachep)->cpudata[smp_processor_id()])

   该宏需要一个给定的高速缓存描述符(cachep)作为输入参数,然后从 cpucache 数组(cpudata)中返回一个指针。所需的索引是当前处理器的 ID,即 smp_processor_id()

   很快系统就会在 cpucache_t 结构后面存储 cpucache 中的对象。这与在 slab 描述符后面存储对象类似。

8.5.2 在 per~CPU 高速缓存中添加/移除对象

   为防止碎片的产生,通常是在数组的末尾进行添加和删除对象的操作。以下代码块用于添加对象(obj)到 CPU 高速缓存(cc)中:

cc_entry(cc)[cc->avail++] = obj;

以下代码块用于移除对象:

obj = cc_entry(cc)[--cc->avail];

辅助宏 cc_entry()给出了 CPU 高速缓存中指向第一个对象的指针,它的定义如下:

#define cc_entry(cpucache) \
  ((void **)(((cpucache_t*)(cpucache))+1))

   它用一个指针指向 cpucache,并根据 cpucache_t 描述符的大小增加其值,从而得到高速缓存中第一个对象。

8.5.3 启用 per-CPU 高速缓存

 在创建一个高速缓存时,就必须启动 CPU 高速缓存,并调用 kmalloc() 函数为之分配内存函数 enable_cpucache() 负责决定 per-CPU 高速缓存的大小,并调用 kmem_tune_cpucache() 函数为该高速缓存分配空间。


  显然,各种指定大小的高速缓存被启动后,CPU 高速缓存就不能再存在了,因此,系统使用全局变量 g_cpucache_up 来防止 CPU 高速缓存被过早地启用。函数 enable_all_cpucaches() 遍历高速缓存链表中所有的高速缓存,并逐个启动各自的 cpucache。


  一旦启用了 CPU 高速缓存,不需要加锁就可以直接访问了,因为 CPU 决不会访问到错误的 cpucache,所以可以保证访问的安全性。

8.5.4 更新 per-CPU 高速缓存的信息

  per-CPU 高速缓存被创建或被修改后,每一个 CPU 通过 IPI 得知该情况。这时若没有改变高速缓存描述符中所有的值,就可能导致高速缓存的信息不一致,因此就必须用加锁的方式来保护 CPU 高速缓存中的信息。所以,Linux 中提供了一个 ccupdate_t 结构,它里面包含了每一个 CPU 所需要的信息,并且每一个 CPU 在这个高速缓存描述符中通过旧信息交换新数据。用于存储新 cpucache 信息的结构定义如下:

// mm/slab.c
typedef struct ccupdate_struct_s
{
  kmem_cache_t *cachep;
  cpucache_t *new[NR_CPUS];
} ccupdate_struct_t;

  其中的 cachep 是已经更新过的高速缓存,new 是系统中每个 CPU 的 cpucache 描述符的数组。 函数 smp_function_all_cpus() 取得每个 CPU,然后调用 do_ccupdate_local() 函数将 ccupdate_struct_t 中的信息与高速缓存描述符中的信息进行交换。

   一旦这些信息被交换后,旧的数据就可以删除了。

8.5.5 清理配操作上节约几毫秒的时间。

  收缩一个高速缓存时,第一步就是调用 drain_cpu_caches() 来清理对象可能具有的 cpucache。在这里 ,slab 分配器必须清楚地知道可以释放哪些 slab,这一点很重要,因为即便在 per-CPU 高速缓存的 slab 中只有一个对象,这整个 slab 也不能被释放。在系统内存紧张时,没有必要在分配操作上节约几毫秒的时间。

8.6 初始化 slab 分配器

  这一节描述 slab 分配器如何初始化它自己。在 slab 分配器创建一个新的高速缓存时,它就从 cache_cache 或者 kmem_cache 高速缓存中分配 kmem_cache_t 结构。这显然是一个是先有鸡还是先有蛋的问题,所以必须静态地初始化 cache_cache,如下:

/* internal cache of cache description objs */
static kmem_cache_t cache_cache = {
  // 初始化这 3 个链表成空链表。
  slabs_full: LIST_HEAD_INIT(cache_cache.slabs_full),
  slabs_partial:  LIST_HEAD_INIT(cache_cache.slabs_partial),
  slabs_free: LIST_HEAD_INIT(cache_cache.slabs_free),
  // 每个对象的大小是一个高速缓存描述器的大小。
  objsize:  sizeof(kmem_cache_t),
  // 高速缓存的创建和删除是非常少见的,因此并不用考虑它的回收。
  flags:    SLAB_NO_REAP,
  // 初始化自旋锁为开锁状态。
  spinlock: SPIN_LOCK_UNLOCKED,
  // 对象和 L1 高速缓存对齐。
  colour_off: L1_CACHE_BYTES,
  // 记录可读性好的命名。
  name:   "kmem_cache",
};

   编译时,系统会计算所有这些静态定义字段的代码。为了初始化结构的其余部分,函数start_kernel() 会调用 kmem_cache_init()

8.7 伙伴分配器接口

  slab 分配器并不拥有页面,它必须通过物理页面分配器为其分配页面。为此,系统提供了两个 API 函数 kmem_getpages() 和 kmem_freepages() 。这两个函数基本上都是对伙伴分配器的 API 封装,因此在分配时要考虑 slab 标志位。分配时,缺省页面从 cachep→gfpflags 得到,而其顺序从 cachep->gfporder 得到,其中 cachep 是请求页面的高速缓存。在释放页面时,PageClearSlab() 会在调用 free_pages() 之前由于每个即将释放的页面而被调用。

8.8 2.6 中有哪些新特性

   最明显的改变是 /proc/slabinfo 格式的版本由 1.1 升级到 2.0,这样更具可读性。最有帮助的改变是现在字段都有了一个首部,省去了记忆每栏意思的必要。

  主要的算法思想以及概念与原来一样。虽然主要的算法没有变,但是算法的实现却很不同。尤其是,2.6 中特别强调使用 per-CPU 对象以避免上锁操操作。其二,2.6 中包含了大量的调试代码,所以在阅读代码时就需要略过那些 #ifdef DEBUG 的部分。最后,对一些函数名做了字面意义上的修改,其实它们的行为依旧没变。例如,kmem_cache_estimate() 现在称为

cache_estimate() ,其实它们除了名字什么都是一样的。

高速缓存描述符

 对 kmem_cache_s 的改变很少。首先,在这个结构的起始位置记录常用的元素,如 per-CPU 相关的数据(原因见 3.9 节)。其次 ,slab 链表(如 slabs_full)以及与其相关的统计数据都被转移到了另一个单独的 struct kmem_list3 中。注释以及不常用的宏表明将有计划使该结构对应于单个的节点。

高速缓存静态标志位

  在 2.4 中的这些标志位仍然存在,他们的使用方法也是相同的。CFLAGS_OPTIMIZE 不再存在了,它在 2.4 中就没有使用过。2.6 中还引入了两个新标志位。


  SLAB_STORE_USER:这是只用于调试的标志位,它记录释放对象的函数。如果对象在释放后被使用,那么感染标志位的字节就不会匹配,系统将显示一个内核错误消息。由于知道最后一个使用对象的函数,所以调试更轻松。


  SLAB_RECLAIM_ACCOUNT:这个标志位由具有易回收对象的高速缓存所使用,如索引节点高速缓存。系统在一个称为 slab_reclaim_pages 的变量中设置了一个计数器,用于记录 slab 在这些高速缓存中分配了多少页面。这个计数器在后面用于 vm_enough_memory() ,来帮助决定系统是否真的内存溢出。


回收高速缓存

回收高速缓存

 这是对 slab 分配器最有意思的改变。不再存在 kmem_cache_reap() 是因为在用户面对更为长远的选择时,该函数不加选择地决定如何缩小高速缓存。高速缓存的用户现在可以使用 set_shrinker() 注册一个收缩高速缓存的回调函数来实现智能计数和收缩 slab。这个简单的函数生成一个 struct shrinker,在这个结构中有一个指向回调函数的指针和一个搜寻权重,这个权重表明了在把对象放入称为 shrinker_list 的链表之前重建对象的困难程度。


  在页面回收时,系统调用函数 shrink_slab() ,它遍历整个 shrinker_ist,并调用每个收缩回调函数两次。第 1 次调用传一个 0 作为参数,它表明如果函数被适当调用了,回调函数应该返回它希望自己能够释放的页面数量。系统对各回调函数的代价作了基本的试探以决定是否值得使用该回调函数。如果值得,它将第 2 次作为参数被调用,以表明有多少对象被释放。


  计算释放页面数量的机制是有技巧的。 每个任务结构中有个称为 reclaim_state 的字段。在 slab 分配器释放页面时,就用被释放的页面数量更新该字段。在调用 shrink_slab() 之前,该字段设置为 0,在 shrink_cache 返回以决定释放多少页面之后,系统将再次读取该字段。

其他的改变

  其他的改变实际上都是表面上的。例如,slab 描述器现在称为 struct slab 而不是 slab_t,这和目前逐步不使用 typedefs 的趋势相一致。per-CPU 高速缓存除了结构以及 API 有新的命名以外基本保持不变。相同类型的变化在大多数 2.6 内核的 slab 分配器中都实现了。

第9章 高端内存管理

 内核只有在设置了页表项后才能直接定址内存。在大多数情况下,用户/内核地址空间分别分成 3 GB/1 GB。这就意味着,正如在 4.1 节中解释的那样,在一台 32 位的机器上至多只有 896 MB 的内存可以直接被访问。在一台 64 位的机器上,因为有足够多的虚拟地址空间,所以这实际上不是个问题。现在还不可能有运行 2.4 内核的机器具有 TB 级的 RAM。


  现在已经有很多高端的 32 位机器拥有 1 GB 的内存,因此,就不能简单地忽略不方便定址的那部分内存。Linux 使用的方法是暂时把高端内存映射为较低的页表。9.2 节中将会讨论。


  高端内存与 I/O 相关的一个问题必须提到,即并不是所有的设备都可以访问高端内存或者对 CPU 可用的所有内存。这可能是这样一种情况,如果 CPU 开启了 PAE 拓展,那么设备就被限制在只能访问有符号 32 位整数的空间(2 GB)或者是 64 位架构中所使用的 32 位设备。控制设备写内存,最好的情形是内存失效,而最坏的情形可能使内核崩责。解决这个问题的方法是使用弹性缓冲区,这个将在 9.5 节中讨论。


  这一章首先简要地描述如何管理持久内核映射(PKMap)地址空间间,然然后讨论页面如何映射到高端内存以及如何解除映射。接下来的小节将先讨论哪个地方的映射必须是原子性的,然后深入讨论弹性缓冲区。最后我们将谈到在内存非常紧张时,如何使用紧急池。

9.1 管理 PKMap 地址空间

  在内核页表顶部,从 PKMAP_BASE 到 FIXADDR_START 的空间保留给 PKMap 。这部分保留的空间大小变化很细微。在 x86 上,PKMAP_BASE 位于 0xFE000000,而 FIXADDR_START 的地址是一个编译时常量,它只随着配置选项而变化,但一般是只有一少部分页面被分配在线性地址空间的尾部附近。这就意味着把页面从高端内存映射到可用空间的页表空间略小于 32 MB 。


  为了映射页面,单个页面集的 PTE 被存放在 PKMap 区域的起始部分,从而允许 1024 个页面在短期内通过函数 kmap() 映射到低端内存,接着由 kunmap() 解除映射。这个池看起来虽然很小,但同时 kmap() 映射页面的时间却也非常短。代码的注解表明了有计划进行分配连续的页表项以扩充这部分区域,但现在保留的仍只是代码注释,所以大部分 PKMap 的部分没有被用到。


  kmap() 调用中用到的页表项称为 pkmap_page_table,它位于 PKMAP_BASE,并且在系统初始化时就建立 。在 x86 上,它是在 pagetable_init() 函数结束部分进行的 。 含有 PGD 和 PMD 项的页面由引导内存分配器分配,以保证它们的存在性。


  当前页表项的状态由一个称为 pkmap_count 的简单数组管理,它具有 LAST_PKMAP 项。在没有 PAE 的 x86 系统统中,它的值是 1024,而在具有 PAE 的系统中,它的值是 512。更准确地说,虽然在代码中没有体现出来,LAST_PKMAP 变量的值等于 PTRS_PER_PTE。


  虽然每个元素不是准确的引用计数,但是那也很接近引用计数。如果该项是 0,则该页空闲,而且自上次 TLB 刷新后就没被使用过。如果是 1,则该槽没有被使用,但是仍有页面映射该槽,等待一次 TLB 刷新。因为当全局的页表修改时需要对所有的 CPU 进行刷新,而这个操作的开销是相当大的,所以每个槽都被使用至少一次以后才会进行刷新。任意更高的数值都是对该页面 n-1 个用户的引用计数。

9.2 映射高端内存页面

  表 9.1 中描述了从高端内存映射页面的 API。主要用于映射页面的函数是 kmap() ,其调用图见图 9.1。如果用户不想阻塞,那么可以使用 kmap_nonblock() ,而中断用户可以使用 kmap_atomic() 。kmap 池相当小,所以 kmap() 调用者尽可能快地调用 kunmap() 就显得很重要,因为与低端内存相比,这个小窗口的压力随着高端内存的增大而变得更为严重。


  kmap() 函数本身非常简单。它首先检查一下以保证中断没有调用这个函数(因为它可能睡眠),然后在确认的情况下调用 out_of_line_bug() 。由于调用 BUG() 中断处理程序有可能使系统瘫痪,所以 out_of_line_bug() 输出 bug 信息并干净地退出。接着要检查的是页面是不是在 highmem_start_page 以下,因为低于该标记位的页面已经是可见的了,而且不需要映射。


  接着要检查的是该页面是否已经在低端内存,如果是就只返回该页面的地址。这样,如果该页面正处于低端内存,kmap() 的调用者将会无条件地知道这一情况,所以这个函数总是安全的。如果要映射的页面是高端内存页面,那就调用 kmap_high() 来完成实际的工作。


  kmap_high() 函数一开始就检查 page→ virtual 字段,该字段在页面已经被映射时设置。如果该字段为 NULL,那么由 map_new_virtual() 提供页面的一个映射。


  map_new_virtual() 通过简单的线性扫描 pkmap_count 来创建新的虚拟映射。为了避免在几次 kmap() 调用间隔之间重复搜索同一个区域,因此扫描不是在 0 处开始的,而是开始于 last_pkmap_nr 处。当 last_pkmap_nr 折返到 0,系统就会调用 flush_all_zero_pkmaps() 将所有项从 1 设为 0,然后刷新 TLB。


  如果在某次扫描完成后,依旧找不到某项,则该进程就会在 pkmap_map_wait 队列上睡眠,直至它被下一次 kunmap() 唤醒。


  映射创建之后,pkmap_count 数组中的相应项就会增 1,然后返回低端内存的虚拟地址。

9.3 解除页面映射

  解除高端内存页面映射的 API 如表 9.2 所列。kunmap() 函数就像它的辅助物一样,执行两次检查。第 1 次做与 kmap() 相似的检查,用于检查中断上下文。第 2 次检查页面是否处于 highmem_start_page 以下。如果是,则该页面已经在低端内存中,不需要做进一步的处理。如果确定该页面要被解除映射,就调用 kunmap_high() 进行解除映射的操作。


  kunmap_high() 的原理很简单。它把 pkmap_count 中该页面的相应元素减 1。如果减到了 1(记住这意味着没有更多的用户了,需要一次 TLB 刷新),所有在 pkmap_map_wait 队列上等待的进程都会被唤醒,因为现在有一个槽可用。但这时并不把该页面从页表上解除映射,因为解除映射需要一次 TLB 刷新。它会一直延迟到 flush_all_zero_pkmaps() 被调用。

9.4 原子性的映射高端内存页面

  虽然并不推荐使用 kmap_atomic(),但是页面槽都会留给每个 CPU 以备需要,比如弹性缓冲区,在中断时供设备使用。一种结构是需要很多不同数量的原子性映射页面操作的,这种数量由 km_type 枚举。可用的总数是 KM_TYPR_NR 标志位。在 x86 上,共有 6 种不同的原子 kmap 使用方法。


  在引导时,在地址 FIX_KMAP_BEGIN 和 FIX_KMAP_END 之间,系统会为每个处理器保留 KM_TYPE_NR 个项。显然在调用 kunmap_atomic() 之前,一个 kamp 原子操作的调用者可能不会睡眠或退出,这是因为同一处理器上的下一个进程可能尝试使用同一个项,但却会失败。


  在所请求类型的操作和处理机的页表中,把请求页面映射到槽时,函数 kmap_atomic() 的任务很简单。函数 kunmap_atomic() 的调用图如图 9.2 所示,该函数很有意思,因为它仅仅是在启用调试项时调用 ptr_clear() 清理 PTE。 一般认为不需要解除原子页面映射,也不需要作 TLB 刷新操作,因为下一次 kmap_atomic() 调用会替换掉这些原子页面。

9.5 弹性缓冲区

  对那些不能访问所有 CPU 可见的所有内存的设备而言,弹性缓冲区是必需的。一个很明显的例子是不能寻址和 CPU 一样多的位数的设备,如在 64 位结构上的 32 位设备,或最近的允许 PAE 的 Intel 处理器。


  它的基本的思想很简单。弹性缓冲区驻留在足够低端的内存中,从而使设备从里面复制数据以及向里面写数据。然后它会把相应的用户页面复制到高端内存。这种附加的复制虽然是不合理的,但它又是不可或缺的。系统首先在低端内存中分配页面供 DMA 和设备的缓冲区页面。然后在 I/O 完成时由内核把它们复制到高端内存的缓冲区中,所以弹性缓冲区扮演着一个中介的角色。这种复制操作有一些负担,因为它至少涉及复制整个页面,但是与换出低端内存中的页面操作比较起来,这些负担又是无关紧要的。

9.5.1 磁盘缓冲

  磁盘块,一般为 1 KB 的大小,并且都装配成页面由 slab 分配器分配的 struct buffer_head 管理。缓冲区首部的用户具有一个回调函数选项。该回调函数在 buffer_head 中注册为 buffer_head->b_end_io(),它在 I/O 完成时调用。这就是弹性缓冲区用于完成把数据复制出弹性缓冲区的机制。注册的回调函数叫 bounce_end_io_write() 。


  缓冲区首部的其他特征以及磁盘块层如何使用它们已经超出了本书的范围,它们更多的是 I/O 层所关心的。

9.5.2 创建弹性缓冲区

   如图 9.3 所示,创建弹性缓冲区是一件简单的事,它从函数 create_bunce() 开始。它的原理很简单,使用一个所提供的缓冲区首部作为模板来创建一块新的缓冲区。该函数有两个参数,它们是 read/write 参数(rw)和所使用的缓冲区首部的模板(bh_orig)。


  函数 alloc_bounce_page() 为缓冲区本身分配页面,它是一个 alloc_page() 的包装器,但是有一个重要的例外情形。如果该分配操作没有成功,就会有一个页面的紧急池以及缓冲区首部返回给弹性缓冲区。这将在 9.6 讨论。


  可以肯定,该缓冲区的首部将由 alloc_bounce_bh() 分配,该函数原理上与 alloc_bounce_page() 类似,它调用 slab 分配器得到一个 buffer_head,如果无法分配时将使用缓冲池。另外,唤醒 bdflush 以开始将脏缓冲区刷出到磁盘,这样就可以立即释放缓冲区。


  一旦分配了页面和 buffer_head,信息就会从模板 buffer_head 被复制到新的 buffer_head 中。 由于这部分操作可能用到 kmap_atomic() ,弹性缓冲区的创建仅在设置了 IRQ 安全锁 io_request_lock 后才开始。I/O 完成的回调过程或者改变为 bounce_end_io_write() 或者改变为 bounce_end_io_read() ,这取决于这是一个读或写缓冲区,因此数据将会在高端内存来回复制。


  在分配方面要注意的最重要的方面是 GFP 标志位并没有涉及高端内存的 I/O 操作。这一点很重要,因为弹性缓冲区是用于高端内存的 I/O 操作的。如果分配器试图进行高端内存 I/O,它将被递归调用并最终失败。

9.5.3 通过弹性缓冲区复制数据

  通过弹性缓冲区复制的数据随着它是读还是写缓冲区而不同。如果该缓冲区用于写数据到设备,那么该缓冲区存储的是在 copy_from_high_bh() 创建弹性缓冲区时从高端内存读出的数据,回调函数 bunce_end_io_write() 将在设备准备好接收数据时完成 I/O 操作。


  如果该缓冲区用于从设备读数据,则在设备准备好前是没有数据转移的。当设备准备好后,设备的中断处理程序将调用回调函数 bounce_end_io_read() ,由它调用 copy_to_high_bh_irq() 把数据复制到高端内存。


  在这两种情形下一旦 I/O 完成,模板的回调函数 buffer_head() 调用后,缓冲区首部和页面都有可能被 bounce_end_io() 回收。如果紧急池没有满,则资源会被加入到池中,否则它们就会被释并放回各自的分配器。

9.6 紧急池

  为了快速使用弹性缓冲区,系统提供了 buffer_head 的两个紧急池以及相应的页面。由于高端内存的缓冲区不能等到低端内存可用时才释放,如果对分配器而言内存过于紧张,这时分配器无法完成请求的 I/O 操作,就会出现这种情况。这会导致操作失败,也可能会阻止这些操作释放它们的内存。


  紧急池由 init_emergency_pool() 初始化,每个池包含 POOL_SISE 项。页面通过一个 pagelist 字段相互链接在一个以 emergency_pages 为首部的链表上。图 9.5 说明了在紧急池中如何存储页面和如何在需要时获得页面。


  buffer_head 与之非常相似,因为它们也通过 buffer_head→inode_buffers 链接到一个以 emergency_bhs 为首部的链表上。页面和缓冲区链表中剩余的项数分别由两个计数器 nr_emergency_pages 和 nr_emergency_bhs 记录,而且这两个列表由一个叫 emergency_lock 的自旋锁保护。

9.7 2.6 中有哪些新特性

内存池

  在 2.4 中,高端内存管理器是惟一维护紧急池中页面的子系统。2.6 中实现了内存池,它是当内存紧张时需要保留的对象的一般概念。这里的对象可以是任何对象,如除高端内存管理器中的页面外,更多的是由 slab 分配器管理的一些对象。系统使用 memppool_create() 来初始化内存池,它需要有如下参数:需要保留对象的最小值(min_nr),一个对象类型的分配函数(alloc_fn() ),一个对象类型的释放函数(free_fn() ),以及一些用于分配和释放函数的可选的私有数据。


  内存池 API 提供两类分配和释放函数,分别叫做 mempool_alloc_slab() 和 mempool_free_slab() 。在使用这两类函数时,私有数据就是待分配和释放对象的 slab 高速缓存。


  在高端内存管理器中,创建有两个页面池,其中一个页面池用于普通的页面池,另外一个用于 ISA 设备 。ISA 设备必须从 ZONE_DMA 中分配,其分配函数是 page_pool_alloc() ,所传递的私有数据参数表明 GFP 标志位是否使用,而释放函数是 page_pool_free() 。这样内存池就代替了 2.4 中紧急急池的那部分分代码。


  在从紧急池中分配或释放对象时,系统提供了内存池 API 函数 mempool_alloc() 和 mempool_free() 函数。另外系统调用 mempool_destroy() 释放内存池。

映射高端内存页面

  在 2.4 中,字段 page→virtual 用于存储 pkmap_count 数组中的页面地址。由于在高端内存系统中结构体 pages 的数量很多,所以相对而言就有很多较小的页面需要映射到 ZONE_NORMAL 中,2.6 中也有这样一个 pkmap_count 数组,但是如何管理它就与 2.4 有很大不同。


  在 2.6 中创建了一个 page_address_htable 的哈希表,这个表基于结构体 page 的地址做哈希操作,另外使用了一个列表来定址结构体 page_address_slot。我们感兴趣的是 struct page 中的两个字段,一个是 struct page,另一个是虚拟地址。当内核需要一个映射页面的虚拟地址时,系统就遍历这个哈希桶。至于页面如何映射到低端内存,这实际上与 2.4 中相似,但 2.6 中不再需要 page->virtual。

进行 I/O

   最后一个改变是在进行 I/O 时,最主要的是使用了 struct bio 来代替结构体 buffer_headbio 结构的工作原理已经超出了本书的范围。需要说明的是,这里引入 bio 结构的主要原因是无论底层的设备是否支持,都可以进行以块为单位的 I/O 操作。在 2.4 中,无论底层设备的传输速率如何,所有的 I/O 操作都被分解为页面大小。

第10章 页面帧回收

  由于磁盘缓冲区、目录表项、索引节点项、进程页面以及其他的原因,运行中的系统最终会使用完所有可用的页面帧。因此,Linux 需要在物理内存耗尽前选择旧的页面进行释放,以使这些页面失效而待重新使用。这一章主要集中讨论 Linux 如何实现页面替换策略以及如何让不同类型的页面失效。


  本质上,Linux 选择页面的方法在一定程度上是凭经验的,而其背后的理论基础基于多种不同的策略。在实际过程中这些方法运行良好,并且它们根据用户的反馈和基准测试在不断地进行调整。在这一章中,首先要讨论的话题是页面替换策略的基础。


  第 2 个要讨论的话题是页面高速缓存。所有从磁盘读出来的数据都存储在页面高速缓存中,以此来减少磁盘 I/O 的次数。严格意义上,这和页面帧回收并没有什么直接的联系,但是 LRU 链表和页面高速缓存却是密不可分的。相关的章节会集中讨论页面如何添加到页面高速缓存中以及如何被快速定位。


  接下来是第 3 个话题,LRU 链表。除了 slab 分配器,系统中所有正在使用的页面都存放在页面高速缓存中,并由 page→Iru 链接在一起,所以很容易就可以扫描并替换它们。slab 页面并没有存放在页面高速缓存当中,因为人们认为基于被 slab 所使用的对象来对页面计数是很困难的。


  在这个部分,会谈及页面如何依附于其他高速缓存。在谈到进程映射如何被移除之前,诸如 dcache 和 slab 分配器这样的高速缓存应当被回收。进程映射页面并不容易进行交换,这是因为除了采用查找每个页表这种手段以外没有其他的方法能把结构页面映射为 PTE,而且查找每个页表的代价也是非常大的。如果页面高速缓存中存在大量的进程映射页面,系统将会遍历进程页表,然后通过 swap_out() 函数交换出页面直至有足够的页面空闲,然而 swap_out() 函数依旧会因为共享页的缘故而带来问题。如果一个页面是共享的,同时一个交换项已经被分配,PTE 就会填写所需的信息以便在交换分区里重新找到该页并将其引用计数减 1。只有当引用计数减到零时,这个页面才被替换出去。诸如此类的共享页面都会在交换高速缓存部分涉及到。


  最后,这个章节同样会涉及页面替换守护程序 kswapd,并讨论它的实现和职责所在。

10.1 页面替换策略

  在讨论页面替换策略时讲的最多的便是基于最近很少使用的算法(LRU),但严格意义上,这并不是正确的,因为这里的链表并不是严格地按照 LRU 顺序来保持的。Linux 中的 LRU 链包含两个链,分别是 active_list 和 inactive_list。active_list 上的对象包含所有进程的工作集 [Den70],而 inactive_list 则包含需要回收的候选对象。由于所有可回收的页面都仅存在于这两个链表中,并且任何进程的页面都可被回收,而不仅仅局限于那些出错的进程,因此替换策略的概念是全局的。


  这两个列表很像一个简化了的 LRU 2Q[JS94],其中分别维护着两个叫做 Am 和 A1 的链表。在 LRU 2Q 中,第一次分配的页面被放置在一个叫做 A1 的先进先出(FIFO)队列中。如果被引用的页面同时也在那个队列中,它们就会被放置到一个叫做 Am 的普通 LRU 链表中。 这与使用 lru_cache_add() 函数把页面放到一个叫 inactive_list(AD) 的队列中及使用 mark_page_accessed() 函数把页面移到 active_list 中(Am)的方式有点相似。这个算法描述了两个链表的大小是如何相互变化的,但是 Linux 采用了一种更加简单的方法:使用 refill_inactive() 函数把页面从 active_list 尾部移到 inactive_list,以确保 active_list 约占总页面高速缓存大小的 2/3。图 10.1 图解了如何构造这两个列表表,并阐明了如何添加页面以及如何通过 refill_inactive() 函数在两个链表之间移动。


  2Q 所描述的链表假定 Am 是一个 LRU 链表,而 Linux 中的链表更像是一个时钟算法 [Car84],其中时针是 active_list 的大小。当页面到达链表的底部时,就会检查引用的标志位。如果设置了引用的标志位,则该页面会被移回到链表的顶部,然后检查下一个页面。如果没有设置,则该页面会被移到 inactive_list。


  这种前向试探的方法意味这些链表的行为像 LRU 一样,但是 Linux 替换策略和 LRU 还是有很多不同之处的,一般认为后者是栈式算法 [MM87]。即使我们忽略掉这是在分析多道程序系统的问题,同时也不考虑每个进程所占内存大小不固定的情况 [CD80],这种策略也不满足内含特性,因为链表中页面的位置主要取决于链表的大小,这和最后一次被引用时是相反的。因为需要链表按每次的引用来进行更新,所以链表的优先级也没有排序。在整个找算法框架中,当从进程中换出页面时,链表的关键地位几乎被忽略掉了,它的换出取决于进程在虚拟地址空间的位置,而不是页面在链表中的位置。


  总而言之,这种算法的确表现得像 LRU 一样,而且基准测试中已经表明它在实际使用过程中运行良好。只有 2 种情况下这种算法才可能表现得很差。第 1 情况是如果待回收的页面主要是匿名页面,在这种情况下,Linux 会连续检查大量的页面,而这将在线性扫描进程页表以搜索待回收的页面之前完成。幸运的是,这种情况极为少见。


  第 2 种情况是在某个单进程中,系统频繁地写在 inactive_list 上的许多对应于文件的常驻页面中。进程和 kswapd 可能会进入一个循环过程,持续地调换这些页面并把它们放到 inactive_list 的首部,却没有释放任何东西。在这种情况下,由于这两个链表的大小没有明显的改变,几乎没有页面会从 active_list 移到 inactive_list 中。

深入理解Linux虚拟内存管理(二)(下):https://developer.aliyun.com/article/1597770

目录
相关文章
|
14天前
|
安全 Linux Shell
Linux上执行内存中的脚本和程序
【9月更文挑战第3天】在 Linux 系统中,可以通过多种方式执行内存中的脚本和程序:一是使用 `eval` 命令直接执行内存中的脚本内容;二是利用管道将脚本内容传递给 `bash` 解释器执行;三是将编译好的程序复制到 `/dev/shm` 并执行。这些方法虽便捷,但也需谨慎操作以避免安全风险。
|
23天前
|
Linux 调度
深入理解Linux虚拟内存管理(七)(下)
深入理解Linux虚拟内存管理(七)
35 4
|
23天前
|
存储 Linux 索引
深入理解Linux虚拟内存管理(九)(中)
深入理解Linux虚拟内存管理(九)
19 2
|
23天前
|
Linux 索引
深入理解Linux虚拟内存管理(九)(上)
深入理解Linux虚拟内存管理(九)
25 2
|
23天前
|
Linux
深入理解Linux虚拟内存管理(七)(中)
深入理解Linux虚拟内存管理(七)
23 2
|
23天前
|
机器学习/深度学习 消息中间件 Unix
深入理解Linux虚拟内存管理(九)(下)
深入理解Linux虚拟内存管理(九)
16 1
|
23天前
|
Linux
深入理解Linux虚拟内存管理(七)(上)
深入理解Linux虚拟内存管理(七)
24 1
|
21天前
|
缓存 Linux 调度
Linux服务器如何查看CPU占用率、内存占用、带宽占用
Linux服务器如何查看CPU占用率、内存占用、带宽占用
66 0
|
23天前
|
Linux API
深入理解Linux虚拟内存管理(六)(下)
深入理解Linux虚拟内存管理(六)
13 0
|
23天前
|
Linux
深入理解Linux虚拟内存管理(六)(中)
深入理解Linux虚拟内存管理(六)
18 0