Linux 内存管理(五)页面分配器

原图

分配物理页面是内存管理中最复杂的部分,它涉及页面回收、内存规整、直接回收内存等相当错综复杂的机制。本节关注在内存充足的情况下如何分配连续物理内存。

page 数据结构

Linux 内核内存管理的实现以 page 数据结构为核心,其他的内存管理设施都基于 page 数据结构,如 VMA 管理、缺页中断、RMAP、页面分配与回收等。page 数据结构定义在 include/linux/mm_types.h 头文件中。

_refcount

_refcount 和__mapcount 是 page 数据结构中非常重要的两个引用计数,且都是 atomic_t 类型的变量。 _refcount 表示内核中引用该页面的次数。

  • 当_refcount 的值为 0 时,表示该页面为空闲页面或即将要被释放的页面
  • 当_refcount 的值大于 0 时,表示该页面已经分配给内核正在使用,暂时不会被释放

_mapcount

_mapcount 表示这个页面被进程映射的个数,即已经映射了多少个用户 PTE。每个用户进程都拥有各自独立的虚拟空间(256TB)和一份独立的页表,所以可能出现多个用户进程地址空间同时映射到一个物理页面的情况,RMAP 系统就是利用这个特性来实现的。_mapcount 主要用于 RMAP 系统中。

  • 若_mapcount 等于-1,表示没有 PTE 映射到页面
  • 若_mapcount 等于 O,表示只有父进程映射到页面。匿名页面刚分配时,_mapcount 初始化为 O。例如,当 do_anonymous_page() 产生的匿名页面通过 page_add_new_anonrmap() 添加到 rmap 系统中时,会设置_mapcount 为 O,这表明匿名页面当前只有父进程的 PTE 映射到页面

RMAP

用户进程在使用虚拟内存过程中,从虚拟内存页面映射到物理内存页面时,PTE 保留了这个记录。page 数据结构中的_mapcount 记录有多少个用户 PTE 映射到物理页面。有的页面需要迁移,有的页面需要交换到磁盘,在交换之前,必须找出哪些进程使用这个页面,然后解除这些映射的用户 PTE。

RMAP 的主要目的是从物理页面的 page 数据结构中找到有哪些映射的用户 PTE,这样页面回收模块就可以很快速和高效地把这个物理页面映射的所有用户 PTE 正都解除并回收这个页面。

RMAP 的典型应用场景如下。

  • kswapd 内核线程为了回收页面,需要断开所有映射到该匿名页面的用户 PTE
  • 页面迁移时,需要断开所有映射到匿名页面的用户 PTE

分配物理页面的接口函数(扩展🙈)

内核中分配物理内存页面的常用接口函数是 alloc_pages(),它用于分配一个或者多个连续的物理页面,分配的页面个数只能是 2 的整数次幂。

分配物理页面的核心接口函数

aloc_pages() 函数的参数有两个,gfp_mask 表示分配掩码,order 表示分配级数。

1
2
3
<include/linux/gfp.h>
struct page *__alloc_pages(gfp_t gfp, unsigned int order, int preferred_nid,
nodemask_t *nodemask);

alloc_paes() 函数用来分配 2 的 order 次幂个连续的物理页面,返回值是第一个物理页面 page 数据结构。第一个参数是 gfp_mask:第二个参数是 order,请求的 order 需要小于 MAX_ORDER,MAX_ORDER 通常默认是 11。 另一个很常见的接口函数是__get_free_pages(),其定义如下。

1
2
3
4
5
6
7
8
9
10
<mm/page_alloc.c>
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order)
{
struct page *page;

page = alloc_pages(gfp_mask & ~__GFP_HIGHMEM, order);
if (!page)
return 0;
return (unsigned long) page_address(page);
}

__get_free_pages() 函数返回的是所分配内存的内核空间虚拟地址。如果所分配内存是线性映射的物理内存,则直接返回线性映射区域的内核空间虚拟地址。

1
static inline void *page_address(const struct page *page)

分配一个物理页面

如果需要分配一个物理页面,可以使用如下两个封装好的接口函数,它们最后仍调用 alloc_pages(),只是 order 的值为 0.

1
2
3
4
5
#define alloc_pages_vma(gfp_mask, order, vma, addr, node, false)\
alloc_pages(gfp_mask, order)

#define __get_free_page(gfp_mask) \
__get_free_pages((gfp_mask), 0)

如果需要返回一个全填充为 0 的页面,可以使用如下接口函数。

1
2
3
4
unsigned long get_zeroed_page(gfp_t gfp_mask)
{
return __get_free_pages(gfp_mask | __GFP_ZERO, 0);
}

使用 alloc_page() 分配的物理页面理论上可能被随机地填充了某些垃圾信息,因此在有些敏感的场合下需要先把分配的内存清零再使用,这样可以减少不必要的麻烦。

页面释放函数

页面释放函数主要有如下几个。

1
2
3
4
5
extern void __free_pages(struct page *page, unsigned int order);
extern void free_pages(unsigned long addr, unsigned int order);

