第十一课 进程同步

内容大纲

  1. 内容收尾: 进程调度
  2. 课后作业点评: 用户线程切换
  3. 顺序协同:
    • xv6的sleep和wakeup机制
    • wakeup丢失问题
    • sleep期间,进程终止

进程调度

进程切换示意图:

  1. 下图显示了一个内核线程从yield()scheduler()线程的过程:
     yield       scheduler
     acquire     release
     RUNNABLE
     swtch       swtch
       |           ^
       |           |
       -------------
    

ptable.lock的使用

  1. xv6进程切换中,获取ptable.lock和释放并不在同一个线程中,这种用法通常比较罕见.
  2. 在xv6中,大部分锁的使用都是在同一线程中.
  3. 线程切换和锁的这种用法在OS的实现中并不罕见.

xv6关于上下文切换和并发的问题

参见第十课的内容


课后作业: 用户线程上下文切换

gdb调试

  (gdb) symbol-file _uthread
  (gdb) b thread_switch
  (gdb) c
  uthread
  (gdb) p/x next_thread->sp
  (gdb) x/9x next_thread->sp
   (gdb) p/x &mythread

栈上第九项的值代表什么含义?

答: 存放的是线程的可执行代码地址,该地址会被载入到CPU的eip寄存器中.

为什么需要将next_thread拷贝到current_thread?

答: current_thread标识了当前正在运行的线程,当线程切换完成,需要修改current_thread的值,以保证下次thread_schedule运行正常.

为什么uthread_yield只会在用户空间调度,而不是调用到内核?

答: 因为该函数仅存在于用户空间,并没有使用到系统调用.因此不会陷入内核.

当uthread因系统调用阻塞时,会发生什么?

答: uthread在内核中仅存在一个线程,是在用户空间模拟了多个用户线程.因此当uthread陷入内核时,用户空间其他模拟线程也不会被调度执行.

uthread可以利用多核CPU并发执行么?

答: 不能,因为uthread是在一个核上模拟了用户空间多线程,自始至终仅有一个核在运行.这些用户空间线程不会被同时调度到不同的核上运行.


顺序协同

进程在执行过程中,可能因为需要等待某些事件,比如:

  • 等待disk io完成
  • 等待pipe reader后读取一部分pipe内容后,以便有空间可以写入
  • 等待子进程退出,以便可以及时清理子进程的资源

在等待特定事件时,是否可以通过while-loop轮询的方式呢?

答: 尽量不要,因为我们不能判断等待时长.轮询是对CPU资源的一种浪费.

更好的做法: 协同使用CPU

  1. xv6的sleep和wakeup机制
  2. 条件变量
  3. 内存屏障
  4. ...

sleep && wakeup

sleep(chan, lock)

进程将睡眠在chan上,其中chan是进程等待的条件的地址.

wakeup(chan)

将唤醒所在等待在chan上的进程,可能唤醒不止一个进程.

注意

这里睡眠和唤醒仅仅是一个大概率的可能,并没有正式的规定说 睡眠进程必须在等待条件发生时才会被唤醒.事实上即使等待条件尚未发生,睡眠进程也可能被唤醒.因此睡眠进程在使用sleep()时,应当再次确认等待事件是否发生.

sleep/wakeup机制面临的问题

  1. wakeup丢失
  2. 睡眠过程中,进程中止

sleep/wakeup示例: iderw()/ideintr()

我们以ide硬盘读取为例:

  1. 首先iderw()将块b读取请求放入请求队列,然后sleep在块b上.
  2. 块b是一个缓冲区,从ide硬盘读入的内容,将会被存放在此.
  3. 执行iderw()的线程将会进入睡眠,等待硬盘读取操作完成.当读取完成后,块b的B_VALID标志位将被置位.
  4. 当ide读取完成后,硬件将会发出中断,进入ide中断处理,执行ideintr()
  5. ideintr()主要进行: 将块b标识为B_VALID,然后唤醒所有等待块b的进程.

思考点

iderw()在睡眠时,将会持有idelock.同时ideintr()也需要持有idelock,那么为什么iderw()不在调用sleep()前,就释放idelock呢? 答: 这里主要是为了针对wakeup丢失的问题.假设这样一个场景: 线程A执行iderw()发起了一次ide请求,然后释放了idelock.此时线程A正准备执行sleep,如果此时ide请求已经完成,那么中断处理程序就会执行ideintr(),尝试唤醒线程A.刚被唤醒的线程A立刻就会执行sleep(),而到了这会就没有中断回来唤醒线程A,这样线程A在没有sleep时,就收到了wakeup.在真正睡眠后,就无法再被调度执行.

