XMU《UNIX 系统程序设计》第五次实验报告(编制模拟“五个哲学家”问题的程序)
想知道第三、四次实验去哪儿了吗?我也想知道。
实验五 编制模拟“五个哲学家”问题的程序
一、实验内容描述
编制模拟“五个哲学家”问题的程序
目的
学习和掌握并发进程同步的概念和方法。
要求
-
程序语法
philosopher [ -t <time> ]
<time>
是哲学家进餐和沉思的持续时间值,缺省值为2秒。 -
五个哲学家的编号为0~4,分别用五个进程独立模拟。
-
程序的输出要简洁,仅输出每个哲学家进餐和沉思的信息。例如,当编号为3的哲学家在进餐时,就打印:
philosopher 3 is eating
而当他在沉思时,则打印:
philosopher 3 is thinking
除此之外不要输出其他任何信息。
4. 利用课堂已教授的知识而不使用线程或IPC机制进行同步。
5. 程序应该一直运行,直到人为地终止它(按 Ctrl-C
或 Ctrl-\
)。不允许出现僵尸进程。
6. 12月15日24点为实验完成deadline。
二、设计与实现
哲学家问题是这样的,假设有五位哲学家围坐在一张圆形餐桌旁,做以下两件事情之一:吃饭,或者思考。吃东西的时候,他们就停止思考,思考的时候也停止吃东西。餐桌上有五碗意大利面,每位哲学家之间各有一只餐叉。因为用一只餐叉很难吃到意大利面,所以假设哲学家必须用两只餐叉吃东西。他们只能使用自己左右手边的那两只餐叉。
这个实验的目标就是使用并发进程同步的方法,模拟他们就餐的顺序。
程序的框架设计很明确,实验文档中也已经给出来了相关的代码:在主程序中,我们调用 fork
建立五个子进程,每个子进程代表一个哲学家。
在每个子进程中,我们使用一个死循环,重复进行思考、拿起叉子、就餐、放下叉子……其中,思考、就餐两个步骤我们只需要输出一条 philosopher i is eating/thinking
,然后进行 sleep
即可。
放下叉子这个时间很容易,没有什么争议,只需要依次方向两只手上的叉子即可,对应在程序中就是解除两个锁。
对于拿起叉子这个事件,实验文档中也给出了设计:先拿起一只手边的叉子,再拿起另一只手的叉子。值得注意的是,对于 \(0\) 到 \(3\) 号哲学家,他们拿起叉子的顺序是先拿起左手边的叉子(forks[i]
),再拿起右手边的叉子(forks[i + 1]
);而对于 \(4\) 号哲学家,他拿起叉子的顺序是先拿起右手边的叉子(fork[0]
),再拿起左手边的叉子(fork[4]
)。
这样做可以避免出现死锁。考虑到,只有两种情况会出现死锁:在某一时刻,每一个科学家都拿起了左手边的叉子,而没有拿起右手边的叉子;都拿起了右手边的叉子,没有拿起左手边的叉子。这时每一个哲学家都将会去等待另一只手边叉子被解锁,而又不愿意放下自己手中的叉子,导致所有科学家都在不停地等待,从而产生死锁。
而上面的设计保证了锁之间的偏序,加锁的顺序不会产生环路,因此不可能存在所有科学家都一只手有叉子,一只手没有叉子的情况。
现在的问题来到怎么实现锁。
因为这个实验只能使用文件作为锁,不能使用线程和 IPC,所以我们考虑到可以以文件被创建和被删除作为锁的状态。
我们规定一个文件 forki
存在,代表叉子 \(i\) 被加了锁;如果不存在,则代表这个叉子没有被加锁。
然后,我们只需要在需要取用锁的时候,轮询判断 forki
是否存在即可。如果存在,我们就继续循环,如果不存在我们就创建文件,然后跳出自旋轮询执行后面的代码。
在我期初的设计中,我使用的是 open(fork, O_RDONLY)
来判断文件是否存在,使用 creat()
系统调用来创建文件。这导致了判断和创建的分离,从而引起了一个竞争问题:如果一个进程 \(i\) 在判断 forki
不存在以后被调度成了进程 \(i + 1\),这个时候进程 \(i + 1\) 也判断 forki
不存在,于是它创建了 forki
文件。如果进程 \(i\) 再次被调度,那么导致其继续执行,于是它重复创建了文件 forki
,这就意味着两个进程同时获得了 forki
的锁,从而引起错误。
于是,我发现可以使用 open(fork, O_CREAT | O_EXCL)
来创建文件,O_EXCL
参数使得文件判断和创建合并成了一个原子操作,如果文件已存在则返回错误,如果不存在则自动创建,故而避免了上述问题。
void lock(char* fork) {
int fd;
while (1) {
if ((fd = open(fork, O_CREAT | O_EXCL, 0)) >= 0) {
close(fd);
break;
}
}
}
void unlock(char* fork) {
if (access(fork, F_OK) == 0)
unlink(fork);
}
完整的代码:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <sys/wait.h>
#define N 5
static char* forks[N] = { "fork0", "fork1", "fork2", "fork3", "fork4" };
static int nsecs;
void philosopher(int i);
void takeFork(int i);
void putFork(int i);
void thinking(int i, int nsecs);
void eating(int i, int nsecs);
void lock(char* fork);
void unlock(char* fork);
int main(int argc, char* argv[]) {
if (argc == 1)
nsecs = 2;
else if (argc == 3 && strcmp(argv[1], "-t") == 0)
nsecs = atoi(argv[2]);
else {
fprintf(stderr, "Usage: %s [-t <time>]\n", argv[0]);
exit(1);
}
for (int i = 0; i < N; ++i)
unlock(forks[i]);
for (int i = 0; i < N; ++i) {
int pid = fork();
if (pid == 0) {
philosopher(i);
exit(0);
}
}
wait(NULL);
return 0;
}
void lock(char* fork) {
int fd;
while (1) {
if ((fd = open(fork, O_CREAT | O_EXCL, 0)) >= 0) {
close(fd);
break;
}
}
}
void unlock(char* fork) {
if (access(fork, F_OK) == 0)
unlink(fork);
}
void philosopher(int i) {
while (1) {
thinking(i, nsecs);
takeFork(i);
eating(i, nsecs);
putFork(i);
}
}
void takeFork(int i) {
if (i == N - 1) {
lock(forks[0]);
lock(forks[i]);
} else {
lock(forks[i]);
lock(forks[i + 1]);
}
}
void putFork(int i) {
if (i == N - 1) {
unlock(forks[0]);
unlock(forks[i]);
} else {
unlock(forks[i]);
unlock(forks[i + 1]);
}
}
void thinking(int i, int nsecs) {
printf("philosopher %d is thinking\n", i);
sleep(nsecs);
}
void eating(int i, int nsecs) {
printf("philosopher %d is eating\n", i);
sleep(nsecs);
}
三、实验结果
1. 编译程序
gcc philosopher.c -o philosopher -std=c99
2. 运行截图
四、实验体会
这次的实验让我深入理解了并发进程同步的概念和方法,通过模拟“五个哲学家”问题,我更加熟悉了在进程间进行同步操作的重要性。在实验过程中,我遇到了一些挑战,但通过仔细思考和调试,最终成功完成了任务。
这次实验让我对哲学家问题有了更深入的理解。通过构建一个围坐在圆形餐桌旁的场景,每个哲学家都有思考和进餐两个行为,而叉子则成为同步的关键。这种抽象的问题在计算机科学中很有代表性,通过实际编码实现,我更好地理解了理论概念在实践中的应用。
在实验中,锁的设计成为了一个关键点。由于不能使用线程或 IPC 机制,我采用了文件作为锁的形式。通过 open(fork, O_CREAT | O_EXCL)
的方式,我确保了判断和创建文件的原子性,有效避免了竞争问题。这让我认识到在并发编程中,同步机制的设计和实现需要仔细考虑,以防止出现不确定的行为。
另外,通过实现整个过程,我对操作系统中的进程控制有了更深刻的认识。使用 fork
系统调用创建五个独立的子进程,每个代表一个哲学家,这种并发的设计增加了程序的复杂性,但也提高了系统的效率。
实验的难点主要在于正确处理锁的逻辑,以及保证程序不出现僵尸进程。通过不断地思考和调试,我逐渐解决了这些问题,确保了程序的正确性和稳定性。实验文档中对于程序输出的简洁要求也锻炼了我的编程规范意识,使得输出信息更加清晰明了。
总的来说,通过这次实验,我不仅学到了并发进程同步的具体实现方式,还提高了对操作系统中进程控制的理解。同时,通过遇到问题并逐一解决,我培养了解决实际编程难题的能力。这次实验是我在学术和实践中的一次全面提升,让我更加深入地了解了操作系统的底层原理和并发编程的精髓。