简介:这份资源围绕操作系统中的Spooling假脱机输入输出模拟技术展开,面向计算机专业学生及系统设计学习者,帮助理解如何通过磁盘缓冲将慢速I/O设备虚拟化,实现多进程共享打印机等外设。包内共11个文件,以5个class编译文件、1个java源程序、1个doc设计报告和4张jpg运行截图为主,源程序展示请求输出进程与spooling输出进程的协作逻辑,报告与截图则呈现设计思路和实际运行状态,压缩包约279KB。已有441人学习。读者可从中获得完整的进程模型实现、输入井与输出井的缓冲管理代码、设备驱动交互细节,以及可对照复现的实验报告与界面截图,适合课程设计、实验报告撰写和操作系统原理的深入理解。1. 从一次打印队列卡死说起:spooling 假脱机到底在模拟什么上周帮同事看一个模拟程序,现象很怪:两个请求输出的进程轮流往打印机写数据,结果第一个进程刚写完一半,第二个进程的数据就插进来了,打印出来的内容交错成一团乱码。他以为是并发锁没加对,查了半天才发现,问题出在没理解 spooling 假脱机输入输出模拟技术的核心——独占设备怎么被改造成共享设备。这个资源就是围绕这个场景做的:用程序模拟 spooling 技术,具体配置是2 个请求输出进程 + 1 个 spooling 输出进程。它要解决的不是"怎么调用打印机 API",而是操作系统层面那个经典问题——多个进程同时要用一台独占设备时,怎么让它们不打架、不阻塞、还能按顺序输出。适合正在学操作系统原理、要做课程设计、或者想搞懂"假脱机"这三个字到底假在哪的从业者。下面我按自己拆这套东西的顺序,把原理、代码结构、参数设置和踩过的坑一条条讲清楚。2. spooling 的三层结构:输入井、输出井和那张关键的表2.1 为什么不能直接让进程抢打印机先想清楚一个前提:打印机、磁带机这类设备,物理特性决定了同一时刻只能有一个进程真正操作它。如果放任 2 个请求输出进程直接去抢,要么加互斥锁让一个等另一个(进程阻塞,CPU 空转),要么不加锁导致输出交错(就是开头那个乱码现场)。spooling 的思路是加一层中间缓冲:进程不直接碰设备,而是把要输出的数据先写到磁盘上的一个区域,再由一个专门的进程去读这个区域、真正驱动设备。这样请求输出的进程写完就走,不用等设备,设备那头也有序。这层磁盘缓冲,就是"井"。2.2 输入井与输出井的分工在模拟程序里,通常用两个数据结构来代表井:结构作用模拟中的常见实现输出井存放 2 个请求进程要打印的数据一个队列或数组,每个元素是一段待输出内容输出进程从井里取数据、驱动"打印机"一个独立循环,按顺序消费队列请求表记录哪个进程申请了输出、数据在哪结构体数组,含进程号、数据地址、长度、状态输出井是共享资源,2 个请求进程都能往里写,所以对井的写入要互斥;输出进程从井里读,也要和写入互斥。但注意——互斥的是井,不是打印机。打印机只被输出进程一个人碰,天然不存在竞争。这就是 spooling 把独占设备"虚拟"成共享设备的关键:竞争被转移到了井上,而井是内存/磁盘结构,加锁成本远低于设备。2.3 请求表:整个模拟的调度中枢请求表是很多人写模拟时容易忽略的部分。它不只是记录,还承担状态流转:// 请求表项结构,模拟 spooling 输出请求 typedef struct { int pid; // 请求进程号,1 或 2 int data_addr; // 数据在输出井中的起始位置 int length; // 数据长度 int state; // 0=空闲 1=已提交待输出 2=正在输出 3=已完成 } SpoolRequest; SpoolRequest req_table[MAX_REQ]; // 请求表 int out_well[WELL_SIZE]; // 输出井,模拟磁盘缓冲 int well_head, well_tail; // 井的读写指针逻辑说明:pid区分是哪个请求进程提交的,state让输出进程知道该处理哪条。well_head和well_tail构成一个环形缓冲,避免井无限增长。参数上,WELL_SIZE决定井能缓冲多少数据,太小会导致请求进程写不进去而等待,太大则失去"缓冲"的模拟意义,一般设成单次输出量的 3 到 5 倍比较合适。提示:请求表的状态字段一定要在互斥区内更新,否则输出进程可能读到"半提交"的记录。3. 2 个请求进程 + 1 个输出进程:用信号量把并发顺序管起来3.1 信号量怎么设:三个就够这套模拟的并发控制,核心是三个信号量:sem_t mutex; // 保护输出井和请求表的互斥访问,初值 1 sem_t empty; // 井中空闲槽位数,初值 WELL_SIZE sem_t full; // 井中已提交待输出的记录数,初值 0请求进程的流程是:申请一个空槽(P empty)→ 拿互斥锁(P mutex)→ 写数据进井、填请求表 → 释放锁(V mutex)→ 通知有数据了(V full)。输出进程反过来:P full → P mutex → 从井里取数据、更新请求表 → V mutex → V empty。这样设计的好处是,2 个请求进程之间不需要直接互斥,它们通过 empty 和 full 这两个计数信号量自然排队。谁先拿到空槽谁先写,写完了输出进程按井里顺序取,天然保证输出有序。3.2 请求进程的代码骨架void *request_process(void *arg) { int pid = *(int *)arg; for (int i = 0; i < REQ_TIMES; i++) { sem_wait(&empty); // 等一个空槽 sem_wait(&mutex); // 进临界区 // 把本进程第 i 条数据写入输出井 out_well[well_tail] = pid * 100 + i; well_tail = (well_tail + 1) % WELL_SIZE; // 填请求表 req_table[req_idx].pid = pid; req_table[req_idx].state = 1; req_idx++; sem_post(&mutex); // 出临界区 sem_post(&full); // 通知输出进程 usleep(rand() % 100000); // 模拟请求间隔 } return NULL; }逻辑说明:sem_wait(&empty)保证井满时请求进程会阻塞等待,不会覆盖未输出的数据。usleep那行模拟真实场景里请求不是连续不断的,加随机间隔能暴露调度顺序问题。参数REQ_TIMES控制每个请求进程提交多少次,设成 5 到 10 次既能看出交错效果,又不至于跑太久。3.3 输出进程的消费逻辑void *spool_output_process(void *arg) { while (output_count < TOTAL_REQ) { sem_wait(&full); // 等有数据可输出 sem_wait(&mutex); // 从井头取数据 int data = out_well[well_head]; well_head = (well_head + 1) % WELL_SIZE; // 更新请求表状态为"正在输出" req_table[out_idx].state = 2; sem_post(&mutex); sem_post(&empty); // 释放一个空槽 printf("输出进程打印: %d\n", data); // 模拟驱动打印机 req_table[out_idx].state = 3; out_idx++; output_count++; usleep(200000); // 模拟打印耗时 } return NULL; }逻辑说明:输出进程是唯一的消费者,full为 0 时它会阻塞,不会空转。usleep(200000)模拟打印机的物理速度,这个值要比请求进程的间隔大,才能看出"井"起到的缓冲作用——请求进程可以快速写满井然后继续干别的,输出进程慢慢打。注意:output_count和out_idx如果被多个线程访问,也要纳入互斥保护,别只锁了井忘了计数器。4. 避坑排查:模拟跑不通时先看这五条4.1 输出内容交错、顺序全乱现象:打印出来的数据是1 2 1 2这种交错,而不是先来先出。 原因:请求进程写井时没加互斥,两个进程同时移动了well_tail,导致数据覆盖或指针错位。 解决:确认sem_wait(&mutex)包住了写井和更新请求表的全部操作,一个都不能漏。4.2 程序卡死不动,谁也不输出现象:跑起来就挂住,没有任何打印。 原因:信号量初值设错,最常见的是full初值给了 1 或者empty初值给了 0。 解决:empty必须等于井容量,full必须从 0 开始。检查sem_init的第三个参数。4.3 输出进程只打了一条就退出现象:只输出第一条数据,循环就结束了。 原因:循环终止条件写成了while(1)里判断full的值,或者output_count没累加。 解决:终止条件应该基于总请求数,而不是井的当前状态。把TOTAL_REQ设成两个请求进程提交次数之和。4.4 井满了请求进程还在写,数据丢失现象:输出条数少于提交条数。 原因:empty信号量没在输出进程里正确V操作,空槽没被释放。 解决:输出进程每消费一条,必须sem_post(&empty),位置放在取完数据之后、打印之前都行,但不能忘。4.5 加了锁反而更慢甚至死锁现象:程序偶尔卡在某个sem_wait上。 原因:P 操作顺序不一致,比如请求进程先 P mutex 再 P empty,输出进程先 P full 再 P mutex,形成环路等待。 解决:所有进程统一先 P 资源信号量(empty/full),再 P 互斥信号量(mutex),顺序不能乱。5. 进阶玩法:把井换成真实文件、加优先级、看调度效果把基础版跑通之后,我一般会做三件事来加深理解。第一,把输出井从内存数组换成磁盘文件。用fopen打开一个临时文件当井,请求进程fwrite,输出进程fread。这样能直观看到"假脱机"里"脱机"的含义——数据先落到磁盘,设备操作和请求操作在时间上彻底解耦。文件指针的移动要配合fseek,注意每次写完要fflush,否则输出进程可能读到空。第二,给请求表加优先级字段。让 pid 为 1 的进程优先级高于 pid 为 2,输出进程每次从井里取数据时,先扫描请求表里状态为"待输出"且优先级最高的记录。这能模拟真实 spooling 系统里"按优先级调度"的行为。改法是在输出进程取数据前加一段扫描:int pick_next() { int best = -1, best_prio = 999; for (int i = 0; i < MAX_REQ; i++) { if (req_table[i].state == 1 && req_table[i].prio < best_prio) { best_prio = req_table[i].prio; best = i; } } return best; // 返回优先级最高的请求下标 }逻辑说明:state == 1过滤出还没输出的记录,prio越小优先级越高。这个函数要在 mutex 保护下调用,返回 -1 表示当前没有待输出请求。第三,用time命令或者自己打时间戳,对比"有 spooling"和"没 spooling"两种模式下 2 个请求进程的总耗时。没 spooling 时,两个进程串行等打印机,总时间约等于两者之和;有 spooling 时,请求进程几乎不等待,总时间接近输出进程单独打印的时间。这个对比数据放在课程设计报告里,比任何文字描述都有说服力。从那以后我每次做并发模拟,都强制先把信号量的 P/V 顺序在纸上画一遍再写代码,画不清楚就不动手。希望帮到你。本文还有配套的精品资源,点击获取
简介:这份资源围绕操作系统中的Spooling假脱机输入输出模拟技术展开,面向计算机专业学生及系统设计学习者,帮助理解如何通过磁盘缓冲将慢速I/O设备虚拟化,实现多进程共享打印机等外设。包内共11个文件,以5个class编译文件、1个java源程序、1个doc设计报告和4张jpg运行截图为主,源程序展示请求输出进程与spooling输出进程的协作逻辑,报告与截图则呈现设计思路和实际运行状态,压缩包约279KB。已有441人学习。读者可从中获得完整的进程模型实现、输入井与输出井的缓冲管理代码、设备驱动交互细节,以及可对照复现的实验报告与界面截图,适合课程设计、实验报告撰写和操作系统原理的深入理解。1. 从一次打印队列卡死说起:spooling 假脱机到底在模拟什么上周帮同事看一个模拟程序,现象很怪:两个请求输出的进程轮流往打印机写数据,结果第一个进程刚写完一半,第二个进程的数据就插进来了,打印出来的内容交错成一团乱码。他以为是并发锁没加对,查了半天才发现,问题出在没理解 spooling 假脱机输入输出模拟技术的核心——独占设备怎么被改造成共享设备。这个资源就是围绕这个场景做的:用程序模拟 spooling 技术,具体配置是2 个请求输出进程 + 1 个 spooling 输出进程。它要解决的不是"怎么调用打印机 API",而是操作系统层面那个经典问题——多个进程同时要用一台独占设备时,怎么让它们不打架、不阻塞、还能按顺序输出。适合正在学操作系统原理、要做课程设计、或者想搞懂"假脱机"这三个字到底假在哪的从业者。下面我按自己拆这套东西的顺序,把原理、代码结构、参数设置和踩过的坑一条条讲清楚。2. spooling 的三层结构:输入井、输出井和那张关键的表2.1 为什么不能直接让进程抢打印机先想清楚一个前提:打印机、磁带机这类设备,物理特性决定了同一时刻只能有一个进程真正操作它。如果放任 2 个请求输出进程直接去抢,要么加互斥锁让一个等另一个(进程阻塞,CPU 空转),要么不加锁导致输出交错(就是开头那个乱码现场)。spooling 的思路是加一层中间缓冲:进程不直接碰设备,而是把要输出的数据先写到磁盘上的一个区域,再由一个专门的进程去读这个区域、真正驱动设备。这样请求输出的进程写完就走,不用等设备,设备那头也有序。这层磁盘缓冲,就是"井"。2.2 输入井与输出井的分工在模拟程序里,通常用两个数据结构来代表井:结构作用模拟中的常见实现输出井存放 2 个请求进程要打印的数据一个队列或数组,每个元素是一段待输出内容输出进程从井里取数据、驱动"打印机"一个独立循环,按顺序消费队列请求表记录哪个进程申请了输出、数据在哪结构体数组,含进程号、数据地址、长度、状态输出井是共享资源,2 个请求进程都能往里写,所以对井的写入要互斥;输出进程从井里读,也要和写入互斥。但注意——互斥的是井,不是打印机。打印机只被输出进程一个人碰,天然不存在竞争。这就是 spooling 把独占设备"虚拟"成共享设备的关键:竞争被转移到了井上,而井是内存/磁盘结构,加锁成本远低于设备。2.3 请求表:整个模拟的调度中枢请求表是很多人写模拟时容易忽略的部分。它不只是记录,还承担状态流转:// 请求表项结构,模拟 spooling 输出请求 typedef struct { int pid; // 请求进程号,1 或 2 int data_addr; // 数据在输出井中的起始位置 int length; // 数据长度 int state; // 0=空闲 1=已提交待输出 2=正在输出 3=已完成 } SpoolRequest; SpoolRequest req_table[MAX_REQ]; // 请求表 int out_well[WELL_SIZE]; // 输出井,模拟磁盘缓冲 int well_head, well_tail; // 井的读写指针逻辑说明:pid区分是哪个请求进程提交的,state让输出进程知道该处理哪条。well_head和well_tail构成一个环形缓冲,避免井无限增长。参数上,WELL_SIZE决定井能缓冲多少数据,太小会导致请求进程写不进去而等待,太大则失去"缓冲"的模拟意义,一般设成单次输出量的 3 到 5 倍比较合适。提示:请求表的状态字段一定要在互斥区内更新,否则输出进程可能读到"半提交"的记录。3. 2 个请求进程 + 1 个输出进程:用信号量把并发顺序管起来3.1 信号量怎么设:三个就够这套模拟的并发控制,核心是三个信号量:sem_t mutex; // 保护输出井和请求表的互斥访问,初值 1 sem_t empty; // 井中空闲槽位数,初值 WELL_SIZE sem_t full; // 井中已提交待输出的记录数,初值 0请求进程的流程是:申请一个空槽(P empty)→ 拿互斥锁(P mutex)→ 写数据进井、填请求表 → 释放锁(V mutex)→ 通知有数据了(V full)。输出进程反过来:P full → P mutex → 从井里取数据、更新请求表 → V mutex → V empty。这样设计的好处是,2 个请求进程之间不需要直接互斥,它们通过 empty 和 full 这两个计数信号量自然排队。谁先拿到空槽谁先写,写完了输出进程按井里顺序取,天然保证输出有序。3.2 请求进程的代码骨架void *request_process(void *arg) { int pid = *(int *)arg; for (int i = 0; i < REQ_TIMES; i++) { sem_wait(&empty); // 等一个空槽 sem_wait(&mutex); // 进临界区 // 把本进程第 i 条数据写入输出井 out_well[well_tail] = pid * 100 + i; well_tail = (well_tail + 1) % WELL_SIZE; // 填请求表 req_table[req_idx].pid = pid; req_table[req_idx].state = 1; req_idx++; sem_post(&mutex); // 出临界区 sem_post(&full); // 通知输出进程 usleep(rand() % 100000); // 模拟请求间隔 } return NULL; }逻辑说明:sem_wait(&empty)保证井满时请求进程会阻塞等待,不会覆盖未输出的数据。usleep那行模拟真实场景里请求不是连续不断的,加随机间隔能暴露调度顺序问题。参数REQ_TIMES控制每个请求进程提交多少次,设成 5 到 10 次既能看出交错效果,又不至于跑太久。3.3 输出进程的消费逻辑void *spool_output_process(void *arg) { while (output_count < TOTAL_REQ) { sem_wait(&full); // 等有数据可输出 sem_wait(&mutex); // 从井头取数据 int data = out_well[well_head]; well_head = (well_head + 1) % WELL_SIZE; // 更新请求表状态为"正在输出" req_table[out_idx].state = 2; sem_post(&mutex); sem_post(&empty); // 释放一个空槽 printf("输出进程打印: %d\n", data); // 模拟驱动打印机 req_table[out_idx].state = 3; out_idx++; output_count++; usleep(200000); // 模拟打印耗时 } return NULL; }逻辑说明:输出进程是唯一的消费者,full为 0 时它会阻塞,不会空转。usleep(200000)模拟打印机的物理速度,这个值要比请求进程的间隔大,才能看出"井"起到的缓冲作用——请求进程可以快速写满井然后继续干别的,输出进程慢慢打。注意:output_count和out_idx如果被多个线程访问,也要纳入互斥保护,别只锁了井忘了计数器。4. 避坑排查:模拟跑不通时先看这五条4.1 输出内容交错、顺序全乱现象:打印出来的数据是1 2 1 2这种交错,而不是先来先出。 原因:请求进程写井时没加互斥,两个进程同时移动了well_tail,导致数据覆盖或指针错位。 解决:确认sem_wait(&mutex)包住了写井和更新请求表的全部操作,一个都不能漏。4.2 程序卡死不动,谁也不输出现象:跑起来就挂住,没有任何打印。 原因:信号量初值设错,最常见的是full初值给了 1 或者empty初值给了 0。 解决:empty必须等于井容量,full必须从 0 开始。检查sem_init的第三个参数。4.3 输出进程只打了一条就退出现象:只输出第一条数据,循环就结束了。 原因:循环终止条件写成了while(1)里判断full的值,或者output_count没累加。 解决:终止条件应该基于总请求数,而不是井的当前状态。把TOTAL_REQ设成两个请求进程提交次数之和。4.4 井满了请求进程还在写,数据丢失现象:输出条数少于提交条数。 原因:empty信号量没在输出进程里正确V操作,空槽没被释放。 解决:输出进程每消费一条,必须sem_post(&empty),位置放在取完数据之后、打印之前都行,但不能忘。4.5 加了锁反而更慢甚至死锁现象:程序偶尔卡在某个sem_wait上。 原因:P 操作顺序不一致,比如请求进程先 P mutex 再 P empty,输出进程先 P full 再 P mutex,形成环路等待。 解决:所有进程统一先 P 资源信号量(empty/full),再 P 互斥信号量(mutex),顺序不能乱。5. 进阶玩法:把井换成真实文件、加优先级、看调度效果把基础版跑通之后,我一般会做三件事来加深理解。第一,把输出井从内存数组换成磁盘文件。用fopen打开一个临时文件当井,请求进程fwrite,输出进程fread。这样能直观看到"假脱机"里"脱机"的含义——数据先落到磁盘,设备操作和请求操作在时间上彻底解耦。文件指针的移动要配合fseek,注意每次写完要fflush,否则输出进程可能读到空。第二,给请求表加优先级字段。让 pid 为 1 的进程优先级高于 pid 为 2,输出进程每次从井里取数据时,先扫描请求表里状态为"待输出"且优先级最高的记录。这能模拟真实 spooling 系统里"按优先级调度"的行为。改法是在输出进程取数据前加一段扫描:int pick_next() { int best = -1, best_prio = 999; for (int i = 0; i < MAX_REQ; i++) { if (req_table[i].state == 1 && req_table[i].prio < best_prio) { best_prio = req_table[i].prio; best = i; } } return best; // 返回优先级最高的请求下标 }逻辑说明:state == 1过滤出还没输出的记录,prio越小优先级越高。这个函数要在 mutex 保护下调用,返回 -1 表示当前没有待输出请求。第三,用time命令或者自己打时间戳,对比"有 spooling"和"没 spooling"两种模式下 2 个请求进程的总耗时。没 spooling 时,两个进程串行等打印机,总时间约等于两者之和;有 spooling 时,请求进程几乎不等待,总时间接近输出进程单独打印的时间。这个对比数据放在课程设计报告里,比任何文字描述都有说服力。从那以后我每次做并发模拟,都强制先把信号量的 P/V 顺序在纸上画一遍再写代码,画不清楚就不动手。希望帮到你。本文还有配套的精品资源,点击获取