操作系统实验ucore_lab7

这篇博客详细介绍了内核级信号量的实现和哲学家就餐问题的解决方案。首先,分析了信号量的结构、down和up函数的工作原理,然后展示了如何在用户态实现信号量接口。接着,讨论了条件变量的管程实现,包括cond_signal和cond_wait函数,以及在哲学家就餐问题中的应用。最后,提到了RCU(Read-Copy-Update)机制,强调了其在读写操作中的免锁特性及其在内核中的应用。

lab7

练习0:

填写已有实验

发现需要更改的文件为:

proc.c

default_pmm.c

pmm.c

swap_fifo.c

vmm.c

trap.c

sched.c

练习1:

理解内核级信号量的实现和基于内核级信号量的哲学家就餐问题(不需要编码)。请在实验报告中给出内核级信号量的设计描述,并说其大致执行流流程。

理解哲学家就餐问题:

​ 每个哲学家拿起叉子,进食,然后放下叉子。

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);
        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]);
    }
}

请给出内核级信号量的设计描述,并说明其大致执行流程:

内核中的信号量结构体如下,与操作系统理论课所实现的相差不大

COPYtypedef struct {
    int value;
    wait_queue_t wait_queue;
} semaphore_t;
  • 进入临界区时,uCore会执行down函数

    COPYdown(&mutex); /* 进入临界区 */
    

    与之相对的,退出临界区时会执行up函数

    COPYup(&mutex); /* 离开临界区 */
    

    down函数和up函数分别是_down_up的wrapper。它们除了传入信号量以外,还会传入一个等待状态wait_state

  • _down函数会递减当前信号量的value值。如果value在递减前为0,则将其加入至等待队列wait_queue中,并使当前线程立即放弃CPU资源,调度至其他线程。注意其中的原子操作。该函数的源码如下:

    COPYstatic __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;
    }
    
  • _up函数实现的功能稍微简单一点:如果没有等待线程则value++,否则唤醒第一条等待线程。

    注意:_up函数如果选择唤醒第一条等待线程的话,则value不加一

    COPYstatic __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);
    }
    

请给出给用户态进程/线程提供信号量机制的设计方案,并比较说明给内核级提供信号量机制的异同

  • 内核为用户态进程/线程提供信号量机制时,需要设计多个应用程序接口,而用户态线程只能通过这些内核提供的接口来使用内核服务。借鉴于Linux提供的标准接口,内核提供的这些接口可分别为:

    COPY/*Initialize semaphore object SEM to VALUE.  If PSHARED then share it
       with other processes.  */
    int sem_init (sem_t *__sem, int __pshared, unsigned int __value);
    /* Free resources associated with semaphore object SEM.  */
    // 将信号量所使用的资源全部释放
    int sem_destroy (sem_t *__sem);
    
    /* Open a named semaphore NAME with open flags OFLAG.  */
    // 开启一个新信号量,并使用给定的flag来指定其标志
    sem_t *sem_open (const char *__name, int __oflag, ...);
    
    /* Close descriptor for named semaphore SEM.  */
    // 将当前信号量所使用的描述符关闭
    int sem_close (sem_t *__sem);
    
    /* Remove named semaphore NAME.  */
    int sem_unlink (const char *__name);
    
    /* Wait for SEM being posted.
    
       This function is a cancellation point and therefore not marked with
       __THROW.  */
    // 一个P操作,如果sem value > 0,则sem value--;否则阻塞直到sem value > 0
    int sem_wait (sem_t *__sem);
    
    /* Test whether SEM is posted.  */
    int sem_trywait (sem_t *__sem);
    
    /* Post SEM.  */
    // 一个V操作,把指定的信号量 sem 的值加 1,唤醒正在等待该信号量的任意线程。
    int sem_post (sem_t *__sem);
    
    /* Get current value of SEM and store it in *SVAL.  */
    // 获取当前信号量的值
    int sem_getvalue (sem_t *__restrict __sem, int *__restrict __sval);
    
  • 相同点

    • 其核心的实现逻辑是一样的
  • 不同点

    • 内核态的信号量机制可以直接调用内核的服务,而用户态的则需要通过内核提供的接口来访问内核态服务,这其中涉及到了用户态转内核态的相关机制。
    • 内核态的信号量存储于内核栈中;但用户态的信号量存储于用户栈中。

练习2:

完成内核级条件变量和基于内核级条件变量的哲学家就餐问题(需要编码)。首先掌握管程机制,然后基于信号量实现完成条件变量实现,然后用管程机制实现哲学家就餐问题的解决方案(基于条件变量)。

执行:make grade 。如果所显示的应用程序检测都输出ok,则基本正确。如果只是某程序过不去,比如matrix.c,则可执行 make run-matrix 命令来单独调试它。大致执行结果可看附录。(使用的是qemu-1.0.1)。

