【操作系统】第二次作业
文章目录
- 【操作系统】第二次作业
- 一:填空题
- 1.用户态到核心态的切换是通过系统调用完成的。
- 2.用户程序想要使用系统资源,必须通过系统调用方式向操作系统提出服务请求。
- 3.操作系统提供的图像界面方式的使用,本质上是通过窗口系统、图形库、系统调用、硬件抽象层和显示驱动程序共同合作机制实现的。
- 4.用户级线程的缺陷在于,当一个进程中的某个线程发起资源请求,则整个进程都将进入阻塞状态。内核级线程是由系统完成线程的管理,所以,当一个进程中的某个线程发起资源请求时,系统将调度该进程中的另一个线程进入运行状态,不会让整个进程进入阻塞状态。
- 5.哲学家就餐问题中,为了保障哲学家们能先后有序吃上饭,我们需要设置信号量,使用适当的P、V操作。假设一共有9位哲学家,哲学家左手边的筷子编号和哲学家自己的编号一致,则需要设置①个信号量?哲学家就餐的伪代码如下,请填补空缺:
- 6.进程控住块PCB是进程存在的标志。
- 二:简答题
- 7.请简述进程和线程的区别。
- 8.编写一个打开文件的程序,调用fork创建一个新进程。子进程和父进程都可以访问open返回的文件描述符吗?当父子进程并发写入文件时,会发生什么?【截图代码、运行过程、运行结果】
- 9.编写代码实现在一个进程中创建多个线程,且不同的线程执行不同的程序,运行该程序,分析可能的运行结果。【给出代码截图、运行结果】
- 10.设置信号量sem来保障n个进程之间对临界区的互斥访问时,信号量的初值设为(1)比较合适。进程执行的伪代码如下:
- 11.请用PV操作描述学生交作业-教师在网上批改作业的过程:
- 12.在Linux系统下,用信号量机制编程实现生产者消费者问题。 [截图给出代码,结果和调试过程]
- 13.在上一题的基础上实现多个线程对1个面包柜中的5个面包或空位的资源争夺。并尝试将互斥信号量和同步信号量的操作交换位置,查看运行结果。 [截图给出代码,结果和调试过程]
一:填空题
1.用户态到核心态的切换是通过系统调用完成的。
2.用户程序想要使用系统资源,必须通过系统调用方式向操作系统提出服务请求。
答:当一个应用程序需要操作硬件资源(如文件、内存、网络等)时,它必须请求操作系统的内核来完成这些操作,因为用户态(User Mode)没有直接访问硬件的权限。
切换过程的大致步骤如下:
graph LR
A[系统调用接口] -->B[陷入(Trap)指令]
B --> C[模式切换]
C --> D[内核态执行]
D --> E[返回用户态]
-
系统调用接口:用户态程序通过系统调用接口(如 Linux 中的 read、write 等)请求内核提供的服务。
-
陷入(Trap)指令:系统调用会触发一条特殊的 CPU 指令,称为陷入指令(如 int 0x80 或 syscall),这会让 CPU 切换到核心态。
-
模式切换:陷入指令将 CPU 从用户态切换到核心态,并且将控制权交给操作系统内核中的相应的系统调用处理例程。
-
内核态执行:内核执行相应的系统调用操作,如文件读写、内存分配等。此时,内核具有对硬件资源的完全访问权限。
-
返回用户态:当系统调用完成后,内核通过返回指令(如 iret)将 CPU 模式切回用户态,继续执行用户程序。
3.操作系统提供的图像界面方式的使用,本质上是通过窗口系统、图形库、系统调用、硬件抽象层和显示驱动程序共同合作机制实现的。
答:
- 窗口系统(Window System):窗口系统是 GUI 的核心组件,用于管理屏幕上的窗口、处理用户输入(如鼠标、键盘),并将其分发给对应的应用程序。它负责窗口的绘制、更新、以及管理窗口之间的重叠。常见的窗口系统有:
-
Windows GDI(Graphics Device Interface):Windows 操作系统的图形管理机制。
-
X Window System:主要用于 Unix 和 Linux 操作系统。
-
Wayland:一种现代化的 Linux 图形协议,替代 X Window 系统。
- 图形库(Graphics Library):图形库提供了绘制基本图形元素(如点、线、矩形、文本、图像等)的功能。应用程序通过调用图形库的函数来绘制界面元素。常见的图形库包括:
-
OpenGL:用于 2D 和 3D 图形渲染的跨平台图形 API。
-
DirectX:微软提供的多媒体和图形 API。
-
Cairo:用于 2D 图形绘制的跨平台库。
-
系统调用(System Calls):用户态程序无法直接操作硬件资源,因此需要通过系统调用向操作系统内核请求资源,比如显卡资源。图形界面的底层操作,如在屏幕上绘制图像、管理窗口等,都是通过系统调用实现的。通过 API 函数调用,程序请求内核来控制图形硬件(如 GPU)执行绘图操作。
-
硬件抽象层(Hardware Abstraction Layer, HAL):硬件抽象层将硬件设备(如显卡)的细节封装,提供统一的接口供操作系统和驱动程序使用。通过 HAL,操作系统可以与不同类型的硬件交互,而不需要直接处理设备的底层细节。
-
显示驱动程序(Display Driver):显示驱动程序负责将操作系统的绘图指令转换为显卡能够理解的硬件指令,最终由显卡完成实际的图像渲染。这是操作系统与图形硬件交互的中介,确保不同硬件能够通过标准化接口实现图像输出。
4.用户级线程的缺陷在于,当一个进程中的某个线程发起资源请求,则整个进程都将进入阻塞状态。内核级线程是由系统完成线程的管理,所以,当一个进程中的某个线程发起资源请求时,系统将调度该进程中的另一个线程进入运行状态,不会让整个进程进入阻塞状态。
答:阻塞状态(Blocked state 或 Waiting state)是进程或线程在操作系统中的一种状态,表示它们暂时无法继续执行,正在等待某个事件的发生。这种事件可能包括等待 I/O 操作完成、请求的资源可用、或者等待某个条件满足等。
阻塞状态的特征:
- 不可执行:处于阻塞状态的进程或线程不能被调度执行,操作系统会将其移出就绪队列,直到等待的事件发生。
- 等待资源:进程或线程可能在等待外部设备的响应(例如读取磁盘数据或网络请求),或等待某个信号量或锁释放资源。
- 恢复执行:一旦等待的条件满足,操作系统将会把该进程或线程从阻塞状态切换到就绪状态,准备在合适的时间重新投入执行。
举例:
- I/O 阻塞:当进程请求读写文件或等待网络数据时,它会进入阻塞状态,直到 I/O 操作完成。
- 同步原语阻塞:当线程在多线程编程中尝试获取某个锁或信号量,而锁被其他线程占用时,它会进入阻塞状态,直到锁被释放。
阻塞与线程类型的关系:
-
用户级线程:如果某个用户级线程发起 I/O 操作或进入阻塞状态,由于操作系统只看到整个进程,它会将整个进程阻塞,所有线程都无法运行。
-
内核级线程:内核级线程的管理由操作系统内核负责,当一个线程阻塞时,操作系统可以调度同一进程中的其他线程继续执行,而不会让整个进程阻塞。
5.哲学家就餐问题中,为了保障哲学家们能先后有序吃上饭,我们需要设置信号量,使用适当的P、V操作。假设一共有9位哲学家,哲学家左手边的筷子编号和哲学家自己的编号一致,则需要设置①个信号量?哲学家就餐的伪代码如下,请填补空缺:
int S[②]={③};//信号量数组,及初值设置
UINT Philosopher(int i) //第i号哲学家就餐
{
while(TRUE)
{思考;休息;
④ //取左手的筷子
⑤ //取右手的筷子
吃饭;
⑥ //放右手筷子
⑦ //放左手筷子
}
}
[!NOTE]
【哲学家问题】
假设有五位哲学家围坐在一张圆形餐桌旁,做以下两件事情之一:吃饭,或者思考。吃东⻄的时 候,他们就停止思考,思考的时候也停止吃东⻄。餐桌中间有一大碗意大利面,每两个哲学家之间 有一只餐叉。因为用一只餐叉很难吃到意大利面,所以假设哲学家必须用两只餐叉吃东⻄。他们只 能使用自己左右手边的那两只餐叉。 每个哲学家分为3个状态{thinking,trying,eating},分别代表思考,尝试获得叉子,吃面三个状 态。 哲学家从来不交谈,因此可能产生死锁,即某个时刻每个哲学家都拿着一只餐叉,永远都在等另一 只餐叉,也就是都处于trying的状态。
① 需要设置 9 个信号量,每个信号量代表一根筷子(因为每个哲学家需要两根筷子才能吃饭)。
② 9。信号量数组应该有 9 个元素,对应每根筷子的状态(是否被占用)。
③ {1, 1, 1, 1, 1, 1, 1, 1, 1}, 初值应为 1,表示每根筷子初始时都是可用的。
④ P(S[i]); // 取左手的筷子 (使用P操作锁定筷子i)
⑤P(S[(i+1) % 9]); // 取右手的筷子 (使用P操作锁定右边的筷子, 编号为(i+1)%9)
⑥ V(S[(i+1) % 9]); // 放右手筷子 (使用V操作释放右边的筷子)
⑦ V(S[i]); // 放左手筷子 (使用V操作释放左手的筷子)
说明:
- P(S[i]) 表示哲学家尝试获取第 i 根筷子,使用 P 操作对该筷子进行加锁,确保它被独占使用。
- P(S[(i+1)%9]) 表示哲学家获取右手边的筷子,其编号为 (i+1)%9,这是为了循环处理哲学家和筷子之间的关系。
- V(S[i]) 和 V(S[(i+1)%9]) 是放下筷子的操作,使用 V 操作解锁相应的信号量,释放资源。
6.进程控住块PCB是进程存在的标志。
答:其他可查看进程的标志:
-
进程标识符(PID):每个进程都有一个唯一的PID,可以通过PID来检查该进程是否存在。
-
进程状态:操作系统会维护进程的状态信息,例如:运行(Running),就绪(Ready),阻塞(Blocked)
-
进程控制块(PCB):操作系统会为每个进程维护一个进程控制块,其中包含进程的所有信息,包括状态、优先级、上下文信息等。
-
系统调用返回值:例如,在Unix/Linux系统中,可以使用kill -0 命令来检查一个进程是否存在。如果进程存在,返回值为0;如果不存在,返回值为1。
-
进程表:操作系统的内核维护一个进程表,记录所有活动进程的信息。通过查看进程表可以判断进程的存在性。
二:简答题
7.请简述进程和线程的区别。
进程是独立的程序运行单位,拥有自己的资源和内存空间,适用于任务之间高度隔离的场景。线程是共享进程资源的执行单位,适用于需要频繁通信和共享资源的任务,但共享资源的同时也意味着线程之间的影响更大。
线程和进程的区别主要体现在以下几个方面:
- 定义:
- 进程:是操作系统中资源分配的最小单位。每个进程有自己的内存空间、数据段、代码段等,是一个独立的程序运行实体。
- 线程:是CPU调度的最小单位。线程属于进程,同一个进程中的线程共享该进程的资源(如内存、文件句柄等),但每个线程有自己的栈空间和寄存器。
- 资源分配:
- 进程:拥有自己独立的资源,如内存空间、全局变量、文件描述符等。进程之间的资源是隔离的,进程间通信(IPC)比较复杂。
- 线程:线程共享同一进程的资源,同一进程中的线程可以直接访问进程的全局变量和数据,线程间通信相对简单。
- 调度和开销:
- 进程:切换进程(进程上下文切换)开销较大,因为需要保存和恢复大量状态信息,如内存、寄存器等。
- 线程:线程切换的开销较小,因为线程共享进程的资源,只需切换少量的信息,如寄存器和栈指针。
- 独立性:
- 进程:进程之间是相对独立的,一个进程崩溃通常不会影响其他进程。
- 线程:同一进程中的线程之间联系紧密,一个线程崩溃可能会导致整个进程崩溃。
- 创建与销毁:
- 进程:创建和销毁进程的开销较大,因为需要分配和回收大量的资源。
- 线程:创建和销毁线程的开销较小,线程的资源比进程少。
6.并行与并发:
- 进程:进程可以在多核系统上实现真正的并行运行,但在单核系统上只能通过时间片轮转实现并发。
- 线程:同一进程中的多个线程可以同时执行,尤其在多核系统上,可以实现真正的并行执行。
8.编写一个打开文件的程序,调用fork创建一个新进程。子进程和父进程都可以访问open返回的文件描述符吗?当父子进程并发写入文件时,会发生什么?【截图代码、运行过程、运行结果】
答:在Linux中,使用fork()创建子进程后,子进程会继承父进程的所有文件描述符。因此,父进程和子进程可以同时访问由open()返回的同一个文件描述符。不过,当父子进程并发写入同一个文件时,如果没有进行同步控制,文件的写入顺序将是不可预测的,因为它们是独立的进程。
代码:
#include<studio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<fcntl.h>
int main(){
//打开文件
int fd = open("output.txt",O_WRONLY|O_CREAT|O_TRUNC,0644);
if(fd < 0){
perror("open");
return 1;
}
//创建子进程
pid_t pid = fork();
if(pid < 0){
perror("fork");
return 1;
}
if(pid == 0){
//子进程
const char*child_msg = "THIS IS CHILD PROCESS\n";
for(int i = 0;i < 5;i++){
write(fd,child_msg,sizeof("THIS IS CHILD PROCESS\n")-1);
sleep(1);
}
}else{
//父进程
const char*parent_msg = "THIS IS PARENT PROCESS\n";
for(int i = 0;i < 5;i++){
write(fd,child_msg,sizeof("THIS IS PARENT PROCESS\n")-1);
sleep(1);
}
//等待子进程结束
wait(NULL);
}
//关闭文件
close(fd);
return 0;
}
**运行结果:**该程序会创建一个 output.txt 文件,并且父进程和子进程会交替写入该文件。由于父进程和子进程同时写入文件,文件的内容可能是交替出现父进程和子进程的消息,但并不保证顺序一致,结果可能类似如下:
说明:
-
父进程和子进程通过fork()共享了同一个文件描述符。
-
写入文件时没有同步机制,因此父进程和子进程的写入顺序无法预测。
-
如果不进行适当的同步(如使用文件锁),可能会导致文件中的数据顺序混乱,甚至发生竞态条件。
9.编写代码实现在一个进程中创建多个线程,且不同的线程执行不同的程序,运行该程序,分析可能的运行结果。【给出代码截图、运行结果】
答;使用代码编写一个多线程的程序,在一个进程中创建多个线程,并使不同的线程执行不同的函数。
代码:
- 定义了三个不同的任务函数 task1、task2 和 task3,分别用于线程 1、2、3 来执行不同的操作。
- 使用 pthread_create 创建三个线程,每个线程都执行不同的任务函数。
- 主线程通过 pthread_join 等待所有线程完成它们的任务。
- 每个任务函数中使用 sleep 来模拟任务耗时,表示不同的线程可能会有不同的执行时间。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
// 线程1要执行的函数
void* task1(void* arg) {
printf("Thread 1: Executing task 1...\n");
sleep(1); // 模拟任务耗时
printf("Thread 1: Task 1 completed.\n");
return NULL;
}
// 线程2要执行的函数
void* task2(void* arg) {
printf("Thread 2: Executing task 2...\n");
sleep(2); // 模拟任务耗时
printf("Thread 2: Task 2 completed.\n");
return NULL;
}
// 线程3要执行的函数
void* task3(void* arg) {
printf("Thread 3: Executing task 3...\n");
sleep(3); // 模拟任务耗时
printf("Thread 3: Task 3 completed.\n");
return NULL;
}
int main() {
pthread_t thread1, thread2, thread3;
// 创建线程
if (pthread_create(&thread1, NULL, task1, NULL)) {
fprintf(stderr, "Error creating thread 1\n");
return 1;
}
if (pthread_create(&thread2, NULL, task2, NULL)) {
fprintf(stderr, "Error creating thread 2\n");
return 1;
}
if (pthread_create(&thread3, NULL, task3, NULL)) {
fprintf(stderr, "Error creating thread 3\n");
return 1;
}
// 等待所有线程结束
if (pthread_join(thread1, NULL)) {
fprintf(stderr, "Error joining thread 1\n");
return 2;
}
if (pthread_join(thread2, NULL)) {
fprintf(stderr, "Error joining thread 2\n");
return 2;
}
if (pthread_join(thread3, NULL)) {
fprintf(stderr, "Error joining thread 3\n");
return 2;
}
printf("Main thread: All tasks are completed.\n");
return 0;
}
运行结果:
运行该程序时,线程 1、2、3 会并发地执行。由于每个线程的任务耗时不同,输出的顺序可能会有所变化,但最终的输出大致如下:
[!NOTE]
问题分析: pthread不是linux下的默认的库,也就是在链接的时候,无法找到phread库中join函数的入口地址,于是链接会失败。
解决方案: 编译命令后,附加 -lpthread 参数。(-l是L是小写 不是i的大写I)
结果分析:
- 虽然线程是并发执行的,但因为 task1 只需要 1 秒,task2 需要 2 秒,task3 需要 3 秒,所以通常 Thread 1 的任务会首先完成,接着是 Thread 2,最后是 Thread 3。
- 线程执行的顺序不一定固定,特别是在多核 CPU 上,各个线程可能会被操作系统调度到不同的 CPU 核上同时执行,但由于 sleep 的时间不同,最终的完成顺序依然可能如上所述。
- 主线程会等待所有线程完成任务,然后输出最后一条消息,表明所有任务已经完成。
10.设置信号量sem来保障n个进程之间对临界区的互斥访问时,信号量的初值设为(1)比较合适。进程执行的伪代码如下:
Process_i (sem) //第i个进程
{ ……
P(sem)
进入临界区执行
V(sem)
……}
[!NOTE]
信号量的工作原理
P(sem) 操作是等待操作(通常称为 wait),它会将信号量的值减1。当信号量值大于0时,进程可以进入临界区;否则,进程必须等待。
V(sem) 操作是释放操作(通常称为 signal),它会将信号量的值加1。当一个进程离开临界区时,信号量的值加1,允许其他进程进入临界区。
答:对于互斥访问的场景,信号量(sem)的初值设置为1,当信号量的初值为1时,只允许一个进程进入临界区,确保互斥访问。多个进程尝试进入临界区时,如果有进程已经在临界区,其他进程会被阻塞,直到这个进程退出并执行 V(sem) 操作。
(2)若临界资源有2个,但有3个进程竞争临界资源,请问信号量的初值设为多少合适?并图示说明各进程使用临界资源的过程。
答:当有2个临界资源,3个进程竞争时,信号量的初值应该为2。这样最多允许两个进程同时进入临界区,第三个进程必须等待,直到有进程释放临界资源。
图示说明:
设三个进程为 Process_1、Process_2 和 Process_3,信号量初值为 2。以下是各个进程竞争临界资源的过程:
Process_1 | Process_2 | Process_3 |
---|---|---|
P(sem)->sem = 1【进入临界区】 | P(sem)->sem = 0【进入临界区】 | P(sem)->sem = 0 【等待,无空闲资源】 |
V(sem)->sem= 1 【退出临界区】 | P(sem)->sem = 0 【获得资源进入临界区】 | |
V(sem)->sem= 1 【退出临界区】 | ||
V(sem)->sem = 2 【退出临界区】 |
说明:
-
信号量初值为2时,允许最多2个进程同时进入临界区。
-
第三个进程必须等待直到有一个临界资源被释放。
11.请用PV操作描述学生交作业-教师在网上批改作业的过程:
学生:线上提交作业,等待教师批改,待教师批改完后下载查看作业成绩;
教师:待学生提交完作业后,线上批改作业;
注意,学生和教师不能同时操作,避免作业数据出错。
答:
(1)设置
设置一个互斥信号量 S,初始值为 1,表示作业的唯一性,确保同时只有一方(学生或教师)可以操作作业。
设置一个条件信号量 C,初始值为 0,表示学生作业是否提交,防止教师在作业未提交时进行批改。
(2)过程描述:
学生提交作业:
- P(S):学生请求操作,检测是否可以提交作业。如果 S = 1,表示作业可操作;如果 S = 0,学生必须等待。
- 提交作业:学生在线提交作业。
- V©:作业提交完毕,通知教师可以进行批改,将 C 置为 1,表示作业已提交。
- V(S):释放互斥信号量 S,允许教师或其他学生继续操作。
教师批改作业:
- P©:教师请求批改作业,检测作业是否已经提交。如果 C = 0,表示学生尚未提交作业,教师必须等待。
- P(S):教师请求操作,检测是否可以批改作业。如果 S = 1,表示作业可操作;如果 S = 0,教师必须等待。
- 批改作业:教师在线批改作业并上传成绩。
- V(S):释放互斥信号量 S,允许学生查看批改后的作业或提交新的作业。
(3)避免同时操作
学生提交作业时使用 P(S) 确保学生独占访问权限,提交完成后通过 V© 通知教师可以批改作业。教师通过 P© 等待学生提交作业,然后使用 P(S) 确保教师独占访问权限,批改完成后释放 S,使其他学生或教师可以继续操作。这样,P、V 操作确保了学生和教师在同一时间不会同时操作同一份作业,避免了数据冲突。
12.在Linux系统下,用信号量机制编程实现生产者消费者问题。 [截图给出代码,结果和调试过程]
在Linux系统下,信号量机制用于解决生产者-消费者问题是一种常见的同步方法。可以通过POSIX信号量(sem_t)来实现。
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
#define BUFFER_SIZE 5
int buffer[BUFFER_SIZE]; // 缓冲区
int in = 0, out = 0; // 生产者和消费者的指针
sem_t empty; // 表示空槽位
sem_t full; // 表示已占用槽位
pthread_mutex_t mutex; // 互斥锁保护缓冲区
// 生产者函数
void *producer(void *arg) {
int item;
while (1) {
item = rand() % 100; // 生成随机数作为产品
sem_wait(&empty); // 等待空槽位
pthread_mutex_lock(&mutex); // 进入临界区
// 将产品放入缓冲区
buffer[in] = item;
printf("Producer produced: %d\n", item);
in = (in + 1) % BUFFER_SIZE;
pthread_mutex_unlock(&mutex); // 离开临界区
sem_post(&full); // 增加已占用槽位
sleep(1); // 模拟生产耗时
}
}
// 消费者函数
void *consumer(void *arg) {
int item;
while (1) {
sem_wait(&full); // 等待已占用槽位
pthread_mutex_lock(&mutex); // 进入临界区
// 从缓冲区取产品
item = buffer[out];
printf("Consumer consumed: %d\n", item);
out = (out + 1) % BUFFER_SIZE;
pthread_mutex_unlock(&mutex); // 离开临界区
sem_post(&empty); // 增加空槽位
sleep(2); // 模拟消费耗时
}
}
int main() {
pthread_t prod_tid, cons_tid;
// 初始化信号量和互斥锁
sem_init(&empty, 0, BUFFER_SIZE); // 缓冲区最开始时有 BUFFER_SIZE 个空槽位
sem_init(&full, 0, 0); // 缓冲区最开始时没有已占用的槽位
pthread_mutex_init(&mutex, NULL);
// 创建生产者和消费者线程
pthread_create(&prod_tid, NULL, producer, NULL);
pthread_create(&cons_tid, NULL, consumer, NULL);
// 等待线程结束
pthread_join(prod_tid, NULL);
pthread_join(cons_tid, NULL);
// 销毁信号量和互斥锁
sem_destroy(&empty);
sem_destroy(&full);
pthread_mutex_destroy(&mutex);
return 0;
}
运行结果:
程序运行后,生产者(producer)会持续生产产品并将其放入缓冲区,而消费者(customer)会持续从缓冲区取出产品。但由于缓冲区的空间有限,当缓冲区满时,生产者会等待直到消费者消费了一部分产品腾出空间;当缓冲区为空时,消费者会等待直达生产者生产新的商品。
…(结果较长,不一一展示)
[!NOTE]
这个是因为pthread并非Linux系统的默认库,编译时注意加上-lpthread参数,以调用链接库
我们再一次在终端输入:gcc -o consumer.out consumer.c -lpthread
此时编译正确
流程图:
在使用信号量(Semaphore)解决生产者-消费者问题(Producer-Consumer Problem)时,我们通常会创建两个类型的信号量:一个用于控制生产者的进入(生产缓冲区的空闲状态),另一个用于控制消费者的离开(缓冲区满的状态)。以下是基本的流程图步骤:
-
生产者:
- 初始化生产者信号量
sem_producer
到 0,表示当前缓冲区无可用空间。 - 生产商品(例如物品): a. 调用
P(sem_producer)
(信号量减一),等待直到生产缓冲区有空间。 b. 将物品放入缓冲区。 c. 如果缓冲区不满(即sem_producer > 0
),则释放一个单位的消费者信号量V(sem_consumer)
,允许消费者开始工作。
- 初始化生产者信号量
-
缓冲区:
- 存放生产者放入的商品。
-
消费者:
- 初始化消费者信号量
sem_consumer
到 1,表示缓冲区有一个空位。 - 消费商品: a. 调用
V(sem_producer)
(如果缓冲区满,则减少一个单位的生产者信号量)。 b. 从缓冲区取出一个商品并消费。 c. 如果缓冲区未满(即sem_consumer > 0
),则释放一个单位的生产者信号量P(sem_producer)
,让其他生产者继续工作。
- 初始化消费者信号量
-
循环条件:
-
生产者和消费者都应在满足相应信号量条件后,检查退出条件(如用户请求、资源耗尽等),然后结束进程。
-
13.在上一题的基础上实现多个线程对1个面包柜中的5个面包或空位的资源争夺。并尝试将互斥信号量和同步信号量的操作交换位置,查看运行结果。 [截图给出代码,结果和调试过程]
答:在这个任务中,将模拟多个线程对面包柜资源的争夺,面包柜中最多可以存放5个面包。使用信号量来控制同步和互斥操作。
[!NOTE]
同步信号量:控制资源的使用,限制访问,例如控制面包的生产和消费。
互斥信号量:保护对共享资源(面包柜)的访问,避免多个线程同时修改面包柜的状态导致竞争。
首先,基于上一题的代码,创建多个生产者线程来模拟将面包放到面包柜中,多个消费者线程模拟取走面包,代码如下:
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
#define BREAD_CABINET_SIZE 5 // 面包柜最大容量
int bread_count = 0; // 当前面包柜中的面包数
sem_t empty_slots; // 同步信号量,表示空位数量
sem_t full_slots; // 同步信号量,表示面包数量
pthread_mutex_t mutex; // 互斥信号量,保护面包柜的访问
// 生产者:添加面包
void *producer(void *arg) {
while (1) {
sem_wait(&empty_slots); // 检查是否有空位(同步)
pthread_mutex_lock(&mutex); // 进入临界区(互斥)
bread_count++; // 放入一个面包
printf("Producer added a bread. Total bread: %d\n", bread_count);
pthread_mutex_unlock(&mutex); // 离开临界区
sem_post(&full_slots); // 增加已占用的面包槽位
sleep(1); // 模拟生产耗时
}
}
// 消费者:取走面包
void *consumer(void *arg) {
while (1) {
sem_wait(&full_slots); // 检查是否有面包可取(同步)
pthread_mutex_lock(&mutex); // 进入临界区(互斥)
bread_count--; // 取走一个面包
printf("Consumer took a bread. Total bread: %d\n", bread_count);
pthread_mutex_unlock(&mutex); // 离开临界区
sem_post(&empty_slots); // 增加空位
sleep(2); // 模拟消费耗时
}
}
int main() {
pthread_t prod1, prod2, cons1, cons2;
// 初始化信号量
sem_init(&empty_slots, 0, BREAD_CABINET_SIZE); // 初始有5个空位
sem_init(&full_slots, 0, 0); // 初始没有面包
pthread_mutex_init(&mutex, NULL); // 初始化互斥锁
// 创建生产者和消费者线程
pthread_create(&prod1, NULL, producer, NULL);
pthread_create(&prod2, NULL, producer, NULL);
pthread_create(&cons1, NULL, consumer, NULL);
pthread_create(&cons2, NULL, consumer, NULL);
// 等待线程执行(在本例中无限循环,故不会到达终点)
pthread_join(prod1, NULL);
pthread_join(prod2, NULL);
pthread_join(cons1, NULL);
pthread_join(cons2, NULL);
// 销毁信号量和互斥锁
sem_destroy(&empty_slots);
sem_destroy(&full_slots);
pthread_mutex_destroy(&mutex);
return 0;
}
运行结果截图:
说明:
- 生产者线程会不断地往面包柜中添加面包直到柜子满。
- 消费者线程会不断地取出面包柜中的面包直到柜子空。
- 信号量和互斥锁确保了面包柜不会被同时修改。
下面是交换互斥信号量和同步信号量的操作过程:
将以下两行代码交换顺序:
sem_wait(&empty_slots); // 检查是否有空位(同步)
pthread_mutex_lock(&mutex); // 进入临界区(互斥)
交换后的代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
#define BREAD_CABINET_SIZE 5 // 面包柜最大容量
int bread_count = 0; // 当前面包柜中的面包数
sem_t empty_slots; // 同步信号量,表示空位数量
sem_t full_slots; // 同步信号量,表示面包数量
pthread_mutex_t mutex; // 互斥信号量,保护面包柜的访问
// 生产者:添加面包
void *producer(void *arg) {
while (1) {
pthread_mutex_lock(&mutex); // 进入临界区(互斥)
sem_wait(&empty_slots); // 检查是否有空位(同步)
bread_count++; // 放入一个面包
printf("Producer added a bread. Total bread: %d\n", bread_count);
sem_post(&full_slots); // 增加已占用的面包槽位
pthread_mutex_unlock(&mutex); // 离开临界区
sleep(1); // 模拟生产耗时
}
}
// 消费者:取走面包
void *consumer(void *arg) {
while (1) {
pthread_mutex_lock(&mutex); // 进入临界区(互斥)
sem_wait(&full_slots); // 检查是否有面包可取(同步)
bread_count--; // 取走一个面包
printf("Consumer took a bread. Total bread: %d\n", bread_count);
sem_post(&empty_slots); // 增加空位
pthread_mutex_unlock(&mutex); // 离开临界区
sleep(2); // 模拟消费耗时
}
}
int main() {
pthread_t prod1, prod2, cons1, cons2;
// 初始化信号量
sem_init(&empty_slots, 0, BREAD_CABINET_SIZE); // 初始有5个空位
sem_init(&full_slots, 0, 0); // 初始没有面包
pthread_mutex_init(&mutex, NULL); // 初始化互斥锁
// 创建生产者和消费者线程
pthread_create(&prod1, NULL, producer, NULL);
pthread_create(&prod2, NULL, producer, NULL);
pthread_create(&cons1, NULL, consumer, NULL);
pthread_create(&cons2, NULL, consumer, NULL);
// 等待线程执行(在本例中无限循环,故不会到达终点)
pthread_join(prod1, NULL);
pthread_join(prod2, NULL);
pthread_join(cons1, NULL);
pthread_join(cons2, NULL);
// 销毁信号量和互斥锁
sem_destroy(&empty_slots);
sem_destroy(&full_slots);
pthread_mutex_destroy(&mutex);
return 0;
}
运行结果截图:
分析:因为我们先获取互斥锁,再等待同步信号量,这有可能导致死锁,没有新的输出,生产者和消费者进程全都无法继续运行下去。这是因为若有一个生产者获得了互斥锁,但同步信号量没有空位,它会阻塞在等待sem_wait(&empty_slots)上,其他线程也因为无法获取互斥锁而无法操作,出现死锁。因此正确的信号量和互斥锁的操作顺序非常重要。
国庆节快乐!O(∩_∩)O~