
引言
共享内存是操作系统进程间通信的核心机制,厘清共享内存与页表的映射关联关系,是掌握内存管理原理的关键环节。编程人员在最初学习共享内存原理时,可能存在认知偏差:混淆物理页共享与页表共享两个核心概念,错误认为共享内存会让多个进程共用同一套页表。本文基于Linux内核原生系统接口设计对照验证实验,依托实验结果拆解底层运行原理,厘清共享内存仅复用物理页帧、各进程各自维护独立页表的底层逻辑。
共享内存是多个进程直接访问同一物理内存区域以实现数据交换的机制,凭借零拷贝、无内核介入的特性成为速度最快的进程间通信方式。
共享内存的实现逻辑可概括为:内核首先创建一块连续的物理页帧,同时维护一个包含内存大小、访问权限等信息的共享内存对象,多个进程通过各自的页表,将自身虚拟地址空间中的一段连续地址,映射到该共享物理页帧,进而实现进程间的数据高效共享。
在Linux操作系统中,共享内存主要实现类型包含System V经典方式和POSIX 共享内存两种方式,本文围绕System V的传统共享技术展开后续讨论。
该共享内存方式在Linux内核(参考4.19版本源码)中的实现集中于ipc/shm.c文件,关键代码如下:
1.共享内存创建
用户进程通过shmget系统调用创建共享内存,其内核层面的实现函数为sys_shmget(位于ipc/shm.c),该函数的核心逻辑是通过shmem_kernel_file_setup或hugetlb_file_setup获得共享内存对应的文件描述符,最终创建共享内存对象(struct shmid_kernel结构体):

2.共享内存使用
共享内存的使用需要通过shmat系统调用将共享内存映射到当前进程的虚拟地址空间,通过共享内存标识符(shmid)、地址参数(shmaddr)和标志位(shmflg)控制内存段的映射方式,成功时返回映射后的内存地址指针。

