第十一课 进程同步
内容大纲
- 内容收尾: 进程调度
- 课后作业点评: 用户线程切换
- 顺序协同:
- xv6的sleep和wakeup机制
- wakeup丢失问题
- sleep期间,进程终止
进程调度
进程切换示意图:
- 下图显示了一个内核线程从
yield()
到scheduler()
线程的过程:yield scheduler acquire release RUNNABLE swtch swtch | ^ | | -------------
ptable.lock的使用
- xv6进程切换中,获取ptable.lock和释放并不在同一个线程中,这种用法通常比较罕见.
- 在xv6中,大部分锁的使用都是在同一线程中.
- 线程切换和锁的这种用法在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
- xv6的sleep和wakeup机制
- 条件变量
- 内存屏障
- ...
sleep && wakeup
sleep(chan, lock)
进程将睡眠在chan
上,其中chan
是进程等待的条件的地址.
wakeup(chan)
将唤醒所在等待在chan
上的进程,可能唤醒不止一个进程.
注意
这里睡眠和唤醒仅仅是一个大概率的可能,并没有正式的规定说 睡眠进程必须在等待条件发生时才会被唤醒.事实上即使等待条件尚未发生,睡眠进程也可能被唤醒.因此睡眠进程在使用sleep()
时,应当再次确认等待事件是否发生.
sleep/wakeup机制面临的问题
- wakeup丢失
- 睡眠过程中,进程中止
sleep/wakeup示例: iderw()/ideintr()
我们以ide硬盘读取为例:
- 首先
iderw()
将块b读取请求放入请求队列,然后sleep
在块b上. - 块b是一个缓冲区,从ide硬盘读入的内容,将会被存放在此.
- 执行
iderw()
的线程将会进入睡眠,等待硬盘读取操作完成.当读取完成后,块b的B_VALID标志位将被置位. - 当ide读取完成后,硬件将会发出中断,进入ide中断处理,执行
ideintr()
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丢失的问题?
目标
- 在condition检查和线程状态为
SLEEPING
前,锁定wakeup()
,不允许调用. - 在线程睡眠期间,释放condition lock.
xv6的策略
- 使用
wakeup
前,要求同时获取condition lock和ptable lock. - 睡眠线程在彻底睡眠前,至少持有condition lock或ptable lock中的一种.
示例
- 在
ideintr()
中,使用wakeup时,就是同时在获取ide.lock和ptable.lock - 在
iderw()
中,调用sleep()时,至少持有一个锁.
图示
|----idelock----|
|---ptable.lock---|
|----idelock----|
|-ptable.lock-|
常见序列协调原语
- 条件变量
- 信号量
另一个示例
- 另一个非常类似的示例是
pipe.c, piperead()/pipewrite()
. piperead()
始终在等待读取buffer中的数据,当读完时,会唤醒写线程.pipe
同样可能存在wakeup丢失的问题- 睡眠线程可能被异常唤醒,在被唤醒后,需要再次检查睡眠条件
停止一个睡眠线程
如何停止一个进程?
困难
- 直接终止一个线程并不一定安全.该线程可能正陷入内核态,正在使用内核栈,页表,或者进程管理表ptable.
- 该线程也可能正处于临界区,正持有锁.
- 综上,我们不能直接杀死一个进程.
解决方法
- 进程自己在合适的时间点退出
kill()
设置了p->killed
标志位- 目标线程在
trap()
和exit()
时,会检查p->killed
标志位,所以kill()
并不会影响在临界区的线程. exit()
会关闭文件描述符,设置线程状态为ZOMBIE
,让出CPU.为什么它不会清理内核栈和页表?- 父进程通过
wait()
来清理子进程的内核栈,页表和proc[]
对应项.
当目标被杀的进程正在SLEEPING?
- 比如目标进程正在等待console输入,正处于
wait()
,或者等待iderw()完成 - 我们希望目标进程能够立刻停止等待,执行退出操作.
- 但这种要求并不总是合理的,比如目标进程正处于一个必须完成的复杂操作中,比如创建文件等.
xv6的解决方法
proc.c/kill()
,会主动唤醒在SLEEPING
状态的目标进程.- 在一些sleep循环中,比如
piperead()
会反复检查p->killed
.若有数据可以读取,则读取数据,从trap返回,然后执行exit.若无数据可读,则检查p->killed
,然后从trap返回,执行exit. - 换言之,对于
piperead()
而言,虽然kill()
是间接的.但是也算是立即反应了. - 对于有些sleep循环而言,就不会反复检查
p->killed
. - 比如
iderw()
在sleep时,就不会再检查p->killed.为什么呢? - 主要是ide操作,对于ide硬件而言是一个原子操作,在中途退出可能导致ide硬盘数据缓存的不一致.因此对于正在执行ide操作的进程,就要等到执行
trap()
时,再执行exit()了.
xv6 kill的细节
- 如果目标进程在用户空间,则在下次调用系统调用或者处理时钟中断时,退出
- 如果目标进程在内核态,则不会再执行其他用户空间指令,不过可能会在内核态消耗一些时间.
JOS中是如何处理这些问题的?
wakeup丢失
- JOS是单处理器OS,并且在内核态禁用中断.因此
wakeup
不会在线程sleep完成前,偷偷执行.
终止睡眠线程
- JOS只有少量简单的系统调用
- JOS没有disk驱动,因此没有复杂的必须原子完成的操作.
- 仅有一个需要注意的调用
IPC recv()
,当env_destroy()
在执行时,目标进程一定不在执行.目标进程在执行recv()
时,依然可以被安全销毁.
总结
sleep/wait
提供了可以让进程间协作的功能- 并发以及中断使得我们不得不考虑
wakeup
丢失的问题 - 在多线程系统中,终止一个线程显得尤为困难:
- 上下文切换 Vs 进程退出
- 进程睡眠 Vs 杀死进程