实验二:系统调用综合实验(内存管理与异步并发)
1. 实验背景
在实验一中,我们实现了多进程并发检查,但存在一个明显的瓶颈:主进程必须等待所有子进程结束(阻塞式 wait())才能接待下一位患者。这在现实医院中是不可接受的——接待员不可能站在原地等化验结果出来才叫下一个号。
此外,实验一中的进程间数据传递受限。为了解决这两个问题,本实验将引入操作系统的共享内存(Shared Memory)机制来实现高效数据交换,并利用非阻塞系统调用实现异步处理流程。
本实验模拟医院内部的高速数据总线与异步接待流程:
- 数据共享:多个进程直接读写同一块内存区域,实现“零拷贝”数据共享。
- 异步非阻塞:接待进程发送检查任务后,立即接待下一位患者,无需等待检查结果返回。
- 资源回收:通过轮询或信号机制,在后台清理已结束的子进程,防止僵尸进程。
2. 实验目的
- 理解虚拟内存隔离:验证普通变量无法在进程间共享,理解共享内存的必要性。
- 掌握内存映射(mmap):学会使用
mmap()创建进程间共享内存区域。 - 掌握非阻塞等待:熟练使用
waitpid(..., WNOHANG)实现非阻塞式的子进程状态检查。 - 异步编程思维:理解“发送即忘”(Fire-and-Forget)与“状态轮询”的异步处理模式。
- 内存安全意识:学习在共享内存中进行边界检查,防止缓冲区溢出。
3. 实验环境
| 项目 | 要求/版本 |
|---|---|
| 操作系统 | Ubuntu 22.04 LTS |
| 编译器 | GCC (GNU Compiler Collection) |
| 编辑器 | Vim / VS Code / Gedit |
| 终端工具 | Bash Shell |
4. 预备知识
mmap():将文件映射到内存。MAP_SHARED:共享映射,修改对其他进程可见。MAP_PRIVATE:私有映射,修改不影响其他进程。
waitpid(pid, &status, WNOHANG):- 普通
wait():如果没有子进程结束,父进程会阻塞(停在这里不动)。 WNOHANG:如果没有子进程结束,函数立即返回 0,父进程可以继续做其他事。
- 普通
- 共享数据结构:所有进程必须对共享内存中的结构体定义保持一致。
unlink():删除文件系统中的一个名字(用于清理共享内存文件)。
5. 实验内容与步骤
任务一:验证内存隔离性
任务描述:
在开发初期,有实习生试图在父进程中定义一个全局变量 int result,希望子进程修改它后,父进程能直接读到。我们需要通过实验证明为什么这种做法在普通多进程中是无效的,从而理解引入共享内存的必要性。
操作指引:
- 创建一个简单的 C 程序
medical_record_task1.c。 - 定义一个全局整型变量
shared_var = 0。 - 使用
fork()创建子进程。 - 在子进程中将
shared_var修改为 100,并打印子进程中的值。 - 在父进程中等待子进程结束后,打印父进程中的
shared_var值。 - 观察并记录:父进程看到的值变了吗?为什么?
💡 代码实现提示:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int shared_var = 0; // 全局变量
int main() {
pid_t pid = fork();
if (pid == 0) {
// === 子进程 ===
// [TODO] 将 shared_var 修改为 100
// [TODO] 打印子进程中的 shared_var 值
exit(0);
} else if (pid > 0) {
// === 父进程 ===
wait(NULL); // 等待子进程结束
// [TODO] 打印父进程中的 shared_var 值
}
return 0;
}
实验要求:
- 保存代码为
medical_record_task1.c。- 截图运行结果,证明父子进程内存是隔离的。
- 在报告中简述“虚拟地址空间”的概念。
任务二:创建共享内存区域
任务描述:
为了解决任务一的问题,医院信息科决定建立一块“公共白板”(共享内存)。所有接诊进程和检查进程都可以在这块白板上写字,且彼此可见。我们将使用 mmap 基于文件创建这块共享区域。
操作指引:
- 补全框架文件
medical_record.c。 - 创建一个临时文件(如
patient_shm.dat),并扩展其大小(例如 4096 字节)。 - 调用
mmap()将该文件映射到内存,标志位使用MAP_SHARED。 - 将映射返回的指针强制转换为一个结构体指针(例如
PatientData *)。 - 在父进程中向该结构体写入患者姓名,然后
fork()创建子进程。 - 子进程直接读取该指针指向的姓名并打印,验证数据是否共享成功。
- 程序结束前,调用
munmap()解除映射,并删除临时文件。
实验要求:
- 截图证明子进程读取到了父进程写入的数据。
- 解释
MAP_SHARED参数的作用。
任务三:多进程并发写入共享数据
任务描述:
回到实验一的场景,现在我们有 3 个检查项目(血常规、尿常规、心电图)。我们希望这 3 个子进程将检查结果直接写入共享内存中的不同区域,而不是通过 exit 状态码返回简单的成功/失败。
操作指引:
- 基于任务二代码,扩展
PatientData结构体,增加一个字符数组results[3][100]用于存储 3 项检查的文本结果。 - 父进程映射内存后,循环
fork()创建 3 个子进程。 - 每个子进程根据索引
i(0, 1, 2),将模拟的检查结果(如“血常规:正常”)写入results[i]。 - 子进程写入完成后,更新
check_status字段(例如使用原子操作或简单标记)。 - 父进程等待所有子进程结束后,一次性从共享内存中读取并打印所有检查结果。
实验要求:
- 截图展示父进程读取到的 3 项详细检查结果。
- 对比实验一,说明这种方式在数据传输量上的优势。
任务四:内存安全与边界检查
任务描述: 医疗数据的安全性至关重要。如果某个检查设备发生故障,向共享内存写入了超长的错误日志,可能会导致缓冲区溢出,覆盖掉其他患者的数据甚至导致程序崩溃。我们需要在代码中加入防护机制。
操作指引:
- 在任务三的基础上,模拟一个“故障子进程”。
- 尝试使用
strcpy()将一个长度超过 100 字符的字符串写入results[i]。 - 观察程序是否崩溃或数据是否被破坏。
- 改进代码,使用
strncpy()或手动检查长度,确保写入数据不超过缓冲区大小。 - 在写入前增加判断:
if (len >= buffer_size) { 处理错误 }。
实验要求:
- 保存代码为
medical_record_task4.c。- 截图展示未加保护时的异常现象(如有)和加保护后的正常提示。
- 在报告中简述“缓冲区溢出”对医疗系统的潜在风险。
任务五(扩展):异步非阻塞处理与状态轮询
任务描述: 在现实医院中,接待员(父进程)将化验单交给检验科(子进程)后,会立即叫号下一位患者,而不会站在原地等结果。我们需要修改程序,使父进程不再阻塞等待子进程结束,而是通过“轮询”方式在后台检查任务状态。
操作指引:
- 基于任务四代码,在父进程创建完检查子进程后,不要立即调用
wait()或waitpid()阻塞等待。 - 父进程打印“请下一位患者就诊”,并继续循环接受输入(模拟接待新患者)。
- 非阻塞状态检查:在接待新患者的间隙,使用
waitpid(pid, &status, WNOHANG)检查之前的子进程是否结束。- 如果返回 0:说明子进程还在运行,父进程继续接待工作。
- 如果返回 PID:说明子进程已结束,父进程记录结果并清理资源。
- 运行程序,连续输入两位患者信息。观察是否能在第一位患者的检查结果出来之前,就开始处理第二位患者的信息。
💡 代码实现提示:
// 父进程主循环中
pid_t check_pids[3];
// ... fork 创建子进程后 ...
// 不要在这里 wait()!直接继续循环
printf(">> 接待员:检查单已送出,请下一位患者!\n");
// 在循环的空闲时间或下次循环开始时检查状态
for (int i = 0; i < 3; i++) {
// WNOHANG 表示非阻塞:如果没有结束,立即返回 0
int ret = waitpid(check_pids[i], &status, WNOHANG);
if (ret > 0) {
printf(">> 系统通知:第 %d 项检查已完成\n", i);
// 从共享内存读取结果...
}
}
实验要求:
- 保存代码为
medical_record_task5.c。- 截图展示“接待下一位”提示出现在“检查完成”提示之前的现象。
- 思考:如果父进程一直不调用
wait系列函数,子进程结束后会变成什么状态?本实验中的waitpid(..., WNOHANG)是如何避免这个问题的?
6. 代码框架 (medical_record.c)
请在以下框架基础上完成任务二。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <sys/wait.h>
// 定义共享数据结构
typedef struct {
char name[50];
} PatientData;
int main() {
const char *filename = "patient_shm.dat";
int fd;
PatientData *ptr;
// [TODO 1] 使用 open 创建或打开文件,权限设为 0666
// 提示:标志位使用 O_RDWR | O_CREAT
// 请在此处编写 open 代码,并将返回值赋给 fd
// fd = open(...);
if (fd == -1) {
perror("open failed");
return 1;
}
// [TODO 2] 使用 ftruncate 设置文件大小为 4096 字节
// 提示:ftruncate(fd, length)
// 请在此处编写 ftruncate 代码
// ftruncate(...);
// [TODO 3] 使用 mmap 创建共享内存映射
// 提示:参数依次为 (NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0)
// 请在此处编写 mmap 代码,并将返回值赋给 ptr
// ptr = mmap(...);
if (ptr == MAP_FAILED) {
perror("mmap failed");
return 1;
}
// 父进程写入数据
strcpy(ptr->name, "ZhangSan");
printf("父进程:已写入姓名 %s\n", ptr->name);
pid_t pid = fork();
if (pid == 0) {
// === 子进程 ===
// [TODO 4] 读取并打印共享内存中的姓名
// 提示:直接访问 ptr->name
// 请在此处编写 printf 代码
// printf(...);
_exit(0);
} else if (pid > 0) {
// === 父进程 ===
wait(NULL); // 等待子进程结束
// [TODO 5] 清理资源 (munmap, close, unlink)
// 提示:顺序解除映射 -> 关闭文件 -> 删除文件
// 请在此处编写清理代码
// munmap(...);
// close(...);
// unlink(...);
} else {
perror("fork failed");
return 1;
}
return 0;
}
7. 编译、运行与测试
# 1. 编译 (以任务五为例)
gcc -o medical_record_task5 medical_record_task5.c
# 2. 运行测试
./medical_record_task5
# 3. 测试流程
# 输入患者 1 姓名 -> 观察是否立即提示"请下一位"
# 输入患者 2 姓名 -> 观察期间是否有患者 1 的检查结果弹出
8. 实验报告及提交要求
8.1 实验报告
请提交一份 PDF 格式 的实验报告,命名格式:学号_姓名_实验二.pdf。报告需包含以下内容:
- 核心代码:粘贴每个任务的关键代码段(特别是
mmap和waitpid部分)。 - 运行截图:
- 任务一:内存隔离验证结果。
- 任务二/三:共享内存读写成功截图。
- 任务五(可选):关键截图,展示“请下一位患者”提示出现在“检查完成”提示之前。
- 问题与思考:
- 对比实验一,共享内存在大数据传输场景下有什么优势?
- 任务五中,
WNOHANG参数起到了什么作用?如果没有它,系统会变成什么样? - 思考:如果多个进程同时写入共享内存的同一位置会发生什么?(提示:竞态条件,简述即可)。
- 心得体会:对操作系统“虚拟内存”与“异步并发”关系的理解。
8.2 实验代码
请提交以下源码文件(确保代码中有必要的注释):
medical_record_task4.c- (可选)
medical_record_task5.c
8.3 提交方式
请将实验报告(PDF)和代码文件打包为 .zip 或 .rar 格式,命名为 学号_姓名_实验二代码包,上传至头歌教学平台对应任务节点。