实现思路:

Hansan为管程所下的定义:“一个管程定义了一个数据结构和能为并发进程所执行(在该数据结构上)的一组操作,这组操作能同步进程和改变管程中的数据”。
因此管程由四部分组成:

管程内部的共享变量;
管程内部的条件变量;
管程内部并发执行的进程;
对局部于管程内部的共享数据设置初始值的语句。

局限在管程中的数据结构,只能被局限在管程的操作过程所访问,任何管程之外的操作过程都不能访问它;另一方面,局限在管程中的操作过程也主要访问管程内的数据结构。由此可见,管程相当于一个隔离区,它把共享变量和对它进行操作的若干个过程围了起来,所有进程要访问临界资源时,都必须经过管程才能进入,而管程每次只允许一个进程进入管程,从而需要确保进程之间互斥。

实现管程所构建的数据结构 monitor 以及 condvar

// 管程的数据结构
typedef struct monitor{
    semaphore_t mutex; // 二值信号量 用来互斥访问管程
    semaphore_t next; // 用于条件同步 用于发出signal操作的进程等条件为真之前进入睡眠
    int next_count; // 记录睡在 signal 操作的进程数
    condvar_t *cv; // 条件变量
} monitor_t;

// 条件变量数据结构
typedef struct condvar{
    semaphore_t sem; // 用于条件同步 用于发出wait操作的进程等待条件为真之前进入睡眠
    int count; // 记录睡在 wait 操作的进程数(等待条件变量成真)
    monitor_t * owner; // 所属管程
} condvar_t;

条件变量机制的实现主要是cond_signal, cond_wait两个函数中,分别表示提醒等待在这个条件变量上的进程恢复执行,以及等待在这个条件变量上,直到有其他进行将其唤醒为止

​ cond_signal: 将指定条件变量上等待队列中的一个线程进行唤醒,并且将控制权转交给这个进程。该操作实现了对共享变量访问的互斥性;执行cond_signal函数时,首先当前进程测试cv.count,如果不大于0,则表示当前没有执行cond_wait而进入休眠状态的进程,函数直接返回。如果cv.count大于0,这表示当前有执行cond_wait而进入休眠状态的进程,因此需要唤醒在cv.sem上等待出入休眠状态的进程。由于只允许一个进程在管程中执行,所以一旦当前进程唤醒了其他进程,自身就要进入休眠状态,即增加monitor.next_count,让当前进程在信号量monitor.next上进入休眠状态,直到被唤醒时,减少monitor.next_count。

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);  
//判断当前的条件变量的等待队列上是否有正在等待的进程,如果没有则不需要进行任何操作;
//如果有正在等待的进程
     if(cvp->count>0)
     {
        //将其中的一个唤醒
        up(&(cvp->sem));
        //所属管程的next计数加1,表示当前进程会被等待者堵塞
        cvp->owner->next_count ++;
        //阻塞,等待条件同步
        down(&(cvp->owner->next));
        //当前进程被唤醒,恢复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);

cond_wait:该函数的功能为将当前进程等待在指定信号量上,其操作过程为将等待队列的计数加1,然后释放管程的锁或者唤醒一个next上的进程来释放锁,然后把自己等在条件变量的等待队列上,直到有signal信号将其唤醒,正常退出函数;若某个进程执行了cond_wait函数,表明该进程需因要的某个条件变量不满足需求而进入休眠状态。等待这个条件变量的休眠进程数cv.count增加。此时如果monitor.next_count大于0,表示至少有1个进程执行cond_signal函数后进入休眠状态,等待monitor.next信号量时,则需要唤醒等待该条件量的另一个进程,然后该进程在cv.sem上休眠。当进程A被唤醒时,减少cv.count,表示等待此条件变量的睡眠进程个数减少了一个,可继续执行。如果monitor.next_count小于等于0,表明目前没有进程执行cond_signal函数进入休眠,则需要唤醒的是由于互斥条件限制而无法进入管程的进程,即唤醒在monitor.mutex上休眠的进程。然后当前进程在cv.sem上休眠,直到被唤醒时,减少cv.count,表示等待此条件的睡眠进程个数减少,可继续执行。

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);
   cvp->count ++; // 修改等待在条件变量的等待队列上的进程计数
  //当管程的 next_count 大于0,说明有进程睡在了 signal 操作上我们将其唤醒
   if (cvp->owner->next_count > 0)
   { // 释放锁
    up(&cvp->owner->next);
   }
   else  //当前没有进程睡在 signal操作数 只需要释放互斥体
   {
    up(&cvp->owner->mutex);
   }
   //将自身阻塞,等待条件变量的条件为真,被唤醒后将条件不成立而睡眠的进程计数减1
   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);
}

实现上述函数后,管程基本已经实现,接下来分析本实验中基于条件变量和管程的哲学家就餐问题的实现

