实验二:系统调用综合实验(内存管理与异步并发)


1. 实验背景

实验一中,我们实现了多进程并发检查,但存在一个明显的瓶颈:主进程必须等待所有子进程结束(阻塞式 wait())才能接待下一位患者。这在现实医院中是不可接受的——接待员不可能站在原地等化验结果出来才叫下一个号。

此外,实验一中的进程间数据传递受限。为了解决这两个问题,本实验将引入操作系统的共享内存(Shared Memory)机制来实现高效数据交换,并利用非阻塞系统调用实现异步处理流程。

本实验模拟医院内部的高速数据总线与异步接待流程:

  • 数据共享:多个进程直接读写同一块内存区域,实现“零拷贝”数据共享。
  • 异步非阻塞:接待进程发送检查任务后,立即接待下一位患者,无需等待检查结果返回。
  • 资源回收:通过轮询或信号机制,在后台清理已结束的子进程,防止僵尸进程。

2. 实验目的

  1. 理解虚拟内存隔离:验证普通变量无法在进程间共享,理解共享内存的必要性。
  2. 掌握内存映射(mmap):学会使用 mmap() 创建进程间共享内存区域。
  3. 掌握非阻塞等待:熟练使用 waitpid(..., WNOHANG) 实现非阻塞式的子进程状态检查。
  4. 异步编程思维:理解“发送即忘”(Fire-and-Forget)与“状态轮询”的异步处理模式。
  5. 内存安全意识:学习在共享内存中进行边界检查,防止缓冲区溢出。

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,希望子进程修改它后,父进程能直接读到。我们需要通过实验证明为什么这种做法在普通多进程中是无效的,从而理解引入共享内存的必要性。

操作指引

  1. 创建一个简单的 C 程序 medical_record_task1.c
  2. 定义一个全局整型变量 shared_var = 0
  3. 使用 fork() 创建子进程。
  4. 在子进程中将 shared_var 修改为 100,并打印子进程中的值。
  5. 在父进程中等待子进程结束后,打印父进程中的 shared_var 值。
  6. 观察并记录:父进程看到的值变了吗?为什么?

💡 代码实现提示

#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 基于文件创建这块共享区域。

操作指引

  1. 补全框架文件 medical_record.c
  2. 创建一个临时文件(如 patient_shm.dat),并扩展其大小(例如 4096 字节)。
  3. 调用 mmap() 将该文件映射到内存,标志位使用 MAP_SHARED
  4. 将映射返回的指针强制转换为一个结构体指针(例如 PatientData *)。
  5. 在父进程中向该结构体写入患者姓名,然后 fork() 创建子进程。
  6. 子进程直接读取该指针指向的姓名并打印,验证数据是否共享成功。
  7. 程序结束前,调用 munmap() 解除映射,并删除临时文件。

实验要求

  • 截图证明子进程读取到了父进程写入的数据。
  • 解释 MAP_SHARED 参数的作用。

任务三:多进程并发写入共享数据

任务描述: 回到实验一的场景,现在我们有 3 个检查项目(血常规、尿常规、心电图)。我们希望这 3 个子进程将检查结果直接写入共享内存中的不同区域,而不是通过 exit 状态码返回简单的成功/失败。

操作指引

  1. 基于任务二代码,扩展 PatientData 结构体,增加一个字符数组 results[3][100] 用于存储 3 项检查的文本结果。
  2. 父进程映射内存后,循环 fork() 创建 3 个子进程。
  3. 每个子进程根据索引 i (0, 1, 2),将模拟的检查结果(如“血常规:正常”)写入 results[i]
  4. 子进程写入完成后,更新 check_status 字段(例如使用原子操作或简单标记)。
  5. 父进程等待所有子进程结束后,一次性从共享内存中读取并打印所有检查结果。

实验要求

  • 截图展示父进程读取到的 3 项详细检查结果。
  • 对比实验一,说明这种方式在数据传输量上的优势。

任务四:内存安全与边界检查

任务描述: 医疗数据的安全性至关重要。如果某个检查设备发生故障,向共享内存写入了超长的错误日志,可能会导致缓冲区溢出,覆盖掉其他患者的数据甚至导致程序崩溃。我们需要在代码中加入防护机制。

操作指引

  1. 在任务三的基础上,模拟一个“故障子进程”。
  2. 尝试使用 strcpy() 将一个长度超过 100 字符的字符串写入 results[i]
  3. 观察程序是否崩溃或数据是否被破坏。
  4. 改进代码,使用 strncpy() 或手动检查长度,确保写入数据不超过缓冲区大小。
  5. 在写入前增加判断:if (len >= buffer_size) { 处理错误 }

实验要求

  • 保存代码为 medical_record_task4.c
  • 截图展示未加保护时的异常现象(如有)和加保护后的正常提示。
  • 在报告中简述“缓冲区溢出”对医疗系统的潜在风险。

任务五(扩展):异步非阻塞处理与状态轮询

任务描述: 在现实医院中,接待员(父进程)将化验单交给检验科(子进程)后,会立即叫号下一位患者,而不会站在原地等结果。我们需要修改程序,使父进程不再阻塞等待子进程结束,而是通过“轮询”方式在后台检查任务状态。

操作指引

  1. 基于任务四代码,在父进程创建完检查子进程后,不要立即调用 wait()waitpid() 阻塞等待。
  2. 父进程打印“请下一位患者就诊”,并继续循环接受输入(模拟接待新患者)。
  3. 非阻塞状态检查:在接待新患者的间隙,使用 waitpid(pid, &status, WNOHANG) 检查之前的子进程是否结束。
    • 如果返回 0:说明子进程还在运行,父进程继续接待工作。
    • 如果返回 PID:说明子进程已结束,父进程记录结果并清理资源。
  4. 运行程序,连续输入两位患者信息。观察是否能在第一位患者的检查结果出来之前,就开始处理第二位患者的信息。

💡 代码实现提示

// 父进程主循环中
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。报告需包含以下内容:

  1. 核心代码:粘贴每个任务的关键代码段(特别是 mmapwaitpid 部分)。
  2. 运行截图
    • 任务一:内存隔离验证结果。
    • 任务二/三:共享内存读写成功截图。
    • 任务五(可选):关键截图,展示“请下一位患者”提示出现在“检查完成”提示之前。
  3. 问题与思考
    • 对比实验一,共享内存在大数据传输场景下有什么优势?
    • 任务五中,WNOHANG 参数起到了什么作用?如果没有它,系统会变成什么样?
    • 思考:如果多个进程同时写入共享内存的同一位置会发生什么?(提示:竞态条件,简述即可)。
  4. 心得体会:对操作系统“虚拟内存”与“异步并发”关系的理解。

8.2 实验代码

请提交以下源码文件(确保代码中有必要的注释):

  • medical_record_task4.c
  • (可选)medical_record_task5.c

8.3 提交方式

请将实验报告(PDF)和代码文件打包为 .zip.rar 格式,命名为 学号_姓名_实验二代码包,上传至头歌教学平台对应任务节点。