写点什么

DPDK 大页内存原理

用户头像
赖猫
关注
发布于: 2021 年 02 月 18 日

在分析 dpdk 大页内存的源码之前,有必要对 linux 内存管理的原理以及大页内存的原理有个了解,缺少这些底层基础知识,分析 dpdk 大页内存的源码将举步维艰。这篇文章详细介绍下 linux 内存管理以及大页内存的方方面面,为分析 dpdk 大页内存源码扫除障碍。


1、linux 内存管理原理


1.1、mmu 内存管理的引入


在没有引入 mmu 内存管理单元时,对于 32 位操作系统,每个进程都有 2 的 32 次方的地址空间(4G)。如果进程 A 占用内存 0x1000---0x2000 物理地址空间, 而进程 B 也占用内存 0x1000---0x2000 物理地址空间,这是完全有可能的。当进程 A 加载执行时,则进程 B 将不能被加载执行。一旦进程 B 被加载执行,则将会破坏进程 A 的物理地址空间。为了解决这个问题,linux 操作系统和 CPU 都做了修改,添加了 mmu 内存管理。

在引入了 mmu 内存管理单元后, 每个进程访问的地址不在是内存中的物理地址,而是虚拟地址。进程 A 被加载物理内存 0x5000----0x6000 物理地址空间; 进程 B 被加载到物理内存 0x7000---0x8000 物理地址空间。同时进程 A 与进程 B 各自都建立了一个虚拟地址到物理地址的映射表。 当 cpu 执行进程 A 时,会使用进程 A 的地址映射表,例如 cpu 读取 0x1000 虚拟地址,查询进程 A 的地址映射表后,发现虚拟地址映射到物理内存中的 0x5000 位置;当 cpu 执行进程 B 时,会使用进程 B 的地址映射表,例如 cpu 读取 0x1000 虚拟地址,查询进程 B 的地址映射表后,发现虚拟地址映射到物理内存中的 0X7000 位置。这样就可以避免之前提到的内存冲突问题。有了 mmu 内存管理单元,linux 就可以轻松实现多任务了。


地址映射表的表项是一个虚拟地址对应一个物理地址, 每个进程用于页表的维护就需要占用太多的内存空间。为此,需要修改映射方式,常用的有三种:页式、段式、段页式,这也是三种不同的 mmu 内存管理方式。这里主要讨论页表的实现。


1.2、页表的演化


1.2.1、一级页表


上面已经讨论过了,如果每个进程的地址映射表表项存储的是每个虚拟地址到物理地址的映射,则需要消耗非常多的物理内存来维护每个进程的映射表。 因此 linux 系统引入了分页内存管理,分页内存管理将虚拟内存、物理内存空间划分为大小固定的块,每一块称之为一页,以页为单位来分配、管理、保护内存 , 默认一个页的大小是 4K。 假设物理内存 4G,物理内存一共可以划分: 4 * 1024 *1024 / 4 = 1048576 个大页。对于每个进程来说, 都有一个页表,维护着虚拟地址到物理地址的转换关系。对于 32 位的系统来说,每个进程可寻址的逻辑地址范围 0---2 的 32 次方(4G);因此每个进程 4G 的逻辑地址空间也按照 4K 大小来划分页, 也就是 1048576 个页。需要注意的是每个进程的的逻辑页,是有可能映射到同一个物理页的。



对于进程的每一个逻辑地址,低 12 位表示在某个页的偏移, 剩余的 12---31 表示这个逻辑地址处于的虚拟页号。例如:0x2009 逻辑地址,0--12 位值为 9, 12 -- 31 位值为 0x2000, 每个页大小为 4K, 则这个逻辑地址处于的虚拟页号为: 8192(0x2000 的十进制) / 4096 = 2; 页内偏移为 9。



上面提到的虚拟地址到物理地址的映射表, 其实也就是页表。每个进程都有各自独立的页表。查询的时候就是将虚拟地址的 11-31 位当做虚拟页号,查找进程自己的页表, 进而找到物理内号。 然后根据虚拟地址的 0-11 位作为页内偏移。最后根据物理地址的计算公式: 物理地址 = 物理页号 * 4K + 页内偏移


需要注意的是:


在进程申请内存空间的时候,将创建这个页表。是否有疑问,32 位系统每个进程都有 4G 的寻址空间, 那对于物理内存一共就只有 4G 的空间,对于 4K 的分页,则每个进程的页表一共有 4 * 1024 *1024 / 4 = 1048576 个条目,每个条数占用 4 字节,最终每个进程维护页表都需要占用:1048576 * 4 = 4194304 字节, 也就是 4M,那如果有几百个进程, 为了维护每个进程所需要的页表,就把内存耗光了,非常消耗资源。

0-1G 的内存空间被系统使用了, 应用进程只能申请 1G 以后的内存空间。如果 linux 内存管理机制是按照这种一级线性页表来实现, 则在进程申请内存空间时,将为进程创建所有页表项,而实际上每个进程没有占用那么多空间, 例如上面的两个进程 A, B, 页表都只有 3 个真实使用的条目,然而 linux 还是会为这个进程维护 4M 的连续页表空间,这页表空间不能分布在内存中的不同位置。显然这个进程很多页表项都没使用,浪费了很多内存空间。

每个进程的多级页表不一定就存放到内存中,当内存不足时,是有可能被交换到磁盘 swap 分区中。在 Linux 中, kswapd 是负责内核页面交换管理的一个守护进程,它的职责是保证 Linux 内存管理操作的高效。当物理内存不够时,它就会变得非常活跃。 kswapd 进程负责确保内存空间总是在被释放中,它监控内核中的 pages_high 和 pages_low 阀值。如果空闲内存的数值低于 pages_low, 则每次 kswapd 进程启动扫描并尝试释放 32 个 free pages.并一直重复这个过程,直到空闲内存的数值高于 pages_high


1.2.2 、多级页表


每个进程都有一个页表, 分页表有很多种实现方式,最简单的一种分页表就是把所有的对应关系记录到同一个线性列表中,即之前提到的一级页表。这种单一的连续分页表,需要给每一个虚拟页预留一条记录的位置,页表需要占用连续的内存空间,不能分布在内存中的不同位置。但对于任何一个应用进程,其进程空间真正用到的地址都相当有限。我们还记得,进程空间会有栈和堆。进程空间为栈和堆的增长预留了地址,但栈和堆很少会占满进程空间。这意味着,如果使用连续分页表,很多条目都没有真正用到,浪费很多的页表空间。因此,Linux 中的分页表,最终是采用了多层的分页结构,多层的分页表能够减少所需的空间。这样有什么好处呢?可以支持更多的进程跑在系统上,直到内存不够用为止。我们以二级分页设计,用以说明 Linux 的多层分页表



二级分页结构,虚拟地址将分为三部分。0-11 仍然没有变化,指的是页内偏移; 将 12-31 位拆分为 2 部分,12-21 为二级页表号;22-31 为一级页表号。这跟字典的目录结构,或者数据库中的索引设计思想是一样的。一级页表中每个页表项 key 为虚拟地址的 22-31 位,也就是一级页表号, 而 value 存放的是二级页表的位置,一级页表项一共有 1024 个。每个二级页表中,每个页表项 key 为虚拟地址的 12-21 位,也就是二级页表号, 而 value 存放的是物理页号,每个二级页表项也一共有 1024 个。二级表有很多张,每个二级表分页记录对应的虚拟地址前 10 位都相同,比如二级表 0x001,里面记录的前 10 位都是 0x001


地址查询的过程要跨越两级,需要多次查找内存。我们先取地址的前 10 位,也就是一级页表号,在一级页表中找到对应记录。该记录会告诉我们,目标二级页表在内存中的位置。我们接着在二级页表中,通过虚拟地址的 12-21 位,也就是二级页表号,找到分页记录,从而最终找到物理页号。最后根据物理地址的计算公式: 物理地址 = 物理页号 * 4K + 页内偏移


多层分页表还有另一个优势。单层分页表必须存在于连续的内存空间。而多层分页表中的每个二级页表,可以分布在内存的不同位置,如果需要为这个进程创建一个新的二级页表,则只需要动态开辟就好了,无需预先就为这个进程开辟好所有二级页表。这样的话,操作系统就可以利用零碎空间来存储分页表。