#define __free_page(page) __free_pages((page), 0)
#define free_page(addr) free_pages((addr), 0)

释放时需要特别注意参数,传递错误的 page 指针或者错误的 order 值会引起系统崩溃。

分配掩码

分配掩码是描述页面分配方法的标志,它影响着页面分配的整个流程。因为 Linux 内核是一个通用的操作系统,所以页面分配器被设计成一个复杂的系统。它既要高效,又要兼顾很多种情况,特别是在内存紧张的情况下的内存分配。gfp_mask 其实被定义成一个 unsigned 类型的变量。

1
typedef unsigned __bitwise__ gfp_t;

gfp_mask 定义在 include/inux/gfp.h 文件中。修饰符在 Linux4.4 内核中被重新归类,大致可以分成如下几类。

  • 内存管理区修饰符(zone modifier)
  • 移动修饰符(mobility and placement modifier)
  • 水位修饰符(watermark modifier)
  • 页面回收修饰符(page reclaim modifier)
  • 行为修饰符(action modifier)

下面详细介绍各种修饰符的标志。

内存管理区修饰符的标志

内存管理区修饰符主要用于表示应当从哪些内存管理区中来分配物理内存。内存管理区修饰符使用 gfp_mask 的低 4 位来表示,其标志如下所示。

  • __GFP_DMA: 从 ZONE_DMA 中分配内存
  • __GFP_DMA32: 从 ZONE DMA32 中分配内存
  • __GFP_HIGHMEM: 优先从 ZONE_HIGHMEM 中分配内存
  • __GFP_MOVABLE: 页面可以被迁移或者回收,如用于内存规整机制

移动修饰符的标志

移动修饰符主要用于指示分配出来的页面具有的迁移属性,其标志如下所示。在 Linux2.6.24 内核中,为了解决外碎片化的问题,引入了迁移类型,因此在分配内存时需要指定所分配的页面具有哪些迁移属性。

  • __GFP_RECLAIMABLE: 在 slab 分配器中指定了 SLAB_RECLAIM_ACCOUNT 标志位,表示 slab 分配器中使用的页面可以通过收割机来回收
  • __GFP_HARDWALL: 使能 cpuset 内存分配策略
  • __GFP_THISNODE: 从指定的内存节点中分配内存,并且没有回退机制
  • __GFP_ACCOUNT: 分配过程会被 kmemcg 记录

水位修饰符的标志

水位修饰符用于控制是否可以访问系统预留的内存。所谓系统预留内存指的是最低警戒水位以下的内存,一般优先级的分配请求是不能访问它们的,只有高优先级的分配请求才能访问,如__GFP_HIGH、__GFP_ATOMIC 等。水位修饰符的标志如下所示。

  • __GFP_HIGH: 表示分配内存具有高优先级,并且这个分配请求是很有必要的,分配器可以使用系统预留的内存(即最低警戒水位线下的预留内存)
  • __GFP_ATOMIC: 表示分配内存的过程不能执行页面回收或者睡眠动作,并且具有很高的优先级,可以访问系统预留的内存。常见的一个场景是在中断上下文中分配内存
  • __GFP_MEMALLOC: 分配过程中允许访问所有的内存,包括系统预留的内存。分配内存进程通常要保证在分配内存过程中很快会有内存被释放,如进程退出或者页面回收
  • __GFP_NOMEMALLOC: 分配过程不允许访问系统预留的内存

页面回收修饰符的标志

页面回收修饰符的常用标志如下所示。

  • __GFP_IO: 允许开启 I/O 传输
  • __GFP_FS: 允许调用底层的文件系统。这个标志清零通常是为了避免死锁的发生,如果相应的文件系统操作路径上已经持有了锁,分配内存过程又递归地调用这个文件系统的相应操作路径,可能会产生死锁
  • __GFP_DIRECT_RECLAIM: 分配内存的过程中允许使用页面直接回收机制
  • __GFP_KSWAPD_RECLAIM: 表示当到达内存管理区的低水位时会唤醒 kswapd 内核线程,以异步地回收内存,直到内存管理区恢复到了高水位为止
  • __GFP_RECLAIM: 用于允许或者禁止直接页面回收和 kswapd 内核线程
  • __GFP_REPEAT: 当分配失败时会继续尝试
  • __GFP_NOFAIL: 当分配失败时会无限地尝试下去,直到分配成功为止。当分配者希望分配内存不失败时,应该使用这个标志位,而不是自己写一个 while 循环来不断地调用页面分配接口函数
  • __GFP_NORETRY: 当使用了直接页面回收和内存规整等机制还无法分配内存时,最好不要重复尝试分配了,直接返回 NULL

行为修饰符的标志

行为修饰符的常见标志如下所示。

  • __GFP_COLD: 分配的内存不会马上被使用。通常会返回一个空的高速缓存页面
  • __GFP_NOWARN: 关闭分配过程中的一些错误报告
  • __GFP_ZERO: 返回一个全部填充为 0 的页面
  • __GFP_NOTRACK: 不被 kmemcheck 机制跟踪
  • __GFP_OTHER_NODE: 在远端的一个内存节点上分配。通常在 khugepaged 内核线程中使用

