ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

虚拟内存与共享内存:Linux页故障与进程通信实验深度解析

虚拟内存与共享内存:Linux页故障与进程通信实验深度解析 操作系统实验做到4.3基本就是到了“虚拟内存”和“进程通信”这两座大山汇合的地方。这个实验标题看着简单无非是“第一次页故障”加“父子进程共享内存通信”但实际做完你会发现它几乎把操作系统内存管理那一章的所有关键概念串起来了。页表、缺页异常、写时复制、物理页分配、地址空间隔离、共享映射、同步互斥一个实验全打了一遍。这篇文章我就按实际操作顺序来写从页故障的原理怎么验证到共享内存通信怎么设计再到踩坑记录尽量还原整个实验的思考过程。适合正在做操作系统实验的本科生也适合那些概念背了一堆但没真正在Linux里跑过缺页统计的同学哪怕你不是计算机专业的只要在学《操作系统》这门课这个实验都值得动手做一遍。1. 实验拆解页故障与共享内存为什么会同时出现这个实验名字里有两件事看起来不太搭。一个是“页故障”这是虚拟内存机制里的概念另一个是“共享内存通信”这是进程间通信的手段。但真正动手之后你会发现这两件事本来就是一体的。1.1 页故障到底是操作系统在“救场”还是在“报错”很多初学者一听“故障Fault”这个词就以为程序出错了。其实页故障是操作系统内存管理的一种正常工作机制。CPU访问一个虚拟地址时要先查页表把虚拟页翻译成物理页。如果页表项里对应物理页不存在、或者权限不对MMU就会触发一次异常Linux内核的异常处理代码会接管把这个异常当成一次缺页来处理。根据内核的日志和性能统计页故障一般分三种小故障minor fault、大故障major fault、保护故障protection fault。小故障的意思是页表项没生效但物理页已经在内存里比如共享内存第一次映射、栈自动增长内核只需要补一张页表映射就行。大故障是物理页不在内存中得从磁盘换入这会带来真正的I/O延迟。保护故障则多半是写时复制Copy-on-Write, COW触发的比如fork之后父子进程写同一个物理页内核会先复制一个副本再让你写。我用一个生活化的类比来理解虚拟地址空间像一栋大楼的楼层索引每个虚拟页相当于门牌号。页表就是楼层前台的花名册。第一次访问一个新页前台花名册上还没登记但你人已经站在门口了这就是缺页。前台赶紧给你补登记这个过程属于小故障。如果这个房间的东西原来锁在仓库磁盘swap分区需要先去仓库搬来这就是大故障。如果这个房间只允许你只读你却想往墙上钉钉子前台会先给你复制一间屋子再让你动手这就是COW保护故障。这个实验标题里的“第一次页故障”指的就是这种“第一次访问某个新虚拟页时页表项还不存在”的场景。通过统计进程的页故障次数你能很直观地看到虚拟内存这个机制到底干了什么活。1.2 共享内存通信为什么要和页故障扯在一起共享内存是效率最高的一种进程间通信方式。管道要拷贝两次数据发送方到内核缓冲区再从内核缓冲区到接收方而共享内存则是多个进程的虚拟地址空间映射到同一块物理内存大家直接读写同一块内存零拷贝速度飞快。但这里藏着一个典型的缺页场景子进程刚fork出来时共享内存区域还没真正建立映射。当子进程第一次读写共享内存地址时必然触发一次页故障内核才把共享内存的物理页真正关联到对应进程的页表里。也就是说“第一次访问共享内存”和“第一次页故障”在时间上几乎是重合的。所以在实验里页故障不是你要刻意去“制造”的故障而是共享内存机制背后必然会发生的事件。你在用户态观察到的是一个共享内存地址变量在底层观察到的则是一连串页表项被填充的过程。这也就引出了这个实验的设计思路创建一块共享内存让父子进程往里写数据、读数据同时记录进程第一次访问共享内存前后的页故障计数变化把“共享内存通信”和“页故障”这两个知识点在同一个程序里验证清楚。2. 实验环境准备与整体方案设计动手前先把实验环境理顺。我在Ubuntu 20.04 LTS内核版本5.4上验证过换别的Linux发行版差别也不大。关键在于要有gcc、make以及能用到的系统工具。另外页故障在不同内核版本里统计口径会有细微差别实验报告里如果引用了数据最好注明内核版本和当时的内存压力情况。2.1 环境与工具选型先确认几样东西# 查看内核版本 uname -r # 查看页大小一般是4096字节 getconf PAGESIZE # 编译器版本 gcc --version # 工具箱 sudo apt install -y gcc make strace perf-tools-unstable 2/dev/null || sudo apt install -y gcc make strace linux-tools-common linux-tools-$(uname -r)页大小默认是4K这个数字对整个实验很关键。后面统计页故障时每次缺页最少也会涉及一个物理页4K。如果你的机器开了大页hugepages那不是本实验讨论的范围先别开不然统计出来的页故障次数会变得“不整齐”。观察页故障数据我用两个办法。一是直接从/proc/self/stat读取进程的缺页计数二是用perf stat观测程序运行时系统级缺页事件。两者的区别前者是进程粒度的后者是系统全局的。实验里我们关心的是父子进程自己的页故障次数所以优先用/proc方式。另外/proc/vmstat里的pgfault、pgmajfault字段是系统全局的缺页次数适合做整体参考不适合定位到某个进程。很多同学做实验喜欢把time -v的输出贴上去里面也有Minor (reclaiming a frame) page faults和Major (requiring I/O) page faults用这个其实也够但它是整个进程生命周期内的累计值没法单独统计某个区间的变化。所以我还是建议直接读/proc/PID/stat。2.2 整体设计思路这个实验我分成三个子目标来看验证第一次访问未映射内存会触发页故障并记录故障前后的计数变化。用 System V 共享内存实现父子进程间的双向通信。在共享内存基础上解决并发读写的一致性问题。很多实验指导书只要求做到前两个但我建议把第三个也做了因为共享内存如果不同步数据竞争是确定会发生的。你写一个字节子进程读到的可能永远都是旧值或者运气好读到了新值但没法保证。这正好是实验报告里最值得写清楚的部分。方案选型上我选了 System V IPCshmget/shmat而不是 POSIX 的mmap。原因有三点System V 共享内存是独立于进程之外的IPC对象生命周期可控你可以在父进程创建后故意fork再让子进程去shmat这样子进程 attach 时刚刚好会触发一次新的页表建立正好配合实验主题。mmap父子进程共享需要传MAP_SHARED标志如果是匿名映射fork之后也能共享但处理细节上更容易踩COW陷阱新手容易混淆。System V 的接口清晰地分离了“创建”“连接”“分离”“控制”四个操作和课本上讲的IP机制对应得更好写实验报告时更好讲。这里有个关键点System V 共享内存在 fork 之后父进程和子进程并不需要各自再shmat一次才能用。因为共享内存描述符是进程的资源fork 时子进程会继承这些资源的引用。但为了实验的效果我在 fork 之后让父子进程各自主动调用一次shmat这样两个进程看起来是“独立地”把共享段映射到自己的虚拟地址空间更能观察“同一块物理内存两个不同的虚拟地址”这个现象。同步方面我用了 System V 信号量semget/semop来做互斥与通知。为什么不用管道来同步那就失去了共享内存的意义。为什么不用 pthread 锁因为父子进程是不同进程不共享同一个进程地址空间pthread 互斥锁默认不能跨进程使用除非用PTHREAD_PROCESS_SHARED属性但那个还要初始化比较绕。System V 信号量天然支持跨进程同步代码量也不大。3. 第一次页故障如何触发、如何观测、如何理解这部分是整个实验里最核心的原理验证环节。你得先写一个很小的观测程序证明“第一次访问时确实发生了页故障”再把观测手段用好最后把内核处理流程讲清楚。3.1 从fork到COW第一次页故障常常发生在你没想到的地方很多人以为fork之后子进程就拥有了一份独立的“副本”其实Linux的fork实现得很狡猾。父进程调用fork内核大部分时候不会立刻复制物理内存而是让父子进程共享同一批物理页同时把这些物理页标记为只读。当子进程或父进程第一次尝试写这些共享页面时MMU会触发一个写保护页故障内核的COW逻辑被唤醒先把物理页复制一份然后把对应进程的页表项指向新复制的那一页最后重新执行那条导致故障的写指令。这就是你实验里遇到的“第一次写数据”和“第一次页故障”的另一个交点。所以实验会发现一个很有意思的现象哪怕你根本没碰共享内存光是fork之后子进程里去改一个普通局部变量系统里也会出现COW类型的页故障。这个故障计数也会反映在进程的minflt统计中部分内核版本里COW统计在minor fault里。这就是为什么实验里统计页故障时要分阶段、按区间去读而不是只看一条命令的最终输出。在实验报告中如果你想观察COW可以故意分配一块比较大的数组fork之后让子进程去逐元素写这块数组每次大数据量写入后观察页故障计数是否大幅增长。这种“写触发COW”的观察点比单纯看共享内存更能讲清楚Linux fork的实现策略。3.2 用代码观测第一次页故障先写一个最基础的程序分配一段匿名内存第一次访问它记录访问前后的页故障计数。#define _GNU_SOURCE #include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/mman.h static long read_pgfaults(void) { FILE *fp fopen(/proc/self/stat, r); long pid, comm, state, ppid, pgrp, session, tty_nr, tpgid; long flags, minflt, cminflt, majflt, cmajflt; char buf[512]; if (!fp) return -1; fgets(buf, sizeof(buf), fp); fclose(fp); // 按空格分割字段 10 是 minflt字段 12 是 majflt sscanf(buf, %ld (%*[^)]) %c %ld %ld %ld %ld %ld %ld %ld %ld %ld %ld, pid, state, ppid, pgrp, session, tty_nr, tpgid, flags, minflt, cminflt, majflt, cmajflt); printf(minor_faults%ld major_faults%ld\n, minflt, majflt); return minflt; } int main(void) { size_t len 4 * 1024 * 1024; // 4MB char *p mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (p MAP_FAILED) { perror(mmap); exit(1); } puts(mmap之后、还没访问数据前:); read_pgfaults(); // 这里开始第一次真正触碰地址空间 memset(p, 0xab, len); puts(memset之后:); read_pgfaults(); // 再读一遍理论上不再触发新的缺页 volatile char x p[0]; (void)x; puts(紧接着再读一字节后:); read_pgfaults(); munmap(p, len); return 0; }这段代码读/proc/self/stat前两列的方式有点小技巧。字段9是flags字段10是minflt字段12是majflt。sscanf里的%*[^)]会把括号里的进程名跳过这是对付/proc/self/stat这种“带空格的进程名”的常规操作。我实测的时候mmap本身不会立即分配物理内存。虚拟内存的分配是惰性的mmap只是建立了虚拟地址区间并没有填充页表。第一次执行memset时才真正触发4MB区域内总共1024个匿名页的缺页所以minflt会一次性增加大约 1024。紧接着读p[0]时那个页面已经在页表里了不会再触发新缺页所以计数基本不变。如果你把memset注释掉直接读p[0]也会触发1次缺页。区别只是缺页次数是1而不是1024。这就直观地说明了物理内存是按需分配的不碰它它就不存在。3.3 页故障处理流程细节实验报告里可写的东西页面故障从CPU角度出发流程大致是CPU执行mov/str等访存指令MMU查页表发现虚拟页对应的页表项不存在或权限不足。CPU触发异常page fault自动跳转到内核的异常处理入口保存现场。Linux 内核根据异常地址调用do_page_fault/handle_mm_fault判断这次缺页的类型。如果是匿名页缺页内核分配一个物理页填充页表项清零或保留旧数据取决于是否COW。如果是文件页缺页内核去磁盘/页缓存找到对应数据建立映射。处理完成后返回用户态重新执行那条触发故障的指令。这里容易被忽略的是“重新执行”。CPU在触发异常时会把指令指针回退到故障指令之前内核处理完之后直接返回到故障指令那一条。所以一条MMU访问如果第一次缺页第二次再跑就已经能正常执行。这也是为什么实验里第一次访问会慢、第二次访问就快的原因因为缺页处理带来了内核态的额外开销。这个流程不需要你完全背下来但实验报告里如果能画一个分段描述用文字写清楚每一步别用mermaid很多平台渲染不出来把“程序陷入内核—缺页处理—返回用户态重新执行”讲明白老师基本都会认可。4. 父子进程共享内存通信的实现细节原理验证完了接下来是实验的主体程序。我用System V共享内存做一个简单但完整的生产者-消费者模型父进程往共享内存里写一批消息子进程从共享内存里读取并打印同时把各自读取到的共享内存地址和一份页故障统计信息打印出来。4.1 共享内存API选型System V还是POSIX可以先列个对比对比项System V (shmget/shmat)POSIX (shm_open/mmap)创建语义独立IPC对象有key标识文件描述符或匿名映射语义更接近文件生命周期显式shmctl IPC_RMID删除不删就一直存在由fd或文件引用关闭引用可能消失权限控制创建时可设mode权限通过文件权限控制TODO: 映射方式shmat/shmat返回地址mmap映射到指定地址区间是否有文件描述符没有只是key id有fd可以直接用poll等复用适合教学场景概念清晰贴近课本接口更新更贴近现代代码做这个实验我强烈建议用System V原因前面也说了它把“创建、关联、分离、删除”四个阶段拆得很明确配合实验主题更好观察页表建立的过程。4.2 核心实现代码讲解下面是一个可以直接编译运行的完整demo。我尽量保留了每个关键步骤的注释代码里刻意写了两个小函数一个用来读缺页计数一个用来打印共享内存段的系统信息。#define _GNU_SOURCE #include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/types.h #include sys/ipc.h #include sys/shm.h #include sys/sem.h #include sys/wait.h #include errno.h #define SHM_KEY 0x1234 #define SEM_KEY 0x5678 #define SHM_SIZE 256 #define MSG_COUNT 5 union semun { int val; struct semid_ds *buf; unsigned short *array; struct seminfo *__buf; }; static void read_n_print_faults(const char *tag) { FILE *fp fopen(/proc/self/stat, r); char buf[512]; long pid; char state; long fields[20]; if (!fp) return; fgets(buf, sizeof(buf), fp); fclose(fp); // 解析前13个字段第10个是minflt第12个是majflt sscanf(buf, %ld (%*[^)]) %c %ld %ld %ld %ld %ld %ld %ld %ld %ld %ld, pid, state, fields[0], fields[1], fields[2], fields[3], fields[4], fields[5], fields[6], fields[7], fields[8], fields[9], fields[10]); printf([%s] pid%ld minflt%ld majflt%ld\n, tag, pid, fields[7], fields[8]); } static int init_sem(int semid, int val) { union semun arg; arg.val val; return semctl(semid, 0, SETVAL, arg); } static void sem_wait(int semid, int semnum) { struct sembuf op {semnum, -1, 0}; while (semop(semid, op, 1) 0) { if (errno EINTR) continue; perror(semop wait); exit(1); } } static void sem_post(int semid, int semnum) { struct sembuf op {semnum, 1, 0}; if (semop(semid, op, 1) 0) { perror(semop post); exit(1); } } int main(void) { int shmid, semid; char *addr NULL; pid_t pid; // 1. 创建共享内存段 shmid shmget(SHM_KEY, SHM_SIZE, IPC_CREAT | 0666); if (shmid 0) { perror(shmget); exit(1); } // 2. 创建信号量semnum0用作互斥锁semnum1用作数据就绪通知 semid semget(SEM_KEY, 2, IPC_CREAT | 0666); if (semid 0) { perror(semget); exit(1); } init_sem(semid, 1); // 互斥锁初始为1 init_sem(semid, 1); // 就绪信号初始为0 // 3. fork子进程 pid fork(); if (pid 0) { perror(fork); exit(1); } if (pid 0) { // ---------- 子进程 ---------- addr shmat(shmid, NULL, 0); if (addr (char *)-1) { perror(shmat child); exit(1); } read_n_print_faults(child after shmat); // 等待父进程写完消息把数据打印出来 sem_wait(semid, 1); sem_wait(semid, 0); char *p addr; printf([child] 从共享内存读到的数据:\n); while (*p ! \0) { putchar(*p); } putchar(\n); sem_post(semid, 0); printf([child] 子进程看到共享内存地址%p\n, addr); read_n_print_faults(child before exit); shmdt(addr); exit(0); } else { // ---------- 父进程 ---------- addr shmat(shmid, NULL, 0); if (addr (char *)-1) { perror(shmat parent); exit(1); } read_n_print_faults(parent after shmat); // 往共享内存写入一段消息 sem_wait(semid, 0); const char *msg hello from parent via shared memory!\n; strcpy(addr, msg); sem_post(semid, 0); printf([parent] 父进程写入完成消息长度%zu\n, strlen(msg)); printf([parent] 父进程看到共享内存地址%p\n, addr); read_n_print_faults(parent before wait); sem_post(semid, 1); // 通知子进程数据就绪 wait(NULL); // 清理资源 shmdt(addr); shmctl(shmid, IPC_RMID, NULL); semctl(semid, 0, IPC_RMID); read_n_print_faults(parent after cleanup); } return 0; }编译gcc -Wall -O0 -o shm_demo shm_demo.c这段代码里有几个容易被忽略的点我单独说一下。第一SHM_KEY和SEM_KEY我用了固定值。如果你在系统里残留了上次实验未清理的共享内段可能造成新旧进程访问到同一段老数据。稳妥的办法是用ipcrm -M 0x1234手动清理或者直接在代码里判断返回值。第二子进程执行shmat时理论上父进程已经执行过shmat所以这个shmat调用并不会触发真正的物理页分配它只是给子进程建立新的页表项指向父进程已经映射好的物理页。但在子进程第一次读共享内存内容时就会发生一次缺页。我在代码里把read_n_print_faults放在shmat之后、读数据之前就是为了抓住这个“映射已建立但还没真正访问”的时间窗口。当然你也可以再加一次读取之后再打印对比两次页故障计数效果更明显。第三信号量同步的顺序要仔细看。父进程先拿互斥锁semnum0写完后释放然后父进程再sem_post(semid, 1)通知子进程“数据好了”。子进程先sem_wait(semid, 1)等待通知再sem_wait(semid, 0)拿互斥锁去读。这里如果子进程先拿互斥锁再等通知就可能死锁父进程在等锁子进程在等通知谁也别想往前走。4.3 同步机制为什么必须加锁共享内存本身不提供任何同步机制。两个进程同时写同一块内存就像两个人同时在一张纸上写字结果谁也不知道最终纸上是什么。你必须用信号量或类似机制来保证“写的时候没人读读的时候没人写”。这个实验里如果偷懒不加同步大多数机器上跑十次能对八次剩下的两次会打印出乱码或者读到半截消息。但因为“大多数时候能跑对”反而比必现的bug更坑一旦换台负载重的机器或者消息长度变长问题立刻暴露。所以从一开始就把信号量加上别偷懒。信号量初始值也有讲究。第一个信号量互斥锁初始值设为1表示可用第二个信号量就绪通知初始值设为0表示最开始没有数据。父进程写完消息后sem_post(semid, 1)把就绪信号量加1子进程sem_wait(semid, 1)会将它减为0后继续执行。这套模式其实就是最简单的“生产者-消费者”模型只是把消费者从线程换成进程而已。5. 运行验证与结果分析代码写完不是结束你得跑起来看懂输出。这一节大部分内容是实验报告里真正能贴出去的东西。5.1 正常流程运行与输出解读我跑一次的结果如下数值因系统状态会有浮动[parent after shmat] pid7823 minflt214 majflt0 [child after shmat] pid7824 minflt215 majflt0 [parent] 父进程写入完成消息长度42 [parent] 父进程看到共享内存地址0x7f0d8f83e000 [parent before wait] pid7823 minflt419 majflt0 [parent] 子进程看到共享内存地址0x7f0d8f757000 [child] 子进程看到共享内存地址0x7f0d8f757000 [child] 从共享内存读到的数据: hello from parent via shared memory! [child before exit] pid7824 minflt368 majflt0 [parent after cleanup] pid7823 minflt432 majflt0这里有几个点值得解读。父子进程各自的共享内存地址并不相同0x7f0d8f83e000和0x7f0d8f757000但访问的是同一块物理内存。这正好说明“虚拟地址是每个进程自己空间里的一张门牌号”映射到同一个物理房间两个门牌号自然可以不同。页故障次数变化比较明显。父进程在shmat后是214到等待子进程前变成了419多出了200多次缺页。这部分主要是父进程写入共享内存消息、执行printf、初始化运行时缓冲区等触发的。子进程则从215涨到368其增长的一部分是读取共享内存内容时发生另一部分是printf、动态库加载等自身运行导致。想单独剥出共享内存触发的缺页次数就需要把读取前后的计数做差再去掉空白试验的差值这个数就是软件层面的“净页故障增长”。如果你发现页故障计数几乎没变化不要急着下结论。有一种情况进程在之前已经访问过这个共享内存地址比如上次实验的共享段没删shmat连到了已有的内存页那么这次shmat之后读它就不会触发新的缺页。解决方法是每次实验结束后把共享内存段删干净或者在代码开头用shmctl(shmid, IPC_RMID, NULL)提前删除残留段如果权限允许。5.2 用工具印证页故障除了读进程自身的/proc/self/stat还可以用系统工具做二次验证。perf stat能给出进程运行期间的page fault总数perf stat ./shm_demo 21 | grep -E page-faults|minor|major如果perf因为权限问题跑不起来可以临时调低系统限制sudo sysctl kernel.perf_event_paranoid-1另一个方向是看/proc/vmstatwatch -n1 grep -E pgfault|pgmajfault /proc/vmstatpgfault是整个系统累计的缺页次数每秒刷新。你在运行程序的瞬间看到这个值暴涨就能证明共享内存访问确实带来了缺页。不过它是全局信号只能用于“验证确实发生了”不能精确定位到哪个进程。如果想在实验报告里展示更细粒度的数据可以用strace -c ./shm_demo看系统调用的次数和耗时。shmat、shmdt、semop这几个调用在strace输出里会非常醒目能直接看到共享内存的生命周期attach、IPC、detach。5.3 验证共享内容真的“共享”很多同学做完实验只是让子进程把父进程写的内容打印出来就觉得“通信成功了”。其实还可以做一个更强的验证让子进程修改共享内存里的一个值父进程再读一次看是否能看到修改结果。如果能看到说明确实是共享的物理内存而不是靠管道传了一份副本。我通常会在实验里加上一段// 父进程在wait之前加一个循环每隔200ms读一次共享内存里某个特定字段 // 子进程在退出前把该字段改成100 // 父进程观察该字段从0变成100实践里这个改动非常小但效果很好。它能把“共享内存”和“消息传递”这两个概念区分开。消息传递是复制一份共享内存是直接修改原对象。实验报告里写一句“子进程修改counter字段后父进程无需接收数据即可看到更新”比长篇大论解释共享内存原理更有说服力。6. 常见问题与排查技巧实录做这种实验问题基本集中在共享内存没删干净、信号量使用出错、以及页故障统计口径不清这三个方向上。我把自己踩过的坑和同学常用的排查方法都列出来方便你快速对照。6.1 共享内存段创建失败/权限问题shmget返回EACCES是最常见的。原因是同key的共享内存已经存在但创建时权限位和当前进程不符。比如上一次运行是用root创建的0666这次普通用户再想创建匹配不到权限。最简单的排查命令ipcs -m看到残留的共享内存段直接清理ipcrm -M 0x1234 # 按shmget创建时用的key删 # 或者 ipcrm -m shmid # 按ipcs -m显示的id删如果 fork 出来的子进程也要访问共享内存但shmat一直失败首先要确认共享内存的权限。IPC_CREAT | 0666给的是读写权限但如果系统里默认 umask 是 0022创建出来的段实际权限可能是0644子进程如果不是文件主身份可能没有写权限。稳妥的做法是IPC_CREAT | 0666且在代码里不依赖umask地显式设置权限位。核心提示每次跑实验前先ipcs -m看一眼有没有残留没有残留再跑可以省掉80%的定位时间。6.2 子进程修改了但父进程读不到这是另一个高频问题。现象是代码逻辑看起来没问题子进程里改了共享内存的值父进程wait之后读到的却是旧值。原因可能有三个。第一个也是最隐蔽的你用了mmap且没有加MAP_SHARED而是用了MAP_PRIVATE。这样父子进程看似共享同一块映射实际上写操作会触发COW子进程写的是自己的私有副本父进程当然看不到。第二个原因是同步没做好。子进程写完还没来得及把数据刷到物理内存现代CPU有缓存一致性协议实际上不会真的“没来及”但编译器或CPU重排可能导致可见性顺序变化父进程就去读了。这个在x86上有时不会复现但在编译优化级别开高如-O2后概率明显增大。解决办法就是加信号量/内存屏障。第三个原因很蠢但很常见父进程wait之后立刻shmdt再打印共享内存内容。共享内存已经被断开了打印的其实是进程退出前残留的地址内容自然可能不对。排查思路是先加打印确认子进程真的写成功了再确认父进程读之前有没有把共享段detach最后确认同步信号量加的是不是正确。6.3 页故障计数“不对”的原因页面故障计数的正确性受太多因素影响很多同学对照理论值对不上就慌了。我在实验里总结了几条经验可以帮你判断计数差异是否正常。你mmap一个4MB区域memset之后理论上minflt增加约1024次但这个前提是区域未映射、页大小4K、并且没有使用MAP_POPULATE这类预分配标志。fork本身会增加父进程和子进程的页故障计数吗有可能。子进程启动时要加载动态链接器、映射动态库会产生一批缺页父进程fork之后的执行路径也会产生新的缺页。所以对比时要用“访问共享内存前后”的差值而不是比较两个进程的绝对数。如果数据比你预期多先看是不是printf/库函数触发了缺页。如果数据比你预期少先看是不是之前已经访问过同一段地址。建议在实验报告里附上“预实验”数据不访问共享内存、只创建和销毁共享内存的空白试验用这个基线值去修正共享内存访问带来的净增长。这样数据才有说服力。6.4 调试辅助工具与心得调试共享内存程序最重要的工具是strace和ipcs。strace -f -e traceshmget,shmat,shmdt,semget,semop,wait4 ./shm_demo-f是跟随fork出来的子进程不然你只能看到父进程的系统调用。输出里会清楚展示父进程shmget成功、fork、父子各自shmat、父进程semop、子进程读取、双方shmdt。哪一步出错错误码是什么一目了然。如果你用的还是System V共享内存检查锁竞争情况可以用perf record -g采样但实验规模很小一般用不到。真正麻烦的是死锁程序卡住不动这时先按CtrlZ再ps -ef | grep shm_demo再gdb -p pidattach到进程上看栈gdb -p 12345 bt如果父进程栈顶在semop子进程栈顶也在semop那就是死锁了回到同步逻辑里找问题。6.5 快速FAQ表现象可能原因排查方式shmget: File exists共享内存段未删除ipcs -mipcrm -M keyshmat: Permission denied权限位不合适或key冲突用0666检查umask子进程读不到数据MAP_PRIVATE 或同步顺序错检查mmap标志检查semop流程缺页计数比理论大很多动态库/printf触发做空白试验做差值缺页计数比理论小很多之前访问过该段地址重启程序或换key程序卡住信号量死锁gdb attach看栈是否停在semop父子进程打印的共享地址不同正常现象虚拟地址不同别改写进报告的要点最后再分享一个小技巧我在做这个实验时最大的收获不是“会写shmget和semop”而是学会了把缺页次数当成一个可观测的指标来用。往代码里多插几处/proc/self/stat的读取函数把每次共享内存操作前后的缺页数打出来这个习惯后来在排查性能问题、分析内存占用时帮了我大忙。再补充一个很实用的小操作如果你不确定共享内存到底有没有被删除干净可以在代码末尾加上shmctl(shmid, IPC_RMID, NULL)并且在main开头用shmget(key, 0, 0)检查一下是否存在存在就直接报错退出。这样哪怕你忘记手动清理也不会跑到上一次实验的残留数据。我在课程设计里吃过这个亏代码逻辑完全正确但因为共享内存段没删读到的是上一个人的老数据查了快一小时才反应过来。如果你学完这个实验还想继续深入建议顺着这几个方向拓展试试POSIX共享内存shm_openmmap体会和System V的差别用pagemap接口看看共享内存对应物理页的PFN号或者干脆把通信数据改成结构体数组做一个带环形缓冲区的生产者-消费者。每个方向都能把“虚拟内存”这个抽象概念往前再推进一步。
返回列表