需要注意的是:


  • 每个进程都有一个属于自己的多级页表。

  • 每个进程的多级页表不一定就存放到内存中,在内存不足时,是有可能被交换到磁盘 swap 分区中。

  • 进程在申请内存空间时,系统将为这个进程创建二级页表,页表大小为真实条目大小。如果二级页表没有被使用,则这个二级页表不会被创建。


1.2.3、tls 查询过程


从上面多级页表的查询中可以看出,查询虚拟地址对应的物理地址时,需要多次查找物理内存。为了加速进行虚拟地址到物理地址的映射, 减少直接查询物理内存的次数,需要将部分页表信息放到 cpu 高速缓存中,也就是 TLB,本质上是内存中页表的一份快照。 当 CPU 收到应用程序发来的虚拟地址后,首先到 TLB 中查找相应的页表数据,如果 TLB 中正好存放着所需的页表项,则称为 TLB 命中(TLB Hit)。如果 TLB 中没有所需的页表项,则称为 TLB 未命中(TLB Miss),接下来就必须访问物理内存中存放的多级页表,同时更新 TLB 的页表数据



从图中可以看出,一个 32 位的虚拟地址被拆分为 2 部分。低 12 位表示在页内偏移, 12---31 表示这个逻辑地址处于的虚拟页号。tlb 表中存放的是虚拟地址 12-31 位与物理页号的对应关系。例如:0x2009 逻辑地址,0--12 位值为 9, 12 -- 31 位值为 0x2000, 每个页大小为 4K, 则这个逻辑地址处于的虚拟页号为: 8192(0x2000 的十进制) / 4096 = 2; 页内偏移为 9。因此 tlb 表中是这么存放这个虚拟地址的: tlb 某个表项中的 key 为 2,也就是这个虚拟地址的 12-31, value 值为 9,也就是这个虚拟地址的 0-11 位


TLB 查询过程

当 cpu 需要查询某个虚拟地址对应的物理地址时,首先会查找 tlb 表。 根据虚拟地址的 12---31 查找 tlb 表,如果 tlb 表存在这个表项,则 tlb 命中,在这个表项中就可以找到这个虚拟地址所在的物理页号。而 0-12 位为虚拟地址所在的某个页的页内偏移。最后根据物理地址的计算公式:???物理地址 =?物理页号 * 4K + 页内偏移?

如果在 tlb 表中查找不到这个虚拟地址对应的表项,则称为 tlb miss, 也就是 tlb 未命中。此时会在上面提到的多级页表中进行查找,在多级页表中查找完成后,同时更新到 tlb 表,下一次 cpu 再次查询这个虚拟地址时,就可以直接在 tlb 表中找到了。

TLB 命中解释

当 tlb 表中查找到相应的表项时,则称为 tlb 命中,否则称之为 tlb 未命中。在 tlb 未命中时,cpu 将产生缺页中断,之后 cpu 将进行虚拟地址到物理地址的转换,最后将转换后的结果存放到 tlb 表中。例如:应用进程申请 2M 的内存空间,每个页的大小为 4K,则 cpu 将产生 2 * 1024 / 4 = 512 次缺页中断,进行虚拟地址与物理地址的映射。2M 一共需要 512 个页表记录虚拟地址与物理地址的映射,因此将这 512 个页表更新到 tlb 表中。需要注意的是 tlb 未命中产生缺页中断,是需要消耗性能的。

TLB 老化机制

tlb 表存在 cpu 的高速缓冲,因此 tlb 的大小是有限制的,通常只能存放 512 条虚拟地址到物理地址的转换记录。因此 tlb 中不可能存放所有的多级页表信息,只会存放经常被使用的那些虚拟地址。因此当 tlb 表项中的某个虚拟地址与物理地址的转换长时间都没有被访问了,cpu 会将这条记录从 tlb 表中删除, 腾出空间给其他虚拟地址使用。

TLB 需要注意的地方

每个进程都有属于各自的多级页表, 而 tlb 表只有一个,位于 cpu 高速缓存中。 那 cpu 怎么知道 tlb 表中存放的是哪个进程对应的虚拟地址转换信息呢? 这里会引入一个 cr3 页表寄存器,存放的是某个进程的一级页表的地址。当 cpu 对某个进程提供的虚拟地址进行转换时,会将进程的一级页表地址加载到 cr3 页表寄存器, tlb 中存放这个进程对应的地址转换信息。这样 tlb 与某个进程关联起来了。