如何解决wakeup丢失的问题?

目标

  1. 在condition检查和线程状态为SLEEPING前,锁定wakeup(),不允许调用.
  2. 在线程睡眠期间,释放condition lock.

xv6的策略

  1. 使用wakeup前,要求同时获取condition lock和ptable lock.
  2. 睡眠线程在彻底睡眠前,至少持有condition lock或ptable lock中的一种.

示例

  1. ideintr()中,使用wakeup时,就是同时在获取ide.lock和ptable.lock
  2. iderw()中,调用sleep()时,至少持有一个锁.

图示

    |----idelock----|
                  |---ptable.lock---|
                                     |----idelock----|
                                      |-ptable.lock-|

常见序列协调原语

  1. 条件变量
  2. 信号量

另一个示例

  1. 另一个非常类似的示例是pipe.c, piperead()/pipewrite().
  2. piperead()始终在等待读取buffer中的数据,当读完时,会唤醒写线程.
  3. pipe同样可能存在wakeup丢失的问题
  4. 睡眠线程可能被异常唤醒,在被唤醒后,需要再次检查睡眠条件

停止一个睡眠线程

如何停止一个进程?

困难

  1. 直接终止一个线程并不一定安全.该线程可能正陷入内核态,正在使用内核栈,页表,或者进程管理表ptable.
  2. 该线程也可能正处于临界区,正持有锁.
  3. 综上,我们不能直接杀死一个进程.

解决方法

  1. 进程自己在合适的时间点退出
  2. kill()设置了p->killed标志位
  3. 目标线程在trap()exit()时,会检查p->killed标志位,所以kill()并不会影响在临界区的线程.
  4. exit()会关闭文件描述符,设置线程状态为ZOMBIE,让出CPU.为什么它不会清理内核栈和页表?
  5. 父进程通过wait()来清理子进程的内核栈,页表和proc[]对应项.

当目标被杀的进程正在SLEEPING?

  1. 比如目标进程正在等待console输入,正处于wait(),或者等待iderw()完成
  2. 我们希望目标进程能够立刻停止等待,执行退出操作.
  3. 但这种要求并不总是合理的,比如目标进程正处于一个必须完成的复杂操作中,比如创建文件等.

xv6的解决方法

  1. proc.c/kill(),会主动唤醒在SLEEPING状态的目标进程.
  2. 在一些sleep循环中,比如piperead()会反复检查p->killed.若有数据可以读取,则读取数据,从trap返回,然后执行exit.若无数据可读,则检查p->killed,然后从trap返回,执行exit.
  3. 换言之,对于piperead()而言,虽然kill()是间接的.但是也算是立即反应了.
  4. 对于有些sleep循环而言,就不会反复检查p->killed.
  5. 比如iderw()在sleep时,就不会再检查p->killed.为什么呢?
  6. 主要是ide操作,对于ide硬件而言是一个原子操作,在中途退出可能导致ide硬盘数据缓存的不一致.因此对于正在执行ide操作的进程,就要等到执行trap()时,再执行exit()了.

xv6 kill的细节

  1. 如果目标进程在用户空间,则在下次调用系统调用或者处理时钟中断时,退出
  2. 如果目标进程在内核态,则不会再执行其他用户空间指令,不过可能会在内核态消耗一些时间.

JOS中是如何处理这些问题的?

wakeup丢失

  1. JOS是单处理器OS,并且在内核态禁用中断.因此wakeup不会在线程sleep完成前,偷偷执行.

终止睡眠线程

  1. JOS只有少量简单的系统调用
  2. JOS没有disk驱动,因此没有复杂的必须原子完成的操作.
  3. 仅有一个需要注意的调用IPC recv(),当env_destroy()在执行时,目标进程一定不在执行.目标进程在执行recv()时,依然可以被安全销毁.

总结

  1. sleep/wait提供了可以让进程间协作的功能
  2. 并发以及中断使得我们不得不考虑wakeup丢失的问题
  3. 在多线程系统中,终止一个线程显得尤为困难:
    • 上下文切换 Vs 进程退出
    • 进程睡眠 Vs 杀死进程

results matching ""

    No results matching ""