2022年7月

最近看内存冷热页面替换方向的论文的时候,看到有篇论文 Transparent Hardware Management of Stacked DRAM as Part of Memory 提出了一个有趣的想法,即以段为单位监测内存访问的热点情况,并为每个段分配固定大小的高速缓存。虽然这个方法具有一定的局限性,即没有考虑到段本身的冷热程度,在效率上并不能达到最优,但它成功唤起了我对于内存管理的思考。

根据计算机组成原理,通常认为内存管理是段页式的,其中段式管理提供逻辑上的内存划分(同一段内存具有相同的语义,即同属一个应用程序,或者同为代码/数据),页式管理提供物理上的内存划分(操作系统级别的最小内存管理单位,如分配/虚拟内存的换入换出等)。

然而,事与愿违,当试图在网上搜索的时候,发现大家都在说,x86和x64的操作系统放弃了使用段寄存器,而intel甚至在x64指令集中要求,将段寄存器置为0才是合理的使用方式。

那么事实为何如此呢?根据从网络上收集到的信息来看,可能主要与指令集设计者想甩掉段式管理这个历史包袱有关。早期处理器存在的问题是,处理器的寄存器宽度只有16位,而寻址范围的宽度可以上升到20位,为了应对处理器无法直接寻址超过寄存器宽度的内存地址的问题,加入了段寄存器,通过段寄存器移位的方式,对于数据段/代码段等段寄存器,扩充了程序可使用的内存地址空间。然而寄存器(指令集)大小是指数增长的,光是从16位升级到32位,就让内存花了数十年的时间才重新扩充满整个地址空间,推动64位指令集的刚需化发展。也就是说,从32位指令集开始,内存地址空间的扩张速度就已经远远追不上寻址空间的发展速度了——从这个角度来看的话,既然段式管理解决的是内存空间大于寻址空间的问题,那么在内存空间远小于寻址空间的情况下,段式内存管理似乎就没有存在的必要了(参考StackOverflow)。而从32位指令集开始,段寄存器中的使用方式已经不是之前的基地址移位了,而是变成了段表项,转换成了通过查表的方式取得段的基地址。

于是,一方面为了保障向前兼容性,另一方面为甩开包袱做好准备,指令集设计者和操作系统设计者在这个问题上默契地采用了同一种方法来应对这个问题:不取消段寄存器,但是要求所有的段的基地址都从0x00000000开始。反正指令集的寻址空间足够大,那么只要我将整个地址空间都视作一个段,不就相当于架空了段寄存器的作用,进而消除了段式管理在操作系统中的应用么?

知乎有一文对于在现代操作系统中的管理设计做了实际验证,通过各种骚操作印证了这个事实。不过文中有一处写的不够清楚,在Linux的gdt_table那里,GDT是如何从一串不规则的值映射到像0x00x1F这类连续0/1的数字的。这个实际上是因为GDT中的变量不是连续存放的,可能是出于硬件设计对数据做了切分,按照OSDev上的Segment Descriptor做一些简单的运算后,就能验证实际上这些变量都是具有正确的含义的。

看完这些信息后,总感觉我之前对段式内存管理的理解有些不太正确。以前一直以为,段式内存管理像malloc一样,要多少分多少,属于一种与软件相关的逻辑定义;现在才发现,段式管理更多的是一种硬件资源管理方式,段的大小似乎是由体系结构所决定的,也没有软件那种随用随分、即时释放的灵活性。

最后总结一下,由于现在寻址空间远大于内存空间,段页式内存管理中的段式管理逐渐失去其作用,被软硬件所淘汰,主流操作系统中主要依靠页式内存管理和多级页表(应用于虚拟内存)来管理内存空间。

同学跑程序要用大页来提升性能,然后发现在机房中的某些机器上能跑起来,而另外一些不行,然后就找到我这debug来了。表现出来的症状是,调用shmget生成共享内存id时直接报ENOMEM问题。

回顾一下计算机基础知识,页是定长、连续的内存物理结构,而大页区别于普通页最大的区别就是其大小,于是首先想到的是在内存分配上是否会因为没有足够长的连续空间而出现即使内存数量足够、但是分配不出连续大空间的问题,解决该问题最直接的方法就是重启。

然而,重启过后,问题仍然存在,遂排除连续内存空间不够的可能性(然而如果是数十/百M的大页应该也不算大,内存不至于那么碎)。而后怀疑是程序本身的问题,于是把shmget操作从程序中抄出来,单独用一段代码执行:

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <wait.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/shm.h>

int main(int argc, char *argv[]) {

    int status;
    int segment_id;

    const int SEGMENT_SIZE = 0x1234;

    segment_id = shmget (IPC_PRIVATE, SEGMENT_SIZE,
                        IPC_CREAT | IPC_EXCL | 0666 | SHM_HUGETLB);

    printf("segment_id=%d\n", segment_id);
    perror("");

    shmctl(segment_id, IPC_RMID, 0);
    exit(EXIT_SUCCESS);
}

由于我们只需要验证共享内存是否可以创建,而不是是否可以使用,因而后面的shmat/shmdt都省去了。运行后发现,ENOMEM错误仍然存在。

然后思路又回到大页这方面,猜测是不是SHM_HUGETLB参数导致的共享内存分配失败,编译执行发现程序运行正常,然后就想到了用SHM_HUGETLB作为关键字上网冲浪。然后在StackOverflow上发现有人遇到了相似的问题,说是因为总大页数达到了系统设置的上限。然后顺着问题中的思路检查机器,用sysctl -a发现了问题:对比两台分别可否运行程序的机器,发现有个叫vm.nr_hugepages的参数,看起来和大页的数量有关,在不能运行同学程序的机器上,该参数值为0,而可以运行的机器上参数值为16384。

然后就去网上搜索这个参数到底是什么含义。经过艰难摸索,在官方文档里找到了描述:

/proc/sys/vm/nr_hugepages indicates the current number of “persistent” huge pages in the kernel’s huge page pool. “Persistent” huge pages will be returned to the huge page pool when freed by a task. A user with root privileges can dynamically allocate more or free some persistent huge pages by increasing or decreasing the value of nr_hugepages.

大意是说大页是有单独的内存池的,而这个nr_hugepages可以通过控制允许创建的大页数目来控制大页内存池的大小,因而合理推测当该内存池大小为0的时候,等价于实际上禁用了大页功能。于是通过使用sysctl命令,临时设置大大页内存池的大小:

sysctl vm.nr_hugepages=1

测试后发现问题解决。

然后继续往下阅读文档发现,似乎还有个选项,允许程序运行时动态调整大页内存池的大小,而不至于“撞墙”:

/proc/sys/vm/nr_overcommit_hugepages specifies how large the pool of huge pages can grow, if more huge pages than /proc/sys/vm/nr_hugepages are requested by applications. Writing any non-zero value into this file indicates that the hugetlb subsystem is allowed to try to obtain that number of “surplus” huge pages from the kernel’s normal page pool, when the persistent huge page pool is exhausted. As these surplus huge pages become unused, they are freed back to the kernel’s normal page pool.

大意是说往/proc/sys/vm/nr_overcommit_hugepages里面写任意一个非0值就会开启这个功能,允许当大页内存池的固定内存用完时弹性借用普通内存池的内存(未经测试)。