2、为什么要使用大页内存


2.1、未使用大页时页表占用的空间


以一个例子来说明为什么要使用 huge page 大页内存。假设 32 位 linux 操作系统上物理内存 100G, 每个页大小为 4K, 每个页表项占用 4 个字节, 系统上一共运行着 2000 个进程,则这 2000 个进程的页表需要占用多少内存呢?


每个进程页表项总条数: 100 * 1024 * 1024k / 4k = 26214400 条;


每个进程页表大小: 26214400 * 4 = 104857600 字节 = 100M;


2000 个进程一共需要占用内存: 2000 * 100M = 200000M = 195G


2000 个进程的页表空间就需要占用 195G 物理内存大小, 而真实物理内存只有 100G, 还没运行完这些进程,系统就因为内存不足而崩溃了,严重的直接宕机。


2.2、使用大页时页表占用的空间


还是以刚才的例子来说明。假设 32 位 linux 操作系统上物理内存 100G, 现在每个页大小为 2M, 每个页表项占用 4 个字节, 系统上一共运行着 2000 个进程,则这 2000 个进程的页表需要占用多少内存呢?


每个进程页表项总条数: 100 * 1024M / 2M = 51200 条;


每个进程页表大小: 51200 * 4 = 204800 字节 = 200K;


2000 个进程一共需要占用内存: 1 * 200k = 200K


可以看到同样是 2000 个进程,同样是管理 100G 的物理内存,结果却大不相同。使用传统的 4k 大小的 page 开销竟然会达到

惊人的 195G;而使用 2M 的 hugepages,开销只有 200K。你没有看错,2000 个进程页表总空间一共就只占用 200K, 而不是 2000 * 200K。 那是因为共享内存的缘故,在使用 hugepages 大页时, 这些大页内存存放在共享内存中, 大页表也存放到共享内存中,因此不管系统有多少个进程,都将共享这些大页内存以及大页表。因此 4k 页大小时,每个进程都有一个属于自己的页表; 而 2M 的大页时,系统只有一个大页表,所有进程共享这个大页表。


2.3、为什么要使用大页


避免使用 swap

所有大页以及大页表都以共享内存存放在共享内存中,永远都不会因为内存不足而导致被交换到磁盘 swap 分区中。而 linux 系统默认的 4K 大小页面,是有可能被交换到 swap 分区的, 大页则永远不会。通过共享内存的方式,使得所有大页以及页表都存在内存,避免了被换出内存会造成很大的性能抖动

减少页表开销

由于所有进程都共享一个大页表,减少了页表的开销,无形中减少了内存空间的占用, 使系统支持更多的进程同时运行。

减轻 TLB 的压力

我们知道 TLB 是直接缓存虚拟地址与物理地址的映射关系,用于提升性能,省去查找 page table 减少开销,但是如果出现的大量的 TLB miss,必然会给系统的性能带来较大的负面影响,尤其对于连续的读操作。使用 hugepages 能大量减少页表项的数量,也就意味着访问同样多的内容需要的页表项会更少,而通常 TLB 的槽位是有限的,一般只有 512 个,所以更少的页表项也就意味着更高的 TLB 的命中率

减轻查内存的压力

每一次对内存的访问实际上都是由两次抽象的内存操作组成。如果只要使用更大的页面,自然总页面个数就减少了,那么原本在页表访问的瓶颈也得以避免,页表项数量减少,那么使得很多页表的查询就不需要了。例如申请 2M 空间,如果 4K 页面,则一共需要查询 512 个页面,现在每个页为 2M,只需要查询一个页就好了


3、如何使用大页内存


通常情况下,Linux 默认情况下每页是 4K,这就意味着如果物理内存很大,则映射表的条目将会非常多,会影响 CPU 的检索效率。因为内存大小是固定的,为了减少映射表的条目,可采取的办法只有增加页的尺寸。因此 Hugepage 便因此而来。也就是打破传统的小页面的内存管理方式,使用大页面 2m,4m,16m,1G 等页面大小,如此一来映射条目则明显减少。如果系统有大量的物理内存(大于 8G),则无论是 32 位的操作系统还是 64 位的,都应该使用 Hugepage 。来看下 linux 系统下如何配置大页。


3.1、设置大页个数


例如系统想要设置 256 个大页,每个大页 2M,则将 256 写入下面这个文件中


 echo 256 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
复制代码


3.2、挂载大页


设置完大页后,为了让大页生效,需要挂载大页文件系统。例如将 hugetlbfs 挂载到/mnt/huge。刚挂载完时/mnt/huge 目录是空的,里面没有一个文件,直到有进程使用共享内存方式使用了这个大页系统为止,才会在这个目录下创建大页文件。


mkdir /mnt/huge
mount -t hugetlbfs nodev /mnt/huge
复制代码


3.3、查看大页信息


查看/proc/meminf,可以看到大页信息,图中可以看出一共有 256 个大页,每个大页 2M,剩余 128 个大页还没有被使用



当使用了大页的某个程序运行时,将会在/mnt/huge 目录下创建大页文件,我们可以在/mnt/huge/目录下看到所有的大页文件,一共 256 个。



3.4、大页的使用


当应用进程想要使用大页时,可以自己实现大页内存的使用方式,例如通过 mmap, shamt 等共享内存映射方式。目前 dpdk 通过共享内存的方式,打开/mnt/huge 目录下的每个大页,然后进行共享内存映射,实现了一套大页内存使用库,来替代普通的 malloc, free 系统调用。或者可以使用 libhugetlbfs.so 这个库,来实现内存的分配与释放。进程只需要链接 libhugetlbfs.so 库就好了,使用库中实现的接口来申请内存,释放内存,替代传统的 malloc,free 等系统调用。


4、进程如何获取虚拟地址对应物理地址


4.1、/proc/self 目录


在分析进程如何获取虚拟地址对应的物理地址之前,我们先来看下/proc/self 目录的意义。我们都知道可以通过/proc/$pid/来获取指定进程的信息,例如内存映射、CPU 绑定信息等等。如果某个进程想要获取本进程的系统信息,就可以通过进程的 pid 来访问/proc/$pid/目录。但是这个方法还需要获取进程 pid,在 fork、daemon 等情况下 pid 还可能发生变化。为了更方便的获取本进程的信息,linux 提供了/proc/self/目录,这个目录比较独特,不同的进程访问该目录时获得的信息是不同的,内容等价于/proc/本进程 pid/。进程可以通过访问/proc/self/目录来获取自己的系统信息,而不用每次都获取 pid


4.2、 虚拟地址和物理地址转换


任何进程可以访问/proc/self/pagemap 文件,来获取虚拟地址对应的物理地址。来看下实现过程。



pagemap 文件里面每一行占用 8 字节,每一行记录了虚拟页号对应的物理页号。


某个虚拟地址的虚拟页号计算公式: virsul_page_num = 虚拟地址 / 页大小;


在获得了虚拟地址对应的虚拟页号后,读取/proc/self/pagemap/文件中虚拟页号对应的内容 file_str。这个内容的 0-54 位记录着虚拟页号对应的真正物理页号。那如何根据虚拟页号,在文件中找到相应位置呢? 由于文件每行占用 8 个字节, 用虚拟页号乘以 8 就是虚拟页号所在文件中的对应物理页号的位置。例如虚拟页号为 2,则 2 * 8 = 16。 从文件开始偏移 16 字节的内容就是这个虚拟地址所对应的物理页号。


某个虚拟地址的物理页号计算公式: phy_page_num = file_str & 0x7fffffffffffff


虚拟地址所在页偏移计算公式: page_offset = 虚拟地址 % 页大小


虚拟地址对应物理地址计算公式为: phy_page_num * 页大小 + page_offset


Linux、C/C++技术交流群:【960994558】整理了一些个人觉得比较好的学习书籍、大厂面试题、技术教学视频资料共享在里面(包括 C/C++,Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK 等等.),有需要的可以自行添加哦!~




用户头像

赖猫

关注

还未添加个人签名 2020.11.28 加入

纸上得来终觉浅,绝知此事要躬行

评论 (1 条评论)

发布
用户头像
写的很棒
2021 年 03 月 31 日 09:09
回复
没有更多了
DPDK大页内存原理