基于上述理论与内核代码解读,本次实验的核心思路的是通过两个进程映射同一块共享内存,利用Linux内核提供的/proc/[pid]/pagemap接口,将两个进程的共享内存虚拟地址分别转换为物理地址,通过对比两组地址的关联关系,验证核心结论,具体思路如下:
(1)若两个进程的共享内存虚拟地址存在差异,说明它们拥有独立的虚拟地址空间,进而证明页表的独立性(页表与虚拟地址空间一一对应,虚拟地址独立则页表独立);
(2)若两个进程的虚拟地址对应的物理地址完全一致,说明它们映射到同一块物理页帧,证明物理页帧的共享性;
结合上述两点,验证“共享内存只共享物理内存,而页表为各进程独立维护”的结论。
1.编写实验
编写C语言程序shm_pagetable_test.c,一方面创建并映射共享内存,另一方面通过/proc/[pid]/pagemap接口,将共享内存的虚拟地址转换为物理地址,以此获取进程虚拟地址与物理地址的映射关系。具体代码如下:
#define _GNU_SOURCE#include <stdio.h>#include <stdlib.h>#include <sys/ipc.h>#include <sys/shm.h>#include <unistd.h>#include <fcntl.h>// 函数功能:将进程的虚拟地址转换为对应的物理地址unsigned long virt_to_phys(pid_t pid, unsigned long virt) { unsigned long pfn; // 页帧号(Page Frame Number) char pagemap_path[64]; // 构造当前进程的pagemap文件路径,用于读取页表映射信息 snprintf(pagemap_path, 64, "/proc/%d/pagemap", pid); // 以只读模式打开pagemap文件,打开失败则报错并退出 int fd = open(pagemap_path, O_RDONLY); if (fd == -1) { perror("open pagemap failed"); exit(EXIT_FAILURE); } // 计算虚拟地址对应的pagemap文件偏移量(每个虚拟页对应8字节数据) unsigned long offset = (virt / 4096) * 8; lseek(fd, offset, SEEK_SET); // 将文件指针移动到目标偏移量位置 unsigned long data; read(fd, &data, 8); // 读取8字节数据,包含页帧号等信息 close(fd); // 关闭文件,释放资源 // 取出页帧号(屏蔽高9位,保留低55位有效页帧号) pfn = data & ((1UL << 55) - 1); // 计算物理地址:页帧号×页大小(4KB) + 虚拟地址在页内的偏移量 return pfn * 4096 + (virt % 4096);}int main() { // 1. 创建共享内存:键值1234,大小4096字节(1个虚拟页),权限0666(可读可写) int shmid = shmget(1234, 4096, IPC_CREAT | 0666); if (shmid == -1) { perror("shmget failed"); // 创建失败则报错并退出 exit(EXIT_FAILURE); } // 2. 将共享内存映射到当前进程的虚拟地址空间,映射地址由内核自动分配 void *shmaddr = shmat(shmid, NULL, 0); if (shmaddr == (void *)-1) { perror("shmat failed"); // 映射失败则报错,销毁共享内存后退出 shmctl(shmid, IPC_RMID, NULL); exit(EXIT_FAILURE); } // 输出当前进程ID与共享内存的虚拟地址,便于后续对比 printf("[PID %d] 共享内存虚拟地址 = %p\n", getpid(), shmaddr); // 3. 向共享内存写入数据(字符'A'),触发物理页分配(避免虚拟地址未关联物理页) *(char *)shmaddr = 'A'; // 4. 调用virt_to_phys函数,将虚拟地址转换为物理地址并输出 unsigned long phys = virt_to_phys(getpid(), (unsigned long)shmaddr); printf("[PID %d] 对应物理地址 = 0x%lx\n\n", getpid(), phys); // 等待用户输入,保持进程运行,便于观察两个进程的地址映射关系 printf("按回车退出...\n"); getchar(); // 5. 实验结束:解除共享内存映射,删除共享内存对象,释放资源 shmdt(shmaddr); shmctl(shmid, IPC_RMID, NULL); return 0;} |
使用GCC编译工具对shm_pagetable_test.c进行编译,生成可执行文件shm_test:
gcc shm_pagetable_test.c -o shm_test |
本次实验需要两个进程同时映射同一块共享内存,由此对比两个进程的虚拟地址与物理地址关系,故需打开两个终端分别执行如下步骤:
./shm_test |
两个进程启动后,分别记录终端输出的进程ID、共享内存虚拟地址和物理地址,用于后续的结果分析与结论验证。
2.结果分析
实验在标准环境下运行后,两个终端的典型输出如下(进程ID和具体地址会因系统环境略有差异,但核心规律一致):
终端1
[PID 19343] 共享内存虚拟地址 = 0x7fb2969f2000[PID 19343] 对应物理地址 = 0x5018d000按回车退出... |
终端2
[PID 19808] 共享内存虚拟地址 = 0x7f13bb2b8000[PID 19808] 对应物理地址 = 0x5018d000 按回车退出... |
从上述输出结果中,可清晰地观察到两个现象:一是两个进程的共享内存虚拟地址完全不同(分别为0x7fb2969f2000和0x7f13bb2b8000);二是两个不同的虚拟地址,对应的物理地址完全相同(均为0x5018d000)。因此以下内容得到验证:
(1)虚拟地址不同:验证了页表的独立性
Linux操作系统的核心特性之一是为每个进程分配独立的虚拟地址空间,虚拟地址空间的分配由内核统一管理,不同进程的虚拟地址空间相互隔离、互不干扰。由于页表是维护虚拟地址与物理地址映射关系的核心数据结构,且与虚拟地址空间一一对应,也就是说不同的虚拟地址空间,分别对应不同的独立的页表。
测试中两个进程的共享内存虚拟地址存在明显差异,说明它们拥有独立的虚拟地址空间,进而验证了“每个进程拥有专属页表,页表相互独立”的,页表并未被多个进程共享。
(2)物理地址相同:验证了物理页帧的共享性
物理地址是物理内存的真实地址,每个物理地址对应唯一的物理页帧。不同进程的虚拟地址映射到同一个物理地址,说明这两个进程共享同一块物理页帧,即共享内存的核心是物理页帧的共享。
综上,内核创建共享内存时,会分配一块连续的物理页帧并维护共享内存对象,两个进程通过各自的页表,将自身的虚拟地址映射到该物理页帧,从而实现数据共享。测试中两个进程的虚拟地址对应同一个物理地址,验证了共享内存的共享的是物理内存,而页表独立。
为更好理解共享内存的原理,我们在测试环境开展了模拟实验:通过上调数据库共享内存配置,使其大小超过操作系统预留的内存大页存储空间上限,从而导致操作系统发生页表内存开销持续增长,整体内存使用率持续增高的异常现象。我们结合本次验证的“共享内存仅共享物理页帧,而页表为各进程独立维护”论述,以及Linux大页机制与数据库的共享内存特性,针对数据库内存容量超过预留内存大页空间总量的场景,详细分析页表内存开销持续增长的原因。
数据库的共享内存区域,主要用于存储数据缓存、日志缓存、共享SQL区等数据,是数据库进程通过shmget、shmat系统调用创建的共享内存段。数据库运行时会启动多个进程(如数据写、日志写等后台进程及用户会话进程),所有进程均通过各自的页表,映射到同一块对应的物理共享内存区,实现数据的高效共享。
Linux大页机制,通过设置较大的内存页大小(如2MB、1GB),大幅度减少页表项数量。如1个2MB大页对应512个4KB小页,仅需1个PMD页表项即可完成映射,而同等大小的内存若使用4KB小页,则需要512个PTE页表项。数据库部署中,通常会配置大页空间,将共享内存区完整映射到预分配的大页中,以此降低页表开销、提升TLB(快表)命中率,优化内存访问性能。
然而,页表的内存占用量与页表项数量呈正相关,页表项数量越多,页表占用的内存空间越大;而页表项数量由“虚拟地址范围 * 内存页大小”共同决定的,也就说明单页的粒度越小(如4KB小页),相同虚拟地址范围所需的页表项数量越多,页表体积也就越大。当数据库共享内存区大小超过预分配的内存大页空间总量时,共享内存区无法完全映射到预分配的大页中。结合前文的测试情况,页表持续增长的原因主要为:
1.大页不足导致小页使用,从而页表项数量激增
由于Linux系统采用了显示预分配的大页管理,预留的大页空间总量固定,当数据库共享内存区大小超过该总量时,超出部分的内存无法使用大页进行映射,数据库将采用默认的4KB小页对超出部分进行映射。那么进程映射内存时,会为每一个虚拟页创建独立的页表项,且页表为进程私有,无法复用。
比如1个2MB大页仅需1个PMD页表项即可完成虚拟地址到物理地址的映射,而同等大小(2MB)的内存若使用4KB小页进行映射,则需要512个PTE页表项,页表项数量呈512倍增长。当共享内存区超出大页空间的部分越大,所需的4KB小页数量越多,对应的页表项数量也就越多;且每个数据库进程都拥有独立的页表,所有进程的页表项都会同步增加,直接导致系统整体页表占用内存持续增长。
2.页表进程私有性,导致内存空间开销随会话数增长而增加
根据前面的实验测试,页表是进程私有,不同进程的页表之前相互独立,即便多个进程映射到同一块物理内存空间,也会各自维护独立的页表项,不存在页表项复用的情况。数据库运行过程中,会启动多个进程(包括后台服务进程与用户会话进程),进程都需要映射共享内存,以实现数据交互与共享。
当共享内存超出大页空间、启用小页映射后,每个数据库进程都需要为超出部分的小页创建独立的页表项。例如,1个用户进程需为超出部分的1GB内存(对应262144个4KB小页)创建262144个页表项,若系统存在10个活跃用户进程,则需创建2621440个PTE页表项,页表开销随外部会话的连接增加,呈线性增长,从而导致页表持续增长。反之,若共享内存大小完全适配预分配的大页空间,每个数据库进程仅需少量页表项即可完成映射,页表开销会大幅降低且保持稳定。
本文通过设计针对性的验证实验,借助Linux内核提供的接口,确证了“共享内存仅共享物理页帧,而页表为各进程独立维护”的结论,并基于此原理解析了当数据库共享内存区配置超出预留大页总量时,系统页表空间持续膨胀的本质原因——即大页不足迫使数据库分配共享内存回退至普通4KB页,导致页表项数量急剧攀升,最终引发页表内存的异常增长。然而,共享内存与大页管理仅是Linux内核内存子系统的局部缩影,进程调度、I/O栈、文件系统缓存等深层机制同样深刻影响着数据库的整体表现。未来,我们将持续结合实践场景,深入探索内核各子系统的交互规律,积累故障根因分析与性能调优的经验,从而为业务系统高效、稳定运行提供更坚实的技术保障。
多年从事系统运维管理相关工作,目前主要负责Linux操作系统领域版本、补丁、镜像等管理工作。


日常负责AI算力平台的建设与运维,保障模型训练与推理的算力调度与优化。