前文列出了 5 大类修饰符的标志,对于内核开发者或者驱动开发者来说,要正确使用这些标志是一件很困难的事情,因此定义了一些常用的标志的组合类型标志(type flag),如下所示。类型标志提供了内核开发中常用的标志的组合,推荐开发者使用这些类型标志。

  • GFP_KERNEL: 内核分配内存常用的标志之一。它可能会被阻塞,即分配过程中可能会睡眠
  • GFP_ATOMIC: 调用者不能睡眠并且保证分配会成功。它可以访问系统预留的内存
  • GFP_NOWAIT: 分配中不允许睡眠等待
  • GFP_NOFS: 不会访问任何的文件系统的接口和操作
  • GFP_NOIO: 不需要启动任何的 I/O 操作。如使用直接回收机制丢弃干净的页面或者为 slab 分配的页面
  • GFP_USER: 通常用户空间的进程用来分配内存,这些内存可以被内核或者硬件使用。常用的一个场景是硬件使用的 DMA 缓冲器要映射到用户空间,如显卡的缓冲器
  • GFP_DMA/ GFP_DMA32: 使用 ZONE_DMA 或者 ZONE_DMA32 来分配内存
  • GFP_HIGHUSER: 用户空间进程用来分配内存,优先使用 ZONE_HIGHMEM,这些内存可以映射到用户空间,内核空间不会直接访问这些内存。另外,这些内存不能迁移
  • GFP_HIGHUSER_MOVABLE: 类似于 GFP_HIGHUSER,但是页面可以迁移
  • GFP_TRANSHUGE / GFP_TRANSHUGE_LIGHT: 通常用于透明页面分配

上面这些都是常用的类型标志,在实际使用过程中需要注意以下事项。

  • GFP_KERNEL 主要用于分配内核使用的内存,在分配过程中会引起睡眠,在中断上下文和不能睡眠的内核路径里使用该类型标志需要特别警惕,因为这会引起死锁或者其他系统异常。
  • GFP_ATOMIC 正好和 GFP_KERNEL 相反,它可以使用在不能睡眠的内存分配路径(如中断处理程序、软中断以及 tasklet 等)中。GFP_KERNEL 可以让调用者睡眠等待系统页面回收来释放一此内存,但 GFP_ATOMIC 不可以,所以可能会分配失败。
  • GFP_USER、 GFP_HIGHUSER 和 GFP_HIGHUSER_MOVABLE 这几个类型标志都是为用户空间进程分配内存的。不同之处在于,GFP_HIGHUSER 首先使用高端内存,GFP_HIGHUSER MOVABLE 优先使用高端内存并且分配的内存具有可移动属性。
  • GFP_NOIO 和 GFP_NOFS 都会产生阻塞,它们用于避免某些其他的操作。GFPNOIO 表示分配过程中绝不会启动任何磁盘 I/O 的操作。GFP_NOFS 表示可以启动磁盘 I/O,但是不会启动文件系统的相关操作。举一个例子,假设进程 A 在执行打开文件的操作中需要分配内存但内存短缺,那么进程 A 会睡眠等待,系统的 OOM Killer 机制会选择一个进程来终止。假设选择了进程 B,而进程 B 退出时需要执行一些文件系统的操作,这些操作可能会申请锁,而恰巧进程 A 持有这个锁,所以死锁就发生了。
  • GFP_KERNEL 分配的页面通常是不可迁移的,GFP_HIGHUSER_MOVABLE 分配的页面是可迁移的。

alloc_pages() 函数

下面以 GFP_KERNEL 为例,看在理想情况下 alloc_pages() 函数是如何分配物理内存的。

1
2
《分配物理内存的例子》
page = alloc_pages(GFP_KERNEL, order);

GFP_KERNEL 类型标志定义在 gfp.h 头文件中,是一个标志组合。

1
#define GFP_KERNEL	(__GFP_RECLAIM | __GFP_IO | __GFP_FS)

所以 GFP_KERNEL 类型标志包含了__GFP_RECLAIM、__GFP_IO 和__GFP_FS 这 3 个标志,其地址换算成十六进制是 0x60 00 C0。

其函数流程如下:

小结

页面分配器是 Linux 内核内存管理中最基本的分配器,伙伴系统分配页面就是使用该方法,理解页面分配器需要关注如下几个方面。

  • 从分配掩码中知道可以从哪些 zone 中分配内存,分配内存的属性属于哪些 MIGRATE_TYPES 类型。
  • 页面分配时应从哪个方向来扫描 zone。
  • zone 水位的判断。

本节介绍了理想情况下页面分配器如何分配物理页面,但是在 Linux 内核处于内存短缺的情况下又该如何分配内存呢?这涉及内存管理中最难的几个主题,如页面回收、直接内存回收内存规整和 OOM Killer 机制等。

参考文献

《奔跑吧 Linux 内核》