ucore lab7(同步互斥)
一、实验目的
1.1理解操作系统的同步互斥的设计实现;
1.2理解底层支撑技术:禁用中断、定时器、等待队列;
1.3在ucore中理解信号量(semaphore)机制的具体实现;
1.4理解管程机制,在ucore内核中增加基于管程(monitor)的条件变量(condition variable)的支持;
1.5了解经典进程同步问题,并能使用同步机制解决进程同步问题。
二、实验内容
实验六完成了用户进程的调度框架和具体的调度算法,可调度运行多个进程。如果多个进程需要协同操作或访问共享资源,则存在如何同步和有序竞争的问题。本次实验,主要是熟悉ucore的进程同步机制—信号量(semaphore)机制,以及基于信号量的哲学家就餐问题解决方案。然后掌握管程的概念和原理,并参考信号量机制,实现基于管程的条件变量机制和基于条件变量来解决哲学家就餐问题。
在本次实验中,在kern/sync/check_sync.c中提供了一个基于信号量的哲学家就餐问题解法。同时还需完成练习,即实现基于管程(主要是灵活运用条件变量和互斥信号量)的哲学家就餐问题解法。哲学家就餐问题描述如下:有五个哲学家,他们的生活方式是交替地进行思考和进餐。哲学家们公用一张圆桌,周围放有五把椅子,每人坐一把。在圆桌上有五个碗和五根筷子,当一个哲学家思考时,他不与其他人交谈,饥饿时便试图取用其左、右最靠近他的筷子,但他可能一根都拿不到。只有在他拿到两根筷子时,方能进餐,进餐完后,放下筷子又继续思考。
三、实验步骤及流程
3.0 练习0:填写已有实验
3.0.1实验要求
本实验依赖实验1/2/3/4/5/6。请把你做的实验1/2/3/4/5/6的代码填入本实验中代码中有“LAB1”/“LAB2”/“LAB3”/“LAB4”/“LAB5”/“LAB6”的注释相应部分。并确保编译通过。注意:为了能够正确执行lab7的测试应用程序,可能需对已完成的实验1/2/3/4/5/6的代码进行进一步改进。
3.0.2实验操作
经过对比观察发现,对于lab1–lab6补充的代码在本次实验中不需要进一步改进。
3.1 练习1:理解内核级信号量的实现和基于内核级信号量的哲学家就餐问题(不需要编码)
3.1.1实验要求
完成练习0后,建议大家比较一下(可用meld等文件diff比较软件)个人完成的lab6和练习0完成后的刚修改的lab7之间的区别,分析了解lab7采用信号量的执行过程。执行make grade,大部分测试用例应该通过。
请在实验报告中给出内核级信号量的设计描述,并说明其大致执行流程。
请在实验报告中给出给用户态进程/线程提供信号量机制的设计方案,并比较说明给内核级提供信号量机制的异同。
3.1.2 代码理解
(1)信号量
信号量实现可以这样描述:当多个进程可以进行互斥或同步合作时,一个进程会由于无法满足信号量设置的某条件而在某一位置停止,直到它接收到一个特定的信号(表明条件满足了)。为了发信号,需要使用一个称作信号量的特殊变量。为通过信号量s传送信号,信号量通过wait和signal操作来修改传送信号量。
(2)信号量结构体
semaphore_t是最基本的记录型信号量(record semaphore)结构,包含了用于计数的整数值value,和一个进程等待队列wait_queue,一个等待的进程会挂在此等待队列上。关于value的值,有以下三种情况:
①value> 0,表示共享资源的空闲数;
②vlaue< 0,表示该信号量的等待队列里的进程数;
③value= 0,表示等待队列为空。
typedef struct {
int value;
wait_queue_t wait_queue;//进程等待队列
} semaphore_t;
(3)check_sync函数
通过观察函数的注释,我们发现,这个check_sync函数被分为了两个部分,第一部分使用了信号量来解决哲学家就餐问题,第二部分则是使用管程的方法。因此,练习一中我们只需要关注前半段。
void check_sync(void){
int i;
//信号量
sem_init(&mutex, 1);
for(i=0;i<N;i++){
sem_init(&s[i], 0);
int pid = kernel_thread(philosopher_using_semaphore, (void *)i, 0);
if (pid <= 0) {//创建失败报错
panic("create No.%d philosopher_using_semaphore failed.\n");
}
philosopher_proc_sema[i] = find_proc(pid);
set_proc_name(philosopher_proc_sema[i], "philosopher_sema_proc");
}
//条件变量
monitor_init(&mt, N);//初始化管程
for(i=0;i<N;i++){
state_condvar[i]=THINKING;
int pid = kernel_thread(philosopher_using_condvar, (void *)i, 0);
if (pid <= 0) {
panic("create No.%d philosopher_using_condvar failed.\n");
}
philosopher_proc_condvar[i] = find_proc(pid);
set_proc_name(philosopher_proc_condvar[i], "philosopher_condvar_proc");
}
}
(4)kernel_thread函数(kern/process/proc.c)
利用kernel_thread函数创建了一个哲学家就餐问题的内核线程。这个函数需要传入三个参数:
①fn是一个函数,代表这个创建的内核线程中所需要执行的函数;
②arg是相关参数,这里传入的是哲学家编号i;
③共享内存的标记位,内核线程之间内存是共享的,因此应该设置为0。
其余地方则是设置一些寄存器的值,保留需要执行的函数开始执行的地址,以便创建了新的内核线程之后,函数能够在内核线程中找到入口地址,执行函数功能。
kernel_thread(int (*fn)(void *), void *arg, uint32_t clone_flags) {
struct trapframe tf;//中断相关
memset(&tf, 0, sizeof(struct trapframe));
tf.tf_cs = KERNEL_CS;
tf.tf_ds = tf.tf_es = tf.tf_ss = KERNEL_DS;
tf.tf_regs.reg_ebx = (uint32_t)fn;
tf.tf_regs.reg_edx = (uint32_t)arg;
tf.tf_eip = (uint32_t)kernel_thread_entry;
return do_fork(clone_flags | CLONE_VM, 0, &tf);
}
(5)philosopher_using_semaphore(kern/sync/check_sync.c)
此函数是需要创建的内核线程去执行的目标函数。从这个函数,我们看到,哲学家需要思考一段时间,然后吃一段时间的饭,这里面的“一段时间”就是通过系统调用sleep实现的,内核线程调用sleep,然后这个线程休眠指定的时间,从某种方面模拟了吃饭和思考的过程。
int philosopher_using_semaphore(void * arg)
{
int i, iter=0;
i=(int)arg;
cprintf("I am No.%d philosopher_sema\n",i);
while(iter++<TIMES)//TIMES=4
{
cprintf("Iter %d, No.%d philosopher_sema is thinking\n",iter,i); // 思考
do_sleep(SLEEP_TIME);
phi_take_forks_sema(i);
cprintf("Iter %d, No.%d philosopher_sema is eating\n",iter,i); //进餐
do_sleep(SLEEP_TIME);
phi_put_forks_sema(i);
}
cprintf("No.%d philosopher_sema quit\n",i);
return 0;
}
(6)do sleep的实现(kern/process/proc.c)
睡眠的过程中是无法被打断的,符合我们一般的认识,因为它在计时器使用的过程中通过local_intr_save关闭了中断,且利用了timer_init定时器函数,去记录指定的时间(传入的参数time),且在这个过程中,将进程的状态设置为睡眠,调用函数add_timer将绑定该进程的计时器加入计时器队列。当计时器结束之后,打开中断,恢复正常。
do_sleep(unsigned int time) {
if (time == 0) {
return 0;
}
bool intr_flag;
local_intr_save(intr_flag);//关闭中断
timer_t __timer, *timer = timer_init(&__timer, current, time);//声明一个定时器,并将其绑定到当前进程current上
current->state = PROC_SLEEPING;
current->wait_state = WT_TIMER;
add_timer(timer);
local_intr_restore(intr_flag);
schedule();
del_timer(timer);
return 0;
}
(7)up函数(kern/sync/sem.c)
up函数的作用是:首先关中断,如果信号量对应的wait queue中没有进程在等待,直接把信号量的value加一,然后开中断返回;如果有进程在等待且进程等待的原因是semophore设置的,则调用wakeup_wait函数将waitqueue中等待的第一个wait删除,且把此wait关联的进程唤醒,最后开中断返回。
static __noinline void __up(semaphore_t *sem, uint32_t wait_state) {
bool intr_flag;
local_intr_save(intr_flag);
{
wait_t *wait;
if ((wait = wait_queue_first(&(sem->wait_queue))) == NULL) {
sem->value ++;//如果没有进程等待,那么信号量加一
}
else {
assert(wait->proc->wait_state == wait_state);
wakeup_wait(&(sem->wait_queue), wait, wait_state, 1);
}
}
local_intr_restore(intr_flag);
}
(8)down函数(kern/sync/sem.c)
down函数的作用是:首先关掉中断,然后判断当前信号量的value是否大于0。如果是>0,则表明可以获得信号量,故让value减一,并打开中断返回即可;如果不是>0,则表明无法获得信号量,故需要将当前的进程加入到等待队列中,并打开中断,然后运行调度器选择另外一个进程执行。如果被V操作唤醒,则把自身关联的wait从等待队列中删除(此过程需要先关中断,完成后开中断)。
static __noinline uint32_t __down(semaphore_t *sem, uint32_t wait_state) {
bool intr_flag;
local_intr_save(intr_flag);//关闭中断
if (sem->value > 0) { //如果信号量大于0,那么说明信号量可用
sem->value --;
local_intr_restore(intr_flag);
return 0;
}
wait_t __wait, *wait = &__wait;
wait_current_set(&(sem->wait_queue), wait, wait_state);//将当前进程加入等待队列
local_intr_restore(intr_flag);
schedule();
local_intr_save(intr_flag);//被唤醒,关中断
wait_current_del(&(sem->wait_queue), wait);
local_intr_restore(intr_flag);
if (wait->wakeup_flags != wait_state) {
return wait->wakeup_flags;
}
return 0;
}
3.2 练习2:完成内核级条件变量和基于内核级条件变量的哲学家就餐问题(需要编码)
3.2.1实验要求
首先掌握管程机制,然后基于信号量实现完成条件变量实现,然后用管程机制实现哲学家就餐问题的解决方案(基于条件变量)。
执行:make grade 。如果所显示的应用程序检测都输出ok,则基本正确。如果只是某程序过不去,比如matrix.c,则可执行“make run-matrix”命令来单独调试它。大致执行结果可看附录。
请在实验报告中给出内核级条件变量的设计描述,并说明其大致执行流程。
请在实验报告中给出给用户态进程/线程提供条件变量机制的设计方案,并比较说明给内核级提供条件变量机制的异同。
请在实验报告中回答:能否不用基于信号量机制来完成条件变量?如果不能,请给出理由,如果能,请给出设计说明和具体实现。
3.2.2关键数据结构及知识点
(1)管程机制
一个管程定义了一个数据结构和能为并发进程所执行(在该数据结构上)的一组操作,这组操作能同步进程和改变管程中的数据。管程由四部分组成:
①管程内部的共享变量;
②管程内部的条件变量;
③管程内部并发执行的进程;
似对局部于管程内部的共享数据设置初始值的语句。
局限在管程中的数据结构,只能被局限在管程的操作过程所访问,任何管程之外的操作过程都不能访问它;另一方面,局限在管程中的操作过程也主要访问管程内的数据结构。由此可见,管程相当于一个隔离区,它把共享变量和对它进行操作的若干个过程围了起来,所有进程要访问临界资源时,都必须经过管程才能进入,而管程每次只允许一个进程进入管程,从而需要确保进程之间互斥。管程机制的数据结构如下:
typedef struct monitor{
semaphore_t mutex;
semaphore_t next;
int next_count;
condvar_t *cv;
} monitor_t;
(2)条件变量
条件变量,即将等待队列和睡眠条件包装在一起,就形成了一种新的同步机制,称为条件变量。一个条件变量CV可理解为一个进程的等待队列,队列中的进程正等待某个条件C变为真。每个条件变量关联着一个断言 “断言” PC。当一个进程等待一个条件变量,该进程不算作占用了该管程,因而其它进程可以进入该管程执行,改变管程的状态,通知条件变量CV其关联的断言PC在当前状态下为真。因而条件变量两种操作如下:
①wait_cv: 被一个进程调用,以等待断言Pc被满足后该进程可恢复执行. 进程挂在该条件变量上等待时,不被认为是占用了管程。如果条件不能满足,就需要等待。
②signal_cv:被一个进程调用,以指出断言Pc现在为真,从而可以唤醒等待断言Pc被满足的进程继续执行。如果条件可以满足,那么可以运行。
typedef struct condvar{
semaphore_t sem;
int count;
monitor_t * owner;
} condvar_t;
(3)philosopher_using_condvar函数
我们发现这里和用信号量还是没有本质的差别,不同之处在于,获取筷子和放下都使用了不同的,配套管程使用的函数phi_take_forks_condvar和phi_put_forks_condvar。
int philosopher_using_condvar(void * arg) { /* arg is the No. of philosopher 0~N-1*/
int i, iter=0;
i=(int)arg;
cprintf("I am No.%d philosopher_condvar\n",i);
while(iter++<TIMES)
{ /* iterate*/
cprintf("Iter %d, No.%d philosopher_condvar is thinking\n",iter,i); /* thinking*/
do_sleep(SLEEP_TIME);
phi_take_forks_condvar(i);
/* need two forks, maybe blocked */
cprintf("Iter %d, No.%d philosopher_condvar is eating\n",iter,i); /* eating*/
do_sleep(SLEEP_TIME);
phi_put_forks_condvar(i);
/* return two forks back*/
}
cprintf("No.%d philosopher_condvar quit\n",i);
return 0;
}
3.2.3代码实现
(1)phi_take_forks_condvar
此函数代码实现如下,mtp为一个管程,state_convader数组记录哲学家的状态。
void phi_take_forks_condvar(int i) {
down(&(mtp->mutex)); //进入临界区
//--------into routine in monitor--------------
// LAB7 EXERCISE1: 201808010410
// I am hungry
// try to get fork
// I am hungry
state_condvar[i]=HUNGRY;
phi_test_condvar(i);
while (state_condvar[i] != EATING) {
cprintf("phi_take_forks_condvar: %d didn't get fork and will wait\n",i);
cond_wait(&mtp->cv[i]);
}
if(mtp->next_count>0)
up(&(mtp->next));
else
up(&(mtp->mutex));
}
(2)phi_put_forks_condvar
如果当前管程的等待数量在唤醒了一个线程之后,还有进程在等待,那么就会唤醒控制当前进程的信号量,让其他进程占有它,如果没有等待的了,那么直接释放互斥锁,这样就可以允许新的进程进入管程了。
void phi_put_forks_condvar(int i) {
down(&(mtp->mutex));
//--------into routine in monitor--------------
// LAB7 EXERCISE1: 201808010410
// I ate over
// test left and right neighbors
// I ate over
state_condvar[i]=THINKING;
phi_test_condvar(LEFT);
phi_test_condvar(RIGHT);
if(mtp->next_count>0)
up(&(mtp->next));
else
up(&(mtp->mutex));//离开临界区
}
(3)cond_signal(kern/sync/monitor.c)
首先判断cvp.count,如果不大于0,则表示当前没有睡眠在这一个条件变量上的进程,因此就没有被唤醒的对象了,直接函数返回即可,什么也不需要操作。
如果大于0,这表示当前有睡眠在该条件变量上的进程,因此需要唤醒等待在cv.sem上睡眠的进程。而由于只允许一个进程在管程中执行,所以一旦进程B唤醒了别人(进程A),那么自己就需要睡眠。故让monitor.next_count加一,且让自己(进程B)睡在信号量monitor.next(宿主管程的信号量)上。如果睡醒了,这让monitor.next_count减一。
这里为什么最后要加一个next_conut–呢?这说明上一句中的down的进程睡醒了,那么睡醒,就必然是另外一个进程唤醒了它,因为只能有一个进程在管程中被signal,如果有进程调用了wait,那么必然需要signal另外一个进程。
cond_signal (condvar_t *cvp) {
//LAB7 EXERCISE1: 201808010410
cprintf("cond_signal begin: cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->next_count);
if(cvp->count>0) {
cvp->owner->next_count ++;
up(&(cvp->sem));
down(&(cvp->owner->next));
cvp->owner->next_count --;
}
cprintf("cond_signal end: cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->next_count);
}
(4)cond_wait(kern/sync/monitor.c)
如果进程A执行了cond_wait函数,表示此进程等待某个条件C不为真,需要睡眠。因此表示等待此条件的睡眠进程个数cv.count要加一。接下来会出现两种情况。
①情况一:如果monitor.next_count如果大于0,表示有大于等于1个进程执行cond_signal函数且睡着了,就睡在了monitor.next信号量上。假定这些进程形成S进程链表。因此需要唤醒S进程链表中的一个进程B。然后进程A睡在cv.sem上,如果睡醒了,则让cv.count减一,表示等待此条件的睡眠进程个数少了一个,可继续执行。
②情况二:如果monitor.next_count如果小于等于0,表示目前没有进程执行cond_signal函数且睡着了,那需要唤醒的是由于互斥条件限制而无法进入管程的进程,所以要唤醒睡在monitor.mutex上的进程。然后进程A睡在cv.sem上,如果睡醒了,则让cv.count减一,表示等待此条件的睡眠进程个数少了一个,可继续执行了。
cond_wait (condvar_t *cvp) {
//LAB7 EXERCISE1: 201808010410
cprintf("cond_wait begin: cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->next_count);
cvp->count++;//需要睡眠的进程个数加一
if(cvp->owner->next_count > 0)
up(&(cvp->owner->next));//唤醒进程链表中的下一个进程
else
up(&(cvp->owner->mutex));//唤醒睡在monitor.mutex上的进程
down(&(cvp->sem));
cvp->count --;
cprintf("cond_wait end: cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->next_count);
}
四、思考题
Q1:请在实验报告中给出内核级信号量的设计描述,并说其大致执行流流程。
对于V操作和P操作,分别用up和down函数来对应。
①down()函数:先关掉中断,然后判断当前信号量的value是否大于0。如果是>0,则表明可以获得信号量,故让value减一,并打开中断返回即可;如果不是>0,则表明无法获得信号量,故需要将当前的进程加入到等待队列中,并打开中断,然后运行调度器选择另外一个进程执行。如果被V操作唤醒,则把自身关联的wait从等待队列中删除(此过程需要先关中断,完成后开中断)。
②up()函数:首先关中断,如果信号量对应的wait queue中没有进程在等待,直接把信号量的value加一,然后开中断返回;如果有进程在等待且进程等待的原因是semophore设置的,则调用wakeup_wait函数将waitqueue中等待的第一个wait删除,且把此wait关联的进程唤醒,最后开中断返回。
Q2:请在实验报告中给出给用户态进程/线程提供信号量机制的设计方案,并比较说明给内核级提供信号量机制的异同。
①相同之处:用户态的进程/线程的信号量的数据结构和内核级的是一样的。 对于用户态的线程/进程使用信号量机制,应该首先通过系统调用进行sem的初始化,设置sem.value以及sem.wait_queue,而在初始化之后,在使用这个信号量时,通过P操作与V操作,也是通过系统调用进入到内核中进行处理,简称是否等待或者释放资源。
②不同之处: 在用户态使用信号量时,需要进行系统调用进入到内核态进行操作。
五、运行结果
①执行命令“make qemu”,得到关于哲学家就餐问题的输出:(篇幅原因,这里只展示部分结果)
②执行命令“make grade”:
六、实验心得
通过本次实验对信号量以及条件变量有了更深入的学习与理解,通过验收以及助教老师的提问对这部分内容掌握的更加牢固,就代码而言,信号量和条件变量实现哲学家就餐问题时差别不大。相信本次实验的内容与收获会对今后的学习起到很好的帮助。之后操作系统课程的实验还有最后一次,希望整个操作系统的实验能够圆满完成。