目录
物理内存热插拔
从 CPU 角度看物理内存架构
内核如何管理 NUMA 节点
NUMA 节点物理内存区域的划分
NUMA 节点的状态 node_states
物理内存区域中的水位线
物理内存区域中的冷热页
内核如何描述物理内存页
匿名页的反向映射
物理内存热插拔
-
物理热插拔阶段:这个阶段主要是从物理上将内存硬件插入(hot-add),拔出(hot-remove)主板的过程,其中涉及到硬件和内核的支持。
-
逻辑热插拔阶段:这一阶段主要是由内核中的内存管理子系统来负责,涉及到的主要工作为:如何动态的上线启用(online)刚刚 hot-add 的内存,如何动态下线(offline)刚刚 hot-remove 的内存。
-
但是这里会有一个问题,就是并非所有的物理页都可以迁移,因为迁移意味着物理内存地址的变化,而内存的热插拔应该对进程来说是透明的,所以这些迁移后的物理页映射的虚拟内存地址是不能变化的。
这一点在进程的用户空间是没有问题的,因为进程在用户空间访问内存都是根据虚拟内存地址通过页表找到对应的物理内存地址,这些迁移之后的物理页,虽然物理内存地址发生变化,但是内核通过修改相应页表中虚拟内存地址与物理内存地址之间的映射关系,可以保证虚拟内存地址不会改变
既然是这些不可迁移的物理页导致内存无法拔出,那么我们可以把内存分一下类,将内存按照物理页是否可迁移,划分为不可迁移页,可回收页,可迁移页。
大家这里需要记住一点,内核会将物理内存按照页面是否可迁移的特性进行分类,笔者后面在介绍内核如何避免内存碎片的时候还会在提到
然后在这些可能会被拔出的内存中只分配那些可迁移的内存页,这些信息会在内存初始化的时候被设置,这样一来那些不可迁移的页就不会包含在可能会拔出的内存中,当我们需要将这块内存热拔出时, 因为里边的内存页全部是可迁移的, 从而使内存可以被拔除。
从 CPU 角度看物理内存架构
UMA架构:
-
总线的带宽压力会越来越大,随着 CPU 个数的增多导致每个 CPU 可用带宽会减少
-
总线的长度也会因此而增加,进而增加访问延迟
在 NUMA 架构下,内存就不是一整片的了,而是被划分成了一个一个的内存节点 (NUMA 节点),每个 CPU 都有属于自己的本地内存节点,CPU 访问自己的本地内存不需要经过总线,因此访问速度是最快的。当 CPU 自己的本地内存不足时,CPU 就需要跨节点去访问其他内存节点,这种情况下 CPU 访问内存就会慢很多。
在 NUMA 架构下,任意一个 CPU 都可以访问全部的内存节点,访问自己的本地内存节点是最快的,但访问其他内存节点就会慢很多,这就导致了 CPU 访问内存的速度不一致,所以叫做非一致性内存访问架构。
CPU 和它的本地内存组成了 NUMA 节点,CPU 与 CPU 之间通过 QPI(Intel QuickPath Interconnect)点对点完成互联,在 CPU 的本地内存不足的情况下,CPU 需要通过 QPI 访问远程 NUMA 节点上的内存控制器从而在远程内存节点上分配内存,这就导致了远程访问比本地访问多了额外的延迟开销(需要通过 QPI 遍历远程 NUMA 节点)。
内核如何管理 NUMA 节点
在内核中是如何将这些 NUMA 节点统一管理起来的?struct pglist_data 这样的一个数据结构来描述 NUMA 节点,在内核 2.4 版本之前,内核是使用一个 pgdat_list 单链表将这些 NUMA 节点串联起来的。在内核 2.4 之后的版本中,内核移除了 struct pglist_data 结构中的 pgdat_next 之指针, 同时也删除了 pgdat_list 单链表。取而代之的是,内核使用了一个大小为 MAX_NUMNODES ,类型为 struct pglist_data 的全局数组 node_data[] 来管理所有的 NUMA 节点。
typedef struct pglist_data {
// NUMA 节点id
int node_id;
// 指向 NUMA 节点内管理所有物理页 page 的数组
struct page *node_mem_map;
// NUMA 节点内第一个物理页的 pfn
unsigned long node_start_pfn;
// NUMA 节点内所有可用的物理页个数(不包含内存空洞)
unsigned long node_present_pages;
// NUMA 节点内所有的物理页个数(包含内存空洞)
unsigned long node_spanned_pages;
// 保证多进程可以并发安全的访问 NUMA 节点
spinlock_t node_size_lock;
.............
}
NUMA 节点物理内存区域的划分
NUMA 节点的状态 node_states
如果系统中的 NUMA 节点多于一个,内核会维护一个位图 node_states,用于维护各个 NUMA 节点的状态信息。 表示 NUMA 节点在某个时刻可以变为 online 状态,N_ONLINE 表示 NUMA 节点当前的状态为 online 状态。
物理内存区域中的水位线
物理内存区域中的冷热页
内核如何描述物理内存页
-
一种是匿名页,匿名页背后并没有一个磁盘中的文件作为数据来源,匿名页中的数据都是通过进程运行过程中产生的,匿名页直接和进程虚拟地址空间建立映射供进程使用。
-
另外一种是文件页,文件页中的数据来自于磁盘中的文件,文件页需要先关联一个磁盘中的文件,然后再和进程虚拟地址空间建立映射供进程使用,使得进程可以通过操作虚拟内存实现对文件的操作,这就是我们常说的内存文件映射。
匿名页的反向映射
我们通常所说的内存映射是正向映射,即从虚拟内存到物理内存的映射。而反向映射则是从物理内存到虚拟内存的映射,用于当某个物理内存页需要进行回收或迁移时,此时需要去找到这个物理页被映射到了哪些进程的虚拟地址空间中,并断开它们之间的映射。
struct anon_vma_chain 结构通过其中的 vma 指针和 anon_vma 指针将相关的匿名页与其映射的进程虚拟内存空间关联了起来。从目前来看匿名页 struct page 算是与 anon_vma 建立了关系,又通过 anon_vma_chain 将 anon_vma 与 vm_area_struct 建立了关系。那么就剩下最后一道关系需要打通了,就是如何通过 anon_vma 找到 anon_vma_chain 进而找到 vm_area_struct 呢?这就需要我们将 anon_vma 与 anon_vma_chain 之间的关系也打通。
我们知道每个匿名页对应唯一的 anon_vma 结构,但是一个匿名物理页可以映射到不同进程的虚拟内存空间中,每个进程的虚拟内存空间都是独立的,也就是说不同的进程就会有不同的 VMA。
不同的 VMA 意味着同一个匿名页 anon_vma 就会对应多个 anon_vma_chain。那么如何通过一个 anon_vma 找到和他关联的所有 anon_vma_chain 呢?找到了这些 anon_vma_chain 也就意味着 struct page 找到了与它关联的所有进程虚拟内存空间 VMA。
我们重点来看 struct anon_vma 结构中的 rb_root 字段,struct anon_vma 结构中管理了一颗红黑树,这颗红黑树上管理的全部都是与该 anon_vma 关联的 anon_vma_chain。我们可以通过 struct page 中的 mapping 指针找到 anon_vma,然后遍历 anon_vma 中的这颗红黑树 rb_root ,从而找到与其关联的所有 anon_vma_chain。
到目前为止,物理内存页 page 到与其映射的进程虚拟内存空间 VMA,这样一种一对多的映射关系现在就算建立起来了。
vm_area_struct 表示的只是进程虚拟内存空间中的一段虚拟内存区域,这块虚拟内存区域中可能会包含多个匿名页,所以 VMA 与物理内存页 page 也是有一对多的映射关系存在。而这个映射关系在哪里保存呢?
大家注意 struct anon_vma_chain 结构中还有一个列表结构 same_vma,从这个名字上我们很容易就能猜到这个列表 same_vma 中存储的 anon_vma_chain 对应的 VMA 全都是一样的,而列表元素 anon_vma_chain 中的 anon_vma 却是不一样的。内核用这样一个链表结构 same_vma 存储了进程相应虚拟内存区域 VMA 中所包含的所有匿名页。
到现在为止还缺关键的最后一步,就是打通匿名内存页 page 到 vm_area_struct 之间的关系,首先我们就需要调用 alloc_zeroed_user_highpage_movable 方法从伙伴系统中申请一个匿名页。当获取到 page 实例之后,通过 page_add_new_anon_rmap 最终建立起 page 到 vm_area_struct 的整条反向映射链路。
如果当前物理内存页 struct page 是一个匿名页的话,那么 mapping 指针的最低位会被设置为
1
, 指向该匿名页在进程虚拟内存空间中的匿名映射区域 struct anon_vma 结构(每个匿名页对应唯一的 anon_vma 结构),用于物理内存到虚拟内存的反向映射。
如果当前物理内存页 struct page 是一个文件页的话,那么 mapping 指针的最低位会被设置为
0
,指向该内存页关联文件的 struct address_space(页高速缓存)。pgoff_t index 字段表示该内存页 page 在页高速缓存中的 index 索引,也表示该内存页中的文件数据在文件内部的偏移 offset。偏移单位为 page size。
内核可以通过这个技巧直接检查 page 结构中的 mapping 指针的最低位来判断该物理内存页到底是匿名页还是文件页。
struct page {
struct address_space *mapping;
pgoff_t index;
// 表示该 page 映射了多少个进程的虚拟内存空间,一个 page 可以被多个进程映射
atomic_t _mapcount
}
经过本小节详细的介绍,我想大家现在已经猜到 _mapcount 字段的含义了,我们知道一个物理内存页可以映射到多个进程的虚拟内存空间中,比如:共享内存映射,父子进程的创建等。page 与 VMA 是一对多的关系,这里的 _mapcount 就表示该物理页映射到了多少个进程的虚拟内存空间中。
内存回收的关键是如何实现一个高效的页面替换算法 PFRA (Page Frame Replacement Algorithm) ,提到页面替换算法大家可能立马会想到 LRU (Least-Recently-Used) 算法。LRU 算法的核心思想就是那些最近最少使用的页面,在未来的一段时间内可能也不会再次被使用,所以在内存紧张的时候,会优先将这些最近最少使用的页面置换出去。在这种情况下其实一个 active 链表就可以满足我们的需求。
四种 LRU 链表(匿名页的 active 链表,inactive 链表和文件页的active 链表, inactive 链表)之外,内核还有一种链表,比如进程可以通过 mlock() 等系统调用把内存页锁定在内存里,保证该内存页无论如何不会被置换出去,比如出于安全或者性能的考虑,页面中可能会包含一些敏感的信息不想被 swap 到磁盘上导致泄密,或者一些频繁访问的内存页必须一直贮存在内存中。
工作原理如下:
-
首先 inactive 链表的尾部存放的是访问频率最低并且最少访问的页面,在内存紧张的时候,这些页面被置换出去的优先级是最大的。
-
对于文件页来说,当它被第一次读取的时候,内核会将它放置在 inactive 链表的头部,如果它继续被访问,则会提升至 active 链表的尾部。如果它没有继续被访问,则会随着新文件页的进入,内核会将它慢慢的推到 inactive 链表的尾部,如果此时再次被访问则会直接被提升到 active 链表的头部。大家可以看出此时页面的使用频率这个因素已经被考量了进来。
-
对于匿名页来说,当它被第一次读取的时候,内核会直接将它放置在 active 链表的尾部,注意不是 inactive 链表的头部,这里和文件页不同。因为匿名页的换出 Swap Out 成本会更大,内核会对匿名页更加优待。当匿名页再次被访问的时候就会被被提升到 active 链表的头部。
-
当遇到内存紧张的情况需要换页时,内核会从 active 链表的尾部开始扫描,将一定量的页面降级到 inactive 链表头部,这样一来原来位于 inactive 链表尾部的页面就会被置换出去。
内核在回收内存的时候,这两个列表中的回收优先级为:inactive 链表尾部 > inactive 链表头部 > active 链表尾部 > active 链表头部。
参考文献
一步一图带你深入理解 Linux 物理内存管理