UCORE实验7
实验目的
熟悉ucore中的进程同步机制,了解操作系统为进程同步提供的底层支持;
在ucore中理解信号量(semaphore)机制的具体实现;
理解管程机制,在ucore内核中增加基于管程(monitor)的条件变量(condition variable)的支持;
了解经典进程同步问题,并能使用同步机制解决进程同步问题。
实验内容
实验六完成了用户进程的调度框架和具体的调度算法,可调度运行多个进程。如果多个进程需要协同操作或访问共享资源,则存在如何同步和有序竞争的问题。本次实验,主要是熟悉ucore的进程同步机制—信号量(semaphore)机制,以及基于信号量的哲学家就餐问题解决方案。然后掌握管程的概念和原理,并参考信号量机制,实现基于管程的条件变量机制和基于条件变量来解决哲学家就餐问题。
在本次实验中,在kern/sync/check_sync.c中提供了一个基于信号量的哲学家就餐问题解法。同时还需完成练习,即实现基于管程(主要是灵活运用条件变量和互斥信号量)的哲学家就餐问题解法。哲学家就餐问题描述如下:有五个哲学家,他们的生活方式是交替地进行思考和进餐。哲学家们公用一张圆桌,周围放有五把椅子,每人坐一把。在圆桌上有五个碗和五根筷子,当一个哲学家思考时,他不与其他人交谈,饥饿时便试图取用其左、右最靠近他的筷子,但他可能一根都拿不到。只有在他拿到两根筷子时,方能进餐,进餐完后,放下筷子又继续思考。
练习0:填写已有实验
本实验依赖实验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的代码进行进一步改进。
分析
对kern/trap/trap.c中的lab6 code中的sched_class_proc_tick(current)改成run_timer_list()
case IRQ_OFFSET + IRQ_TIMER:
ticks ++;
assert(current != NULL);
run_timer_list(); //这里
break;
run_timer_list()的作用是:更新当前系统时间点,遍历当前所有处在系统管理内的计时器,找出所有应该激活的计数器,并激活它们。该过程在且只在每次计时器中断时被调用。在ucore中,其还会调用调度器事件处理程序。
在这里我们要补充计时器的概念,计时器提供了基于时间事件的调度机制。在ucore中,timer中断(irq0)给操作系统提供了有一定间隔的时间事件,操作系统将其作为基本的调度和计时单位(记两次时间中断之间的时间间隔为一个时间片,timer splice)。
timer_t结构用于存储一个定时器所需要的相关数据,包括倒计时时间以及所绑定的进程。
typedef struct {
unsigned int expires; //the expire time
struct proc_struct *proc; //the proc wait in this timer. If the expire time is end, then this proc will be scheduled
list_entry_t timer_link; //the timer list
} timer_t;
一个 timer_t 在系统中的存活周期可以被描述如下:
①timer_t 在某个位置被创建和初始化,并通过add_timer加入系统管理列表中。
②系统时间被不断累加,直到 run_timer_list 发现该timer_t到期。
③run_timer_list更改对应的进程状态,并从系统管理列表中移除该timer_t。
练习1: 理解内核级信号量的实现和基于内核级信号量的哲学家就餐问题(不需要编码)
完成练习0后,建议大家比较一下(可用kdiff3等文件比较软件)个人完成的lab6和练习0完成后的刚修改的lab7之间的区别,分析了解lab7采用信号量的执行过程。执行make grade,大部分测试用例应该通过。
请在实验报告中给出内核级信号量的设计描述,并说其大致执行流流程。
请在实验报告中给出给用户态进程/线程提供信号量机制的设计方案,并比较说明给内核级提供信号量机制的异同。
分析
理解内核级信号量的实现和基于内核级信号量的哲学家就餐问题
(1)理解内核级信号量的实现
信号量是一种同步互斥机制的实现,普遍存在于现在的各种操作系统内核里。ucore中信号量的数据结构如下:
typedef struct {
int value; //信号量的当前值
wait_queue_t wait_queue; //信号量对应的等待队列
} semaphore_t;
semaphore_t是最基本的记录型信号量(record semaphore)结构,包含了用于计数的整数值value,和一个进程等待队列wait_queue,一个等待的进程会挂在此等待队列上。
在ucore中最重要的信号量操作是P操作函数down(semaphore_t *sem)和V操作函数up(semaphore_t *sem)。但这两个函数的具体实现是__down(semaphore_t *sem, uint32_t wait_state) 函数和__up(semaphore_t *sem, uint32_t wait_state)函数。
进入临界区时,ucore会执行down函数。然后其具体实现函数__down函数的功能为:具体实现信号量的P操作,首先关掉中断,然后判断当前信号量的value是否大于0。如果是>0,则表明可以获得信号量,故让value减一,并打开中断返回即可;如果不是>0,则表明无法获得信号量,故需要将当前的进程加入到等待队列中,并打开中断,然后运行调度器选择另外一个进程执行。如果被V操作唤醒,则把自身关联的wait从等待队列中删除(此过程需要先关中断,完成后开中断)。
即__down会递减当前信号量的value值。如果value在递减前为0,则将其加入至等待队列wait_queue中,并使当前线程立即放弃CPU资源,调度至其他线程。
static __noinline uint32_t __down(semaphore_t *sem, uint32_t wait_state) {
bool intr_flag;
local_intr_save(intr_flag);
if (sem->value > 0) {
// value值递减
sem->value --;
local_intr_restore(intr_flag);
return 0;
}
// 如果在上一步中,值已经为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;
}
退出临界区时,ucore会执行up函数。然后其具体实现函数__up函数的功能为:具体实现信号量的V操作,首先关中断,如果信号量对应的wait queue中没有进程在等待,直接把信号量的value加一,然后开中断返回;如果有进程在等待且进程等待的原因是semophore设置的,则调用wakeup_wait函数将waitqueue中等待的第一个wait删除,且把此wait关联的进程唤醒,最后开中断返回。
即如果没有等待线程则value++,否则唤醒第一条等待线程。(唤醒第一条等待线程value不+1)
static __noinline void __up(semaphore_t *sem, uint32_t wait_state) {
bool intr_flag;
local_intr_save(intr_flag);
{
wait_t *wait;
// 如果当前等待队列中没有线程等待,则value照常+1
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);
}
不难看出,信号量的计数器value具有有如下性质:
value>0,表示共享资源的空闲数
vlaue<0,表示该信号量的等待队列里的进程数
value=0,表示等待队列为空
(2)基于内核级信号量的哲学家就餐问题
哲学家就餐问题:有五个哲学家,他们的生活方式是交替地进行思考和进餐。哲学家们公用一张圆桌,周围放有五把椅子,每人坐一把。在圆桌上有五个碗和五根筷子,当一个哲学家思考时,他不与其他人交谈,饥饿时便试图取用其左、右最靠近他的筷子,但他可能一根都拿不到。只有在他拿到两根筷子时,方能进餐,进餐完后,放下筷子又继续思考。
我们先来看哲学家就餐的主要代码,即思考,拿起叉子,进餐,放下叉子:
int state_sema[N]; /* 记录每个人状态的数组 */
/* 信号量是一个特殊的整型变量 */
semaphore_t mutex; /* 临界区互斥 */
semaphore_t s[N]; /* 每个哲学家一个信号量 */
struct proc_struct *philosopher_proc_sema[N];
int philosopher_using_semaphore(void * arg) /* i:哲学家号码,从0到N-1 */
{
int i, iter=0;
i=(int)arg;
cprintf("I am No.%d philosopher_sema\n",i);
while(iter++<TIMES)
{ /* 无限循环 */
cprintf("Iter %d, No.%d philosopher_sema is thinking\n",iter,i); /* 哲学家正在思考 */
do_sleep(SLEEP_TIME); //在SLEEP_TIME这段时间内,当前进程放弃CPU资源
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;
}
拿起 / 放下叉子时,由于需要修改当前哲学家的状态,同时该状态是全局共享变量,所以需要上锁来防止条件竞争。将叉子放回桌上时,如果当前哲学家左右两边的两位哲学家处于饥饿状态,即准备进餐但没有刀叉时,如果条件符合(有两个空闲叉子),则唤醒这两位哲学家并让其继续进餐。
void phi_take_forks_sema(int i) /* i:哲学家号码从0到N-1 */
{
down(&mutex); /* 进入临界区 */
state_sema[i]=HUNGRY; /* 记录下哲学家i饥饿的事实 */
phi_test_sema(i); /* 试图得到两只叉子 */
up(&mutex); /* 离开临界区 */
down(&s[i]); /* 如果得不到叉子就阻塞 */
}
void phi_put_forks_sema(int i) /* i:哲学家号码从0到N-1 */
{
down(&mutex); /* 进入临界区 */
state_sema[i]=THINKING; /* 哲学家进餐结束 */
phi_test_sema(LEFT); /* 看一下左邻居现在是否能进餐 */
phi_test_sema(RIGHT); /* 看一下右邻居现在是否能进餐 */
up(&mutex); /* 离开临界区 */
}
phi_test_sema函数用于设置哲学家的进食状态。如果当前哲学家满足进食条件,则更新哲学家状态,执行哲学家锁所对应的V操作,以唤醒等待叉子的哲学家所对应的线程。
void phi_test_sema(i) /* i:哲学家号码从0到N-1 */
{
if(state_sema[i]==HUNGRY&&state_sema[LEFT]!=EATING
&&state_sema[RIGHT]!=EATING)
{
state_sema[i]=EATING;
up(&s[i]);
}
}
给出给用户态进程/线程提供信号量机制的设计方案,并比较说明给内核级提供信号量机制的异同
(1)用户态进程/线程提供信号量机制的设计方案
想要用户态进程/线程来使用信号量,则ucore要提供相应的系统调用,使其能调用内核的信号量机制相关的函数,至少要有下面几种调用函数:
int sem_init(sem_t *sem, int pshared, unsigned int ); //信号量初始化
int sem_destroy(sem_t *sem); //销毁信号量
int sem_wait(sem_t *sem); //申请信号量,即P操作
int sem_post(sem_t *sem); //释放信号量,即V操作
(2)用户级和内核级提供信号量机制的异同
相同点:本质都是通过__down和__up函数完成信号量的P操作和V操作。
不同点:内核级提供信号量机制是直接调用内核里的相关函数,而用户级需要借助系统调用来实现。并且内核级的信号量存储于内核栈中,而用户级的信号量存储于用户栈中。
练习2: 完成内核级条件变量和基于内核级条件变量的哲学家就餐问题(需要编码)
首先掌握管程机制,然后基于信号量实现完成条件变量实现,然后用管程机制实现哲学家就餐问题的解决方案(基于条件变量)。
执行:make grade 。如果所显示的应用程序检测都输出ok,则基本正确。如果只是某程序过不去,比如matrix.c,则可执行 make run-matrix 命令来单独调试它。大致执行结果可看附录。(使用的是qemu-1.0.1)。
请在实验报告中给出内核级条件变量的设计描述,并说其大致执行流流程。
请在实验报告中给出给用户态进程/线程提供条件变量机制的设计方案,并比较说明给内核级提供条件变量机制的异同。
分析
1.基于信号量实现完成内核级条件变量实现
(1)介绍管程
一个管程定义了一个数据结构和能为并发进程所执行(在该数据结构上)的一组操作,这组操作能同步进程和改变管程中的数据。管程由以下四部分组成:
①管程内部的共享变量;
②管程内部的条件变量;
③管程内部并发执行的进程;
④对局部于管程内部的共享数据设置初始值的语句。
即一个管程由一个锁和多个条件变量组成。由此可见,管程相当于一个隔离区,它把共享变量和对它进行操作的若干个过程围了起来,所有进程要访问临界资源时,都必须经过管程才能进入,而管程每次只允许一个进程进入管程,从而需要确保进程之间互斥。
管程的结构体代码:
typedef struct monitor monitor_t;
typedef struct monitor{
semaphore_t mutex; // the mutex lock for going into the routines in monitor, should be initialized to 1;保证对管程的访问是互斥的锁,初始为1
semaphore_t next; // the next semaphore is used to down the signaling proc itself, and the other OR wakeuped waiting proc should wake up the sleeped signaling proc;进程同步需要的信号量
int next_count; // the number of of sleeped signaling proc;当前睡眠的进程数
condvar_t *cv; // the condvars in monitor;管程中的条件变量,提供条件等待
} monitor_t;
管程中的成员变量mutex是一个二值信号量,是实现每次只允许一个进程进入管程的关键元素,确保了互斥访问性质。管程中的条件变量cv通过执行wait_cv,会使得等待某个条件C为真的进程能够离开管程并睡眠,且让其他进程进入管程继续执行;而进入管程的某进程设置条件C为真并执行signal_cv时,能够让等待某个条件C为真的睡眠进程被唤醒,从而继续进入管程中执行。管程中的成员变量信号量next和整形变量next_count是配合进程对条件变量cv的操作而设置的,这是由于发出signal_cv的进程A会唤醒睡眠进程B,进程B执行会导致进程A睡眠,直到进程B离开管程,进程A才能继续执行,这个同步过程是通过信号量next完成的;而next_count表示了由于发出singal_cv而睡眠的进程个数。
初始化管程时,函数monitor_init会初始化传入管程的相关成员变量,并为该管程设置多个条件变量并初始化。
// Initialize monitor.
void
monitor_init (monitor_t * mtp, size_t num_cv) {
int i;
assert(num_cv>0);
mtp->next_count = 0;
mtp->cv = NULL;
sem_init(&(mtp->mutex), 1); //unlocked
sem_init(&(mtp->next), 0);
// 分配当前管程内的条件变量
mtp->cv =(condvar_t *) kmalloc(sizeof(condvar_t)*num_cv);
assert(mtp->cv!=NULL);
// 初始化管程内条件变量的各个属性
for(i=0; i<num_cv; i++){
mtp->cv[i].count=0;
sem_init(&(mtp->cv[i].sem),0);
mtp->cv[i].owner=mtp;
}
}
为了让整个管程正常运行,还需在管程中的每个函数的入口和出口增加相关操作,即:
function (…)
{
sem.wait(monitor.mutex);
the real body of function;
if(monitor.next_count > 0)
sem_signal(monitor.next);
else
sem_signal(monitor.mutex);
}
这样带来的作用有两个:
①只有一个进程在执行管程中的函数。
②避免由于执行了cond_signal函数而睡眠的进程无法被唤醒。
对于第二点,如果进程A由于执行了cond_signal函数而睡眠(这会让monitor.next_count大于0,且执行sem_wait(monitor.next)),则其他进程在执行管程中的函数的出口,会判断monitor.next_count是否大于0,如果大于0,则执行sem_signal(monitor.next),从而执行了cond_signal函数而睡眠的进程被唤醒。上诉措施将使得管程正常执行。
(2)实现条件变量
条件变量的结构体代码:
typedef struct condvar{
semaphore_t sem; // the sem semaphore is used to down the waiting proc, and the signaling proc should up the waiting proc;实现条件变量的信号量
int count; // the number of waiters on condvar;等待当前条件变量的进程数目
monitor_t * owner; // the owner(monitor) of this condvar;当前条件变量的父管程
} condvar_t;
条件变量的定义中也包含了一系列的成员变量,信号量sem用于让发出wait_cv操作的等待某个条件C为真的进程睡眠,而让发出signal_cv操作的进程通过这个sem来唤醒睡眠的进程。count表示等在这个条件变量上的睡眠进程的个数。owner表示此条件变量的宿主是哪个管程。
ucore设计实现了条件变量wait_cv操作和signal_cv操作对应的具体函数,即cond_wait函数和cond_signal函数。
cond_signal负责唤醒进程。调用up唤醒一个在条件变量上等待的进程,自己则调用down进行睡眠,等待唤醒。
// Unlock one of threads waiting on the condition variable.
void
cond_signal (condvar_t *cvp) {
//LAB7 EXERCISE1: 202008010404
cprintf("cond_signal begin: cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->next_count);
/*
* cond_signal(cv) {
* if(cv.count>0) {
* mt.next_count ++;
* signal(cv.sem);
* wait(mt.next);
* mt.next_count--;
* }
* }
*/
if(cvp->count>0) { //判断是否有进程在等待
cvp->owner->next_count ++; //自己睡在信号量monitor.next上,睡眠进程+1
up(&(cvp->sem)); //唤醒一个在条件变量上等待的进程
down(&(cvp->owner->next)); //由于管程只能允许一个进程执行,本进程进入睡眠
cvp->owner->next_count --; //如果自己睡醒了,睡眠进程-1
}
cprintf("cond_signal end: cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->next_count);
}
等待条件变量的操作通过调用cond_wait完成。在cond_wait中,首先要查看next上是否有进程在睡眠,这些进程因发出cond_signal而睡眠,当前进程需要唤醒其中一个进程。如果没有,则唤醒一个因为互斥条件mutex无法进入管程的进程。然后自己等待在条件变量上,直到条件满足,被cond_signal唤醒。
// Suspend calling thread on a condition variable waiting for condition Atomically unlocks
// mutex and suspends calling thread on conditional variable after waking up locks mutex. Notice: mp is mutex semaphore for monitor's procedures
void
cond_wait (condvar_t *cvp) {
//LAB7 EXERCISE1: 202008010404
cprintf("cond_wait begin: cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->next_count);
/*
* cv.count ++;
* if(mt.next_count>0)
* signal(mt.next)
* else
* signal(mt.mutex);
* wait(cv.sem);
* cv.count --;
*/
cvp->count++; //等待在条件变量上的进程数+1
if(cvp->owner->next_count > 0) //如果有则唤醒next上睡眠的进程,锁传递
up(&(cvp->owner->next));
else
up(&(cvp->owner->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);
}
2.用管程机制实现哲学家就餐问题的解决方案(基于条件变量)
同样的,这里需要实现两个函数,一个是phi_take_forks_condvar(拿起叉子),一个是phi_put_forks_condvar(放下叉子)。
首先,哲学家需要尝试获取刀叉,如果刀叉没有获取到,则等待刀叉:
void phi_take_forks_condvar(int i) {
down(&(mtp->mutex));
//--------into routine in monitor--------------
// LAB7 EXERCISE1: 202008010404
// I am hungry
state_condvar[i]=HUNGRY; //第i个哲学家处于饥饿状态
// try to get fork
phi_test_condvar(i); //查看是否满足条件,拿起叉子
if (state_condvar[i] != EATING) {
cprintf("phi_take_forks_condvar: %d didn't get fork and will wait\n",i);
cond_wait(&mtp->cv[i]);
}
//--------leave routine in monitor--------------
if(mtp->next_count>0)
up(&(mtp->next));
else
up(&(mtp->mutex));
}
当吃完后,哲学家放下叉子,如果此时左右两边的哲学家都满足条件可以进餐,则设置对应的条件变量:
void phi_put_forks_condvar(int i) {
down(&(mtp->mutex));
//--------into routine in monitor--------------
// LAB7 EXERCISE1: 202008010404
// I ate over
state_condvar[i]=THINKING; //第i个哲学家吃完进入思考状态
// test left and right neighbors
phi_test_condvar(LEFT); //看一下左邻居现在是否能进餐
phi_test_condvar(RIGHT); //看一下右邻居现在是否能进餐
//--------leave routine in monitor--------------
if(mtp->next_count>0)
up(&(mtp->next));
else
up(&(mtp->mutex));
}
3.给出给用户态进程/线程提供条件变量机制的设计方案,并比较说明给内核级提供条件变量机制的异同
由于条件变量本质上也是由信号量组成的,因此其设计方案和异同也和用户级提供信号量机制,内核级提供信号量机制也基本相似,此处不再赘述。
实验心得
本实验的重点是互斥和同步。互斥是指某一资源同时只允许一个进程对其进行访问,具有唯一性和排它性,但互斥不用限制进程对资源的访问顺序,即访问可以是无序的。同步是指在进程间的执行必须严格按照规定的某种先后次序来运行,即访问是有序的,这种先后次序取决于要系统完成的任务需求。在进程写资源情况下,进程间要求满足互斥条件。在进程读资源情况下,可允许多个进程同时访问资源。本实验提供了多种同步互斥手段,包括中断控制、等待队列、信号量、管程机制(包含条件变量设计)等,通过这些手段,使得进程间能更好地利用资源,从而让整个系统的运行更加高效。