实现哲学家问题主要在check_sync.c中

这里创建了5个线程表示5个哲学家,哲学家尝试4次思考->拿叉子->吃饭->放下叉子。

具体分析实现:主要是phi_take_forks_condvar函数以及phi_put_forks_condvar函数

phi_take_forks_condvar函数表示指定的哲学家尝试获得自己所需要进餐的两把叉子,如果不能获得则阻塞,具体实现流程为:

  1. 给管程上锁,将哲学家的状态修改为HUNGER;
  2. 判断相邻的哲学家是否正在进餐;
  3. 如果能够进餐,将自己的状态修改成EATING,然后释放锁,离开管程即可;
  4. 如果不能进餐,等待在自己对应的条件变量上,等待相邻的哲学家释放资源的时候将自己唤醒;
void phi_take_forks_condvar(int i) {
    //通过P操作进入临界区
     down(&(mtp->mutex));  
      //记录下哲学家i是否饥饿,即处于等待状态拿叉子
      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));
}

phi_put_forks_condvar函数表示释放当前哲学家占用的叉子,并且唤醒相邻的因为得不到资源而进入等待的哲学家:

  • 首先获取管程的锁,将自己的状态修改成THINKING;
  • 检查相邻的哲学家是否在自己释放了叉子的占用之后满足了进餐的条件,如果满足,将其从等待中唤醒
  • 释放锁,离开管程
void phi_put_forks_condvar(int i) {
   	 //通过P操作进入临界区
     down(&(mtp->mutex));
    //记录进餐结束的状态
      state_condvar[i]=THINKING;
    //看一下左边哲学家现在是否能进餐
      phi_test_condvar(LEFT);
    //看一下右边哲学家现在是否能进餐
      phi_test_condvar(RIGHT);
      //如果有哲学家睡眠就予以唤醒
     if(mtp->next_count>0)
        up(&(mtp->next));
     else
        up(&(mtp->mutex));
}

​ 由于每个哲学家只可能占有所有需要的资源或者完全不占用资源,因此不会出现部分占有资源的现象,从而避免了死锁的产生;最终必定所有哲学将都能成功就餐

challenge:实现 Linux 的 RCU

RCU简介:

RCU 的全称是(Read-Copy-Update),意在读写-复制-更新,在 Linux 提供的所有内核互斥的设施当中属于一种免锁机制。在之前讨论过的读写自旋锁(rwlock)、顺序锁(seqlock)一样,RCU 的适用模型也是读写共存的系统。

  • 读写自旋锁:读者和写者互斥,读者和读者共存,写者和写者互斥。(偏向读者)
  • 顺序锁:写者和写者互斥,写者直接打断读者(偏向写者)

RCU 与他们不同,它的读取和写入操作无需考虑两者之间的互斥问题。

之前的锁分析中,可以知道,加锁、解锁都涉及内存操作,同时伴有内存屏障引入,这些都会导致锁操作的系统开销变大,在此基础之上, 内核在 Kernel 的 2.5 版本引入了 RCU 的免锁互斥访问机制。因此RCU实际上是一种改进的 rwlock,读者几乎没有什么同步开销,它不需要锁,不使用原子指令。不需要锁也使得使用更容易,因为死锁问题就不需要考虑了。写者的同步开销比较大,它需要延迟数据结构的释放,复制被修改的数据结构,它也必须使用某种锁机制同步并行的其它写者的修改操作。读者必须提供一个信号给写者以便写者能够确定数据可以被安全地释放或修改的时机。有一个专门的垃圾收集器来探测读者的信号,一旦所有的读者都已经发送信号告知它们都不在使用被RCU保护的数据结构,垃圾收集器就调用回调函数完成最后的数据释放或修改操作。

总的来说,RCU的行为方式:

1、随时可以拿到读锁,即对临界区的读操作随时都可以得到满足

2、某一时刻只能有一个人拿到写锁,多个写锁需要互斥,写的动作包括 拷贝–修改–宽限窗口到期后删除原值

3、临界区的原始值为m1,如会有人拿到写锁修改了临界区为m2,则在写锁修改临界区之后拿到的读锁获取的临界区的值为m2,之前获取的为m1,这通过原子操作保证

对比发现RCU读操作随时都会得到满足,但写锁之后的写操作所耗费的系统资源就相对比较多了,并且只有在宽限期之后删除原资源。

针对对象:

RCU 保护的对象是指针。这一点尤其重要.因为指针赋值是一条单指令.也就是说是一个原子操作.因它更改指针指向没必要考虑它的同步.只需要考虑cache的影响。

内核中所有关于 RCU 的操作都应该使用内核提供的 RCU 的 APIs 函数完成,这些 APIs 主要集中在指针和链表的操作

实现原理:

在RCU的实现过程中,我们主要解决以下问题:

1,在读取过程中,另外一个线程删除了一个节点。删除线程可以把这个节点从链表中移除,但它不能直接销毁这个节点,必须等到所有的读取线程读取完成以后,才进行销毁操作。RCU中把这个过程称为宽限期(Grace period)

2,在读取过程中,另外一个线程插入了一个新节点,而读线程读到了这个节点,那么需要保证读到的这个节点是完整的。这里涉及到了发布-订阅机制(Publish-Subscribe Mechanism)

3, 保证读取链表的完整性。新增或者删除一个节点,不至于导致遍历一个链表从中间断开。但是RCU并不保证一定能读到新增的节点或者不读到要被删除的节点

1. 宽限期
void foo_read(void)
{
    rcu_read_lock();
    foo *fp = gbl_foo;
    if ( fp != NULL )
        dosomething(fp->a,fp->b,fp->c);
    rcu_read_unlock();
}
 
void foo_update( foo* new_fp )
{
	spin_lock(&foo_mutex);
	foo *old_fp = gbl_foo;
	gbl_foo = new_fp;
	spin_unlock(&foo_mutex);
	synchronize_rcu();
	kfee(old_fp);
}

​ 其中 foo_ read 中增加了 rcu_read_lock 和 rcu_read_unlock,这两个函数用来标记一个RCU读过程的开始和结束。其实作用就是帮助检测宽限期是否结束。foo_update 增加了一个函数 synchronize_rcu(),调用该函数意味着一个宽限期的开始,而直到宽限期结束,该函数才会返回。我们再对比着图看一看,线程1和2,在 synchronize_rcu 之前可能得到了旧的 gbl_foo,也就是 foo_update 中的 old_fp,如果不等它们运行结束,就调用 kfee(old_fp),极有可能造成系统崩溃。而3,4,6在synchronize_rcu 之后运行,此时它们已经不可能得到 old_fp,此次的kfee将不对它们产生影响。

2. 订阅——发布机制
#define rcu_assign_pointer(p, v) 
         __rcu_assign_pointer((p), (v), __rcu)
 
#define __rcu_assign_pointer(p, v, space) 
         do { 
                 smp_wmb(); 
                 (p) = (typeof(*v) __force space *)(v); 
         } while (0)

我们可以看到它的实现只是在赋值之前加了优化屏障 smp_wmb来确保代码的执行顺序。另外就是宏中用到的__rcu,只是作为编译过程的检测条件来使用的。

3、数据读取的完整性

为了解决读取完整性的问题,我们调用一个发布语义的原生接口,rcu_assign_ pointer() :

#define rcu_assign_pointer(p, v)					      
{									     
	uintptr_t _r_a_p__v = (uintptr_t)(v); 
    if (__builtin_constant_p(v) && (_r_a_p__v) == (uintptr_t)NULL) //在必要时插入一个内存屏障
    	{	
            WRITE_ONCE((p), (typeof(p))(_r_a_p__v));
        }
    else {			//关闭编译器在赋值时的非顺序编译优化,保证赋值时已经初始化了。
        	smp_store_release(&p, RCU_INITIALIZER((typeof(p))_r_a_p__v));
    		}
}

这个接口能够保证从编译器和CPU层面上gp被赋值前,p指向的字段能够赋值完成。

知识点总结:

1. 原子操作(Atomic Operator)

  • 原子操作是指一次不存在任何中断或失效的操作

  • 该操作只有两种情况

    • 操作成功执行
    • 操作没有执行

    不存在出现部分执行的情况

    • 操作系统需要利用同步机制在并发执行的同时,保存一些操作是原子操作。

2. 进程的交互关系


相互感知的程度交互关系进程间的影响
相互不感知(完全不了解其他进程的存在)独立一个进程的操作对其他进程的结果无影响
间接感知(双方都与第三方交互,例如数据共享)通过共享进行协作一个进程的结果依赖于共享资源的状态
直接感知(双方直接交互,例如通信)通过通信进行协作一个进程的结果依赖于从其他进程获得的信息

进程之间可能出现三种关系:

  • 互斥(mutual exclusion):一个进程占用资源,其他进程不能使用
  • 死锁(deadlock):多个进程占用部分资源,形成循环等待
  • 饥饿(starvation):其他进程可能轮流占用资源,一个进程一直得不到资源

3. 临界区

a. 相关区域的概念

  • 临界区(critical section):进程中访问临界资源的一段需要互斥执行的代码。
  • 进入区(entry section):检查可否进入临界区的一段代码。如果可以进入,则设置“正在访问临界区”标志
  • 退出区(exit section): 清除标志
  • 剩余区(remainder section): 代码中的其余部分

b. 临界区的访问规则

空闲则入、忙则等待、有限等待、让权等待(可选)

让权等待:让不能进入临界区的进程暂时释放CPU资源。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值