练习1 理解内核级信号量的实现和基于内核级信号量的哲学家就餐问题
在理解信号量之前,先了解等待队列、定时器、关中断。
等待队列
到目前为止,用户进程或内核线程还没有睡眠的支持机制。在课程中提到用户进程或内核线程可以转入等待状态以等待某个特定事件(比如睡眠,等待子进程结束,等待信号量等),当该事件发生时这些进程能够被再次唤醒。内核实现这一功能的一个底层支撑机制就是等待队列wait_queue
,等待队列和每一个事件(睡眠结束、时钟到达、任务完成、资源可用等)联系起来。需要等待事件的进程在转入休眠状态后插入到等待队列中。当事件发生之后,内核遍历相应等待队列,唤醒休眠的用户进程或内核线程,并设置其状态为就绪状态(PROC_RUNNABLE),并将该进程从等待队列中清除。ucore在kern/sync/{ wait.h, wait.c }
中实现了等待项wait
结构和等待队列wait queue
结构以及相关函数),这是实现ucore中的信号量机制和条件变量机制的基础,进入wait queue的进程会被设为等待状态(PROC_SLEEPING),直到他们被唤醒。
我们先看等待队列的实现:
typedef struct {
list_entry_t wait_head; // wait_queue的队头
} wait_queue_t;
struct proc_struct;
typedef struct {
struct proc_struct *proc; // 等待进程的指针
uint32_t wakeup_flags; // 进程被放入等待队列的原因标记
wait_queue_t *wait_queue; // 指向此wait结构所属于的wait_queue
list_entry_t wait_link; // 用来组织wait_queue中wait节点的连接
} wait_t;
该等待队列的一些高层操作如下:
//让wait与进程关联,且让当前进程关联的wait进入等待队列queue,当前进程睡眠
void wait_current_set(wait_queue_t *queue, wait_t *wait, uint32_t wait_state);
//把与当前进程关联的wait从等待队列queue中删除
wait_current_del(queue, wait);
//唤醒与wait关联的进程
void wakeup_wait(wait_queue_t *queue, wait_t *wait, uint32_t wakeup_flags, bool del);
//唤醒等待队列上挂着的第一个wait所关联的进程
void wakeup_first(wait_queue_t *queue, uint32_t wakeup_flags, bool del);
//唤醒等待队列上所有的等待的进程
void wakeup_queue(wait_queue_t *queue, uint32_t wakeup_flags, bool del);
定时器
定时器提供了基于时间事件的调度机制。在ucore 中,时钟(timer)中断给操作系统提供了有一定间隔的时间事件,操作系统将其作为基本的调度和计时单位(我们记两次时间中断之间的时间间隔为一个时间片,timer slice)。
基于此时间单位,操作系统得以向上提供基于时间点的事件,并实现基于时间长度的睡眠等待和唤醒机制。在每个时钟中断发生时,操作系统产生对应的时间事件。应用程序或者操作系统的其他组件可以以此来构建更复杂和高级的进程管理和调度算法。
sched.h, sched.c
定义了有关timer
的各种相关接口来使用 timer
服务,其中主要包括:
typedef struct {……} timer_t
: 定义了 timer_t
的基本结构,其可以用 sched.h
中的timer_init函数对其进行初始化。
void timer_init(timer t *timer, struct proc_struct *proc, int expires)
: 对某定时器 进行初始化,让它在 expires 时间片之后唤醒 proc 进程。
void add_timer(timer t *timer)
: 向系统添加某个初始化过的timer_t
,该定时器在 指定时间后被激活,并将对应的进程唤醒至runnable(如果当前进程处在等待状态)。
void del_timer(timer_t *time)
: 向系统删除(或者说取消)某一个定时器。该定时器在取消后不会被系统激活并唤醒进程。
void run_timer_list(void)
: 更新当前系统时间点,遍历当前所有处在系统管理内的定时器,找出所有应该激活的计数器,并激活它们。该过程在且只在每次定时器中断时被调用。在ucore 中,其还会调用调度器事件处理程序。
一个 timer_t
在系统中的存活周期可以被描述如下:
timer_t
在某个位置被创建和初始化,并通过add_timer
加入系统管理列表中- 系统时间被不断累加,直到
run_timer_list
发现该timer_t
到期。 run_timer_list
更改对应的进程状态,并从系统管理列表中移除该timer_t
。
timer_t
的结构如下:
typedef struct {
unsigned int expires; // 经过多长时间过期
struct proc_struct *proc; // 该计时器绑定的进程
list_entry_t timer_link; // 将所有计时器链接
} timer_t;
他有一个过期时长expire
,定时器的主要操作如下:
sched.h, sched.c
定义了有关timer的各种相关接口来使用 timer 服务,其中主要包括:
typedef struct {……} timer_t
: 定义了 timer_t 的基本结构,其可以用 sched.h 中的timer_init函数对其进行初始化。
void timer_init(timer t *timer, struct proc_struct *proc, int expires)
: 对某定时器 进行初始化,让它在 expires 时间片之后唤醒 proc 进程。
void add_timer(timer t *timer)
: 向系统添加某个初始化过的timer_t
,该定时器在 指定时间后被激活,并将对应的进程唤醒至runnable
(如果当前进程处在等待状态)。
void del_timer(timer_t *time)
: 向系统删除(或者说取消)某一个定时器。该定时器在取消后不会被系统激活并唤醒进程。
void run_timer_list(void)
: 更新当前系统时间点,遍历当前所有处在系统管理内的定时器,找出所有应该激活的计数器,并激活它们。该过程在且只在每次定时器中断时被调用。
timer通过时钟中断实现计时:
case IRQ_OFFSET + IRQ_TIMER:
ticks ++;
assert(current != NULL);
run_timer_list();
break;
关中断
如果没有在硬件级保证读内存-修改值-写回内存的原子性,我们只能通过复杂的软件来实现同步互斥操作。但由于有开关中断和test_and_set_bit等原子操作机器指令的存在,使得我们在实现同步互斥原语上可以大大简化。
在ucore中提供的底层机制包括中断屏蔽/使能控制等。kern/sync.c
中实现的开关中断的控制函数local_intr_save(x)
和local_intr_restore(x)
,它们是基于kern/driver
文件下的intr_enable()
、intr_disable()
函数实现的。具体调用关系为:
关中断:local_intr_save --> __intr_save --> intr_disable --> cli
开中断:local_intr_restore--> __intr_restore --> intr_enable --> sti
最终的cli
和sti
是x86的机器指令,最终实现了关(屏蔽)中断和开(使能)中断,即设置了eflags寄存器中与中断相关的位。通过关闭中断,可以防止对当前执行的控制流被其他中断事件处理所打断。既然不能中断,那也就意味着在内核运行的当前进程无法被打断或被重新调度,即实现了对临界区的互斥操作。所以在单处理器情况下,可以通过开关中断实现对临界区的互斥保护,需要互斥的临界区代码的一般写法为:
......
local_intr_save(intr_flag);
{
临界区代码
}
local_intr_restore(intr_flag);
......
由于目前ucore只实现了对单处理器的支持,所以通过这种方式,就可简单地支撑互斥操作了。在多处理器情况下,这种方法是无法实现互斥的,因为屏蔽了一个CPU的中断,只能阻止本地CPU上的进程不会被中断或调度,并不意味着其他CPU上执行的进程不能执行临界区的代码。所以,开关中断只对单处理器下的互斥操作起作用。在本实验中,开关中断机制是实现信号量等高层同步互斥原语的底层支撑基础之一。
哲学家就餐问题
哲学家就餐问题,即有五个哲学家,他们的生活方式是交替地进行思考和进餐。哲学家们公用一张圆桌,周围放有五把椅子,每人坐一把。在圆桌上有五个碗和五根筷子,当一个哲学家思考时,他不与其他人交谈,饥饿时便试图取用其左、右最靠近他的筷子,但他可能一根都拿不到。只有在他拿到两根筷子时,方能进餐,进餐完后,放下筷子又继续思考。
在分析之前先对信号量进行简介,直接看信号量的伪代码如下
struct semaphore {
int count;
queueType queue;
};
void P(semaphore S){
S.count--;
if (S.count<0) {
把进程置为睡眠态;
将进程的PCB插入到S.queue的队尾;
调度,让出CPU;
}
}
void V(semaphore S){
S.count++;
if (S.count≤0) {
唤醒在S.queue上等待的第一个进程;
}
}
基于上诉信号量实现可以认为,当多个进程可以进行互斥或同步合作时,一个进程会由于无法满足信号量设置的某条件而在某一位置停止,直到它接收到一个特定的信号(表明条件满足了)。为了发信号,需要使用一个称作信号量的特殊变量。为通过信号量s传送信号,信号量通过V、P操作来修改传送信号量。
接下来进入代码的分析。
lab7和之前的lab6的总体步骤基本没有多大的变化,开始的执行流程都与实验六相同,而二者的差异主要是从,而我们跟着代码继续往下看,一直到创建第二个内核线程init_main
时,我们可以看到,init_main
的内容有一定的修改,函数在开始执行调度之前多执行了一个check_sync
函数,check_sync
函数如下:
void check_sync(void){
int i;
//check semaphore
sem_init(&mutex, 1);
for(i=0;i<N;i++){d
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");
}
//check condition variable
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");
}
}
根据注释可以看到,该函数分为了两个部分,第一部分是实现基于信号量的哲学家问题,第二部分是实现基于管程的哲学家问题。
练习1要求分析基于信号量的哲学家问题,这里我们先只用看该函数的前半部分。
首先实现初始化了一个互斥信号量,然后创建了对应5个哲学家行为的5个信号量,并创建5个内核线程代表5个哲学家,每个内核线程完成了基于信号量的哲学家吃饭睡觉思考行为实现。现在我们继续跟进philosopher_using_semaphore
函数观察它的具体实现。
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);
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;
}
看到核心就是phi_take_forks_sema
和phi_put_forks_sema
两个函数,具体的函数注释如下:
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); /* 离开临界区 */
}
而这里到了信号量的核心部分,就是上述代码中的up
和down
函数就分别调用了__up
函数和__down
函数,而这两个函数分别对应着信号量的V,P操作。
先看__up
函数,它实现了信号量的的V操作
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 ++;//信号量的value加一
}
else {//有进程在等待
assert(wait->proc->wait_state == wait_state);
wakeup_wait(&(sem->wait_queue), wait, wait_state, 1);//将`wait_queue`中等待的第一个wait删除,并将该进程唤醒
}
}
local_intr_restore(intr_flag);//开启中断返回
}
首先通过local_intr_save
函数关闭中断,如果信号量对应的wait queue
中没有进程在等待,直接把信号量的value
加一,然后通过local_intr_restore
函数开中断返回。如果有进程在等待且进程等待的原因是semophore
设置的,则调用wakeup_wait
函数将wait_queue
中等待的第一个wait
删除,且把此wait
关联的进程唤醒,最后通过local_intr_restore
函数开中断返回。
再来看看__down
函数,它实现了信号量的P操作
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大于0
sem->value --;//直接让value减一
local_intr_restore(intr_flag);//开中断返回
return 0;
}
//当前信号量value小于等于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);//被V操作唤醒,从等待队列移除
local_intr_restore(intr_flag);//开中断
if (wait->wakeup_flags != wait_state) {
return wait->wakeup_flags;
}
return 0;
}
首先关掉中断,然后判断当前信号量的value
是否大于0。如果是大于0,则表明可以获得信号量,故让value
减一,并打开中断返回即可;如果小于0,则表明无法获得信号量,故需要将当前的进程加入到等待队列中,并打开中断,然后运行调度器选择另外一个进程执行。如果被V操作唤醒,则把自身关联的wait
从等待队列中删除(此过程需要先关中断,完成后开中断)。
练习2 完成内核级条件变量和基于内核级条件变量的哲学家就餐问题
管程的概念
管程,即定义了一个数据结构和能为并发进程所执行(在该数据结构上)的一组操作,这组操作能同步进程和改变管程中的数据。管程与Java中的一个类类似。
管程相当于一个隔离区,它把共享变量和对它进行操作的若干个过程围了起来,所有进程要访问临界资源时,都必须经过管程才能进入,而管程每次只允许一个进程进入管程,从而需要确保进程之间互斥。
管程主要由这四个部分组成
- 管程内部的共享变量;
- 管程内部的条件变量;
- 管程内部并发执行的进程;
- 对局部于管程内部的共享数据设置初始值的语句。
所谓条件变量,即将等待队列和睡眠条件包装在一起,就形成了一种新的同步机制,称为条件变量。一个条件变量CV可理解为一个进程的等待队列,队列中的进程正等待某个条件C变为真。
管程和信号量的区别:
管程实际上与信号量类似,但是信号量申请互斥、放弃互斥的代码散落在具体应用中,如果得不到资源时忘记释放互斥锁,就会造成死锁。而管程将PV操作都集中到一个模块中,在进程得不到需要的资源时,自动放弃持有的互斥锁并阻塞。
管程的实现
管程的实现主要有Hansen和Hoare两种。
Hansen:
T1进入管程阻塞后,T2进入管程,T2发出signal后他不立刻退出管程让T1执行,而是继续执行,直到T2释放了锁,才允许到T1执行。
Hoare:
和Hansen不同的是,T2发出signal后立刻放弃管程的占用,允许T1执行,当T1执行完后才让T2执行剩余代码。
Hansen的效率更高,而Hoare的思路更清晰。真实的OS里一般用Hansen的方法。
typedef struct condvar{
semaphore_t sem; // semaphore包含一个信号量数和等待队列
int count; // 等待这个条件变量的进程数
monitor_t * owner; // 拥有这个条件变量的管程
} condvar_t;
typedef struct monitor{
semaphore_t mutex; // 访问管程的互斥锁,初值为1
semaphore_t next; // 发出signal的进程应该消耗此信号量以阻塞,最后由处于wait状态的进程唤醒
int next_count; // 发出signal后正在等待的进程数
condvar_t *cv; // 在管程里的条件变量
} monitor_t;
管程主要函数为wait
和signal
:
// Unlock one of threads waiting on the condition variable.
// 解锁一个等待条件变量的线程
void
cond_signal (condvar_t *cvp) {
//LAB7 EXERCISE1: YOUR CODE
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 ++; //发出signal并睡眠的进程数加一
up(&(cvp->sem)); //唤醒等待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);
}
// 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: YOUR CODE
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++; //需要睡眠的进程数加一
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);
}
可以看出如果进程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
减一,表示等待此条件的睡眠进程个数少了一个,可继续执行了!
哲学家就餐问题
void phi_take_forks_condvar(int i) {
//取得互斥信号量
down(&(mtp->mutex));
//--------into routine in monitor--------------
// LAB7 EXERCISE1: YOUR CODE
// I am hungry
// try to get fork
// I am hungry
state_condvar[i]=HUNGRY; //将第i个哲学家状态设为hungry,需要叉子
// 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: YOUR CODE
// I ate over
// test left and right neighbors
// I ate over
state_condvar[i]=THINKING; //就餐结束
// 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));
}