MIT6.S081 ---- Preparation: Read chapter 7
Chapter 7 Scheduling
任何操作系统运行的进程数量都可能超过计算机 CPUs 的数量,所以这些进程需要一个策略分时共享 CPUs。理想情况下,共享对用户进程是透明的(用户进程不感知共享)。一种常用的方法是通过多路复用(\(multiplexing\)),在硬件 CPUs 上调度这些进程,让每个进程感觉独占虚拟 CPU。本章解释 xv6 如何实现多路复用。
xv6 内核共享内存:xv6 支持内核线程的概念,每个进程有一个内核线程来执行这个进程的系统调用,所有这些内核线程共享内核内存。
xv6 每个用户进程都有一个控制线程,执行这个进程的用户指令。
xv6 内核线程机制大部分是支持在多个用户进程之间切换,每个用户进程有一个 线程运行在进程的内存上,所以用户进程内的线程间不共享内存,因为每个进程都有单独的地址空间和单个线程。
Linux 使用了一些基础的技术,允许在用户进程内有多个线程,这些线程共享所在进程的内存。
Multiplexing
在两种情况下,xv6 将 CPU 从一个进程切换到另一个进程,从而实现多路复用:
- 首先,当一个进程等待设备或者管道 I/O 完成,或者等待子进程 exit,或者等待
sleep
系统调用时,xv6 的sleep
和wakeup
机制负责切换。 - 其次,xv6 周期性的强制处理计算密集型任务(长时间运行没有 sleep)。
多路复用给进程一个感觉:自己独占一个 CPU,就像 xv6 使用内存分配器和硬件页表使进程感觉自己独占内存一样。
实现多路复用带来了一些挑战:
- 第一,如何从一个进程切换到另一个进程?尽管上下文切换的想法很简单,但它的实现是 xv6 中最难懂的代码。
- 第二,如何以对用户进程透明的方式强制切换(抢占式调度,非自愿式调度)?xv6 使用标准的技术,硬件定时器的中断驱动上下文切换。(用户进程没有直接的切换方式,而是通过定时器中断进入内核,内核让出 CPU 给其他进程,对哟用户进程是透明的)
- 第三,所有 CPUs 都在一组共享进程之间切换,为避免竞争,需要一个 locking plan。
- 第四,当进程 exit 时,进程的内存和其他资源必须被释放,但不能自己释放,因为(例如)不能在仍使用内核栈使释放内核栈。
- 第五,多核机器的每个核心必须记录哪个进程正在运行该核心上运行,以便系统调用能影响对应的进程的内核状态。
- 最后,
sleep
和wakeup
允许进程放弃 CPU 并且等待被另一个进程或中断唤醒。
小心避免竞争(races),竞争会导致丢失 wakeup 通知。xv6 尝试尽可能简单的解决这些问题,但是代码仍然很难搞。
Code: Context switching
图 7.1 描述了从一个用户进程切换到另一个用户进程的相关步骤:
- 一次用户态-内核态的转换(系统调用或中断),转换到被旧的(被切换的进程)进程的内核线程;
- 一次上下文切换,切换到当前 CPU 的调度线程;
- 一次上下文切换,切换到新的(切换到的进程)进程的内核线程;
- 一次 trap 返回到用户级进程。(进程的切换实质是内核线程的切换和trap机制的结合)
xv6 中,每个 CPU 有一个专用的调度线程(保存的寄存器和栈),因为在旧的进程的内核栈上执行调度不安全:一些其他核心可能唤醒进程并运行,在不同的核心上使用相同的栈是灾难(调度线程有独立的栈)。
本节阐述内核线程和调度线程之间切换的机制。
从一个线程切换到另一个线程涉及保存旧线程的 CPU 寄存器,恢复之前保存的新线程的寄存器;保存和恢复 $sp
和 $pc
表示 CPU 将切换栈,以及将要执行的代码。
任何时候,一个 CPU 核心只能运行一个线程:用户线程,内核线程,调度线程。
一个进程执行用户级指令,或者在内核中执行指令,或者不执行,状态被保存在 trapframe 和上下文中。
每个进程有两个线程:一个是用户级线程,一个是内核级线程,进程要么在用户空间执行,要么在内核空间执行。
函数 swtch
为内核线程切换保存和恢复上下文。swtch
不直接感知线程;只是保存和恢复 \(32\) 个 RISC-V 寄存器,称为 \(contexts\)。当进程要放弃 CPU 时,进程的内核线程调用 swtch
保存自己的 \(context\),返回调度线程的 \(context\)。
每个 \(context\) 用 struct context
(kernel/proc.h)表示,进程的 struct proc
和 CPU 的 struct cpu
都有 struct context 这个结构体。
swtch
接受两个参数:struct context *old
和 struct context *new
。在 old
中保存当前寄存器,恢复 new
中的寄存器,然后返回。
通过一个进程的 swtch
观察调度。在 Chapter4 中,中断结束有可能 usertrap
调用 yield
。yield
调用 sched
,sched
调用 swtch
保存当前 context 在 p->context
,并且切换到之前保存在 cpu->scheduler
(kernel/proc.c) 中的调度 context。
swtch
(kernel/swtch.S)只保存 callee-saved
寄存器;C 编译器在调用 swtch
处生成代码,在栈上保存 caller-saved
寄存器。swtch
知道 struct context
中每个寄存器域的偏移。
不保存 $PC
寄存器(因为正在执行的就是 swtch
,这的 $PC
总是可预测的)。但是,swtch
要保存 $ra
寄存器,存储调用 swtch
处的返回地址(需要知道从哪里调用的 swtch
,方便切换回这个线程时,从调用点继续执行)。
swtch
从新的上下文中恢复寄存器,新的上下文是上一个 swtch
保存的寄存器的值。
当 swtch
返回时,返回到恢复的 $ra
寄存器指向地址的指令,就是新的线程之前调用 swtch
的指令。另外,会返回新线程的栈,因为恢复的 $sp
寄存器指向了这里。
为什么
swtch
只保存了 \(14\) 个寄存器,但 RISC-V 代码可以使用 \(32\) 个寄存器?
调用swtch
是按照调用函数的方式进行调用的,C 编译器生成代码保存caller-saved
寄存器。
在我们的例子中,sched
调用 swtch
切换到 cpu->scheduler
(每个 CPU 有一个 scheduler context)。当 scheduler
调用 swtch
(kernel/proc.c) 切换为放弃 CPU 的进程时(我理解这里放弃 CPU 的进程指的是由运行态转为就绪态的进程),上下文被保存。当跟踪 swtch
返回时,它没有返回到 sched
,而是返回到 scheduler
,栈指针在当前 CPU 的 scheduler 栈中。
Code: Scheduling
上小节看了 swtch
的底层细节;现在将 swtch
作为封装,检查从一个进程的内核线程通过 scheduler 切换到另一个进程。scheduler 在每个 CPU 中用一个特殊的线程表示,这些线程运行 scheduler()
函数。函数负责选择下一个要运行的进程。
一个进程想放弃 CPU 必须: 持有自己的进程锁 p->lock
,释放持有的其他锁(不释放其他锁容易造成死锁:比如单 CPU 单 core,进程 \(1\) 持有锁 L,切换到进程 \(2\),然后进程 \(2\) 自旋(自旋锁的持有先关中断,再自旋)在锁 L 上,形成死锁),更新自己的状态(p->state
),然后调用 sched
。 yield
(kernel/proc.c)、sleep
以及 exit
就是这个处理流程。
sched
仔细检查了一些要求(kernel/proc.c),然后检查了一个细节:因为锁被占有,中断应为关闭状态。
最后,sched
调用 swtch
在 p->context
中保存当前上下文,并且切换为 cpu->context
中保存的 scheduler context。swtch
返回到 scheduler 的栈上,好像 scheduler
调用 swtch
然后返回一样。scheduler 继续 for
循环,找到一个可以运行的进程,切换到这个进程,如此循环。
刚看到 xv6 对于 swtch
的调用需要先占有 p->lock
:swtch
的调用者必须已经先占有锁,锁的控制传递到切换到的代码。
这个规则对于锁的使用不同寻常:通常 acquire 锁的线程有责任 release 锁,这样会更容易验证正确性。对于上下文切换很有必要打破这个规则,因为在执行 swtch
时 p->lock
保护的不变量(进程的 state
和 context
域)不是真的(我理解这里的意思是虽然修改了进程的 state,但是当前并没有完成切换,还在切换的过程,所以不变量不是真的)。例如有个问题,如果 p->lock
没有在 swtch
期间被占有:其他 CPU 可能决定在 yield
设置一个进程状态为 RUNNABLE
后,且 swtch
完成切换前,即进程对应的内核线程的内核栈还在被原 CPU 使用期间,运行这个进程。后果就是两个 CPU 运行在同一个栈上,会造成混乱。
内核线程放弃 CPU 的唯一地方就是 sched
,它总是切换到 scheduler
的固定位置,scheduler
几乎总是调度一些之前调用 sched
的内核线程。因此,如果打印出 xv6 线程切换的行号,可能观察到如下的简单模式:(kernel/proc.c:scheduler():swtch),(kernel/proc.c:sched():swtch),(kernel/proc.c:scheduler():swtch),(kernel/proc.c:sched():swtch),以此循环。有意通过线程切换相互之间转移控制权的过程称为协程(\(coroutines\));在这个例子中,sched
和 scheduler
相互是协程。
有一种情况 scheduler 调用 swtch
没有在 sched
中结束。allocproc
设置新进程的上下文 $ra
寄存器为 forkret
(kernel/proc.c),因此它的第一次 swtch
调用返回 forkret
的开头。forkret
释放锁 p->lock
;此外,因为新进程需要返回用户空间(就像 fork
的返回),可以从 usertrapret
开始执行。
scheduler
(kernel/proc.c)运行一个循环:找一个要运行的进程,运行这个进程直到这个进程放弃 CPU,重复这个过程。
scheduler 遍历进程表,找到一个就绪的进程(p->state == RUNNABLE
)。一旦找到一个就绪进程,则设置对应 CPU 的 c->proc
,标记进程状态为 RUNNING
,然后调用 swtch
开始运行。(kernel/proc.c)
思考 scheduling 代码的结构的一个方法是:它强制维护一组进程的不变量(进程状态 p->state
,CPU 上正在运行的进程 c->proc
,进程的上下文 p->context
),在这些不变量不真时,都占有 p->lock
。
一个不变量是如果一个进程状态是 RUNNING
,定时器中断的 yield
必须能安全的切换掉这个进程;这意味着 CPU 寄存器必须保存进程的寄存器值(如,swtch
还没有将这些值写入 context
) ,c->proc
必须指向这个进程。
另一个不变量是如果进程的状态是 RUNNABLE
,一个空闲 CPU 的 scheduler
必须能安全的运行这个进程;这意味着 p->context
必须保存进程的寄存器(如,它们不是真正的寄存器),还意味着 CPU 没有执行在进程的内核栈上,意味着 CPU 的 c->proc
没有指向这个进程。
观察发现,在占有 p->lock
期间,这些不变量属性经常不是真的。(所以需要锁保护)
维护这些不变量解释了为什么 xv6 经常在一个线程中 acquire p->lock
,在另一线程 release p->lock
,例如,在 yield
中 require ,在 scheduler
中 release。
一旦 yield
已经开始修改一个运行进程的状态为 RUNNABLE
,则锁必须保持被占有,直到恢复不变量:最早的正确的 release 位置在 scheduler
(运行在自己的栈上)清除 c->proc
之后。类似的,一旦 scheduler
开始将一个 RUNNABLE
进程转为 RUNNING
,直到内核线程完全运行锁才能被 release (如在 yield
中的 swtch
之后)。
Code: mycpu and myproc
xv6 经常需要一个指向当前进程 proc
结构体的指针。在单处理机上,可以用一个指向当前 proc
的全局变量。但多核机器上不行,因为每个核心执行的是不同的进程。
解决这个问题的方法是:利用每个核心有自己的一组寄存器;能使用其中一个寄存器帮助找到对应核心的信息。
xv6 为每个 CPU 维护一个 struct cpu
(kernel/proc.h),里面记录有:这个 CPU 上正在运行的进程(如果有),为 CPU 的 scheduler 线程保存的寄存器,用于管理关中断的嵌套的 spinlocks 的数量。函数 mycpu
(kernel/proc.c)返回一个指向当前 CPU 的 struct cpu
的指针。RISC-V 用 hartid
为它的 CPU 编号。在内核中时, xv6 确保每个 CPU 的 hartid
存储在 CPU 的 $tp
寄存器中。这使得 mycpu
能使用 $tp
索引 cpu
结构体数组找到对应的 struct cpu
。
确保一个 CPU 的 $tp
总是保存 CPU 的 hartid 有点复杂。
在 CPU 的启动早期,处于 machine-mode 时,start
设置 $tp
寄存器(kernel/start.c)。usertrapret
保存 $tp
到 trapframe->kernel_hartid
,因为用户空间可能修改 $tp
寄存器。
最后,当从用户空间进入内核时,uservec
恢复保存的 $tp
寄存器。编译器保证永远不会使用 $tp
寄存器。如果 xv6 在需要时能向 RISC-V 硬件请求当前的 hartid,这样会更方便,但是 RISC-V 只允许在 machine-mode 而不是 supervisor-mode 可以直接获取 hartid。
cpuid()
和 mycpu()
的返回值是易损的:如果定时器中断并且使线程 yield,然后调度到另一个 CPU,之前的返回值将是错误的。为了避免这个问题,xv6 要求调用者关中断,在使用完返回的 struct cpu
之后再开中断。
函数 myproc()
返回指向正运行在当前 CPU 上的进程的 struct proc
指针。myproc
关中断,调用 mycpu
,从当前进程指针(c->proc
) 中取出 struct cpu
,然后开中断。即使开中断,myproc
的返回值也可以安全使用:如果定时器中断将调用进程调度到其他 CPU ,它的 struct proc
指针也保持相同。
Sleep and wakeup
调度和锁帮助隐藏了一个线程对另一个线程的操作,但是我们也需要一个帮助线程主动交互的抽象。
例如,xv6 中读管道可能需要等待一个写进程产生数据;一个父进程调用 wait
可能需要等待子进程退出;一个读硬盘的进程需要等待硬盘硬件完成读取。
xv6 使用一个称为 sleep 和 wakeup 的机制应对这些情况。sleep 允许一个内核线程等待一个特定的事件;另一个线程能调用 wakeup 通知那个等待事件的线程恢复。sleep 和 wakeup 通常称为序列协作或条件同步机制(\(sequence\ coordination\) or \(conditional\ synchronization\ mechanisms\))。
sleep 和 wakeup 提供了一个相对底层的同步接口。为了在 xv6 中应用这种方法,使用它们构建一个更高层次的同步机制,称为信号量(\(semaphore\))协调生产者和消费者(xv6 没有使用信号量)。一个 semaphore 维护了一个计数并提供了两个操作。V
操作(用于生产者)增加计数。P
操作(用于消费者)等待直到计数值非零,然后将计数值减一,然后返回。如果只有一个生产者,一个消费者,它们在不同的 CPUs 上运行,并且编译器没有做太多优化,则下列实现可能是正确的:
struct semaphore {
struct spinlock lock;
int count;
};
void
V(struct semaphore *s)
{
acquire(&s->lock);
s->count += 1;
release(&s->lock);
}
void
P(struct semaphore *s)
{
while(s->count == 0)
;
acquire(&s->lock);
s->count -= 1;
release(&s->lock);
}
上述实现代价较高。如果生产者很少运行,消费者将花费大量时间自旋(while
循环判断是否非零)。消费者 CPU 也许能找到比忙等(\(busy\ waiting\))轮询(\(polling\))s->count
更高效的方法。避免忙等,需要消费者让出 CPU ,当 V
操作增加计数值之后恢复运行。
实现这些还远远不够。想象下这一对调用,sleep
和 wakeup
按如下方式工作。sleep(chan)
暂停运行在任意值 chan
上,称为 \(wait\ channel\)。sleep
让调用它的进程睡眠,释放 CPU 给其他进程运行。
wakeup(chan)
唤醒所有睡眠在 chan
(如果有)上的进程,使它们的 sleep
调用能返回。如果没有进程等待在 chan
上,wakeup
什么都不做。通过使用 sleep
和 wakeup
改变信号量的实现。
void
V(struct semaphore *s)
{
acquire(&s->lock);
s->count += 1;
wakeup(s);
release(&s->lock);
}
void
P(struct semaphore *s)
{
while(s->count == 0)
sleep(s);
acquire(&s->lock);
s->count -= 1;
release(&s->lock);
}
P
没有自旋,而是放弃 CPU ,这很好。然而,事实证明,没有遇到所谓的 \(lost\ wake-up\) 问题,用这个接口设计 sleep
和 wakeup
并不简洁。
假设 P
发现 s->count == 0
。之后当 P
运行在 \(13\) 和 \(14\) 行之间时,在另一个 CPU 上运行 V
:它改变 s->count
为非 \(0\),并且调用 wakeup
,结果发现没有进程睡眠,然后 wakeup
什么都不做。之后 P
继续执行 \(14\) 行:它调用 sleep
并且睡眠。这引入一个问题:P
正在睡眠等待一个已经运行完了的 V
操作。
除非很幸运,生产者再次调用 V
,否则即使计数值非零,消费者也将永远等待。
根本问题是不变量(只有当 s->count == 0
时,P
才运行)被运行在错误时间的 V
所违反。保护不变量的一个不正确的方法是在 P
中应用锁,将检查 count
和 调用 sleep
作为一个原子执行。
void
V(struct semaphore *s)
{
acquire(&s->lock);
s->count += 1;
wakeup(s);
release(&s->lock);
}
void
P(struct semaphore *s)
{
acquire(&s->lock);
while(s->count == 0)
sleep(s);
s->count -= 1;
release(&s->lock);
}
这个版本的实现避免 lost wakeup,因为锁防止 V
在 \(14\) 和 \(15\) 之间执行。但这可能造成死锁:P
睡眠的时候持有锁,所以 V
将一直阻塞等待锁。
通过更改 sleep
接口修复之前的方案:调用者必须传递条件锁(\(condition\ lock\))给 sleep
,在调用者进程被标记为睡眠并且在 sleep channel 上等待,之后释放锁。
锁将强制并发的 V
等待,直到 P
已经使进程进入睡眠,以便 wakeup
能发现睡眠的消费者并且唤醒它。一旦消费者被唤醒,sleep
在返回前会再次要求占有锁。下面的 sleep/wakeup 方案是可用的。
void
V(struct semaphore *s)
{
acquire(&s->lock);
s->count += 1;
wakeup(s);
release(&s->lock);
}
void
P(struct semaphore *s)
{
acquire(&s->lock);
while(s->count == 0)
sleep(s, &s->lock);
s->count -= 1;
release(&s->lock);
}
P
持有 s->lock
, 防止 V
尝试在 P
检测 c->count
和调用 sleep
之间唤醒进程。然而,为了避免 lost wakeup,我们需要 sleep
释放 s->lock
并让消费者进程睡眠作为一个原子操作。
Code: Sleep and wakeup
xv6 sleep
和 wakeup
提供了上个例子展示的接口,这样的实现(以及如何使用它们的规则)确保不会出现 lost wakeup。基本的思想是: sleep
标记当前进程为 SLEEPING
并且调用 sched
释放 CPU; wakeup
查找睡眠在给定 wait channel 上的进程,然后标记为 RUNNABLE
。sleep
和 wait
的调用者能使用任何双方都方便的数字作为 channel。xv6 经常使用与等待相关的内核数据结构的地址。
sleep
持有锁 p->lock
。现在将要睡眠的进程持有 p->lock
和 lk
。持有 lk
对于调用者(本例中是 P
)是必要的:确保其他进程(本例中是 V
)不能开始调用 wakeup(chan)
。sleep
持有 p->lock
,安全的释放 lk
:其他进程可能开始调用 wakeup(chan)
,但是 wakeup
将等待持有 p->lock
,直到 sleep
已经使进程进入睡眠,防止 wakeup
错过 sleep
。
现在 sleep
持有 p->lock
并且没有占有其他锁,通过记录 sleep channel、改变进程状态为 SLEEPING
、调用 sched
将使进程进入睡眠。接下来才明白为什么直到进程被标记为 SLEEPING
之后才释放 p->lock
(被 scheduler
释放)很关键。
在某个时刻,一个进程将持有条件锁,设置睡眠进程等待的条件,调用 wakeup(chan)
。当占有条件锁使,再调用 wakeup
很重要。wakeup
循环进程表,占有要检查的进程的 p->lock
:因为可能操作进程的状态,还因为 p->lock
能确保 sleep
和 wakeup
不会相互错过彼此。当 wakeup
找到一个进程状态为 SLEEPING
并且和 chan
匹配,则改变进程的 RUNNABLE
。下次 scheduler 运行,将发现该进程可运行。
为什么 sleep
和 wakeup
的 locking rules 能保证睡眠进程不会错过一个 wakeup
?
睡眠进程占有条件锁或者自己的 p->lock
或者在检查条件之前和标记 SLEEPING
之后占有两个锁。
调用 wakeup
的进程在 wakeup
循环之间占有两个锁。因此唤醒进程要么在消费线程检查条件之前使条件为真;要么唤醒进程的 wakeup
在睡眠线程被标记 SLEEPING
之后严格检查睡眠线程。wakeup
找到睡眠进程并唤醒它(除非其他进程先唤醒了它)。
有时,多个进程在同一个 channel 上睡眠;例如,多个进程读一个管道。调用一次 wakeup
将唤醒它们。其中一个进程将首先运行,并占有 sleep
被调用时带有的锁,并且(如果是管道)读取等待在管道中的数据。其他进程被唤醒时将发现没有数据可以读取。在这些进程看来,wakeup
是假的,它们必须再次睡眠。因此,sleep
总是在检查条件的循环中被调用。
如果 sleep/wakeup
被意外在同一个 channel 调用了两次,它们将看到虚假的 wakeup,但上述循环会容忍这个问题。sleep/wakeup
的吸引力在于既轻量(不需要创建特殊的数据结构作为睡眠 channel)并且提供了一个间阶层(调用者不需要知道他们正和哪个特定的进程进行交互)。
Code: Pipes
使用 sleep
和 wakeup
同步生产者和消费者的一个更复杂的例子是 xv6 的管道实现。我们在 Chapter1 中看到的管道接口:写入管道一端的字节数据被拷贝到内核中的缓冲区,然后从管道的另一端读出。后面的章节研究管道相关的支持基础----文件描述符,本章先研究 pipewrite
和 piperead
的实现。
管道可以用 struct pipe
表示,包含一个 lock
和 data
缓冲区。nread
和 nwrite
统计读取和写入缓冲区的字节数。
缓冲区是环形:buf[PIPESIZE-1]
之后的下一个字节是 buf[0]
。计数不环绕。这个约定使实现将缓冲区满(nwrite == nread+PIPESIZE
)和缓冲区空(nwrite == nread
)进行区分,但这意味着缓冲区索引必须使用 buf[nread % PIPESIZE]
而不是 buf[nread]
(nwrite
类似)。
思考在不同的 CPUs 上同时调用 piperead
和 pipewrite
。pipewrite
(kernel/pipe.c)获取管道锁,该锁保护计数值、data 和相关的不变量。piperead
也尝试获取锁,但是不能获得,它在 acquire
中自旋等待锁。当 piperead
等待时,pipewrite
循环处理要被写入的字节(addr[0..n-1]
),逐一添加到管道中。循环中,可能出现缓冲区满的情况,此时,pipewrite
调用 wakeup
提醒所有睡眠状态的读进程缓冲区中有等待的数据,然后在 &pi->write
上睡眠,等待读进程从缓冲区中取出一些字节。sleep
释放 pi->lock
,使调用 pipewrite
的进程睡眠。
现在 pi->lock
可用,piperead
管理占有 pi-lock
,并进入它的临界区:发现 pi->nread != pi->nwrite
(pipewrite
进入睡眠,因为 pi->nwrite == pi->nread+PIPESIZE
),进入 for
循环,将数据拷贝出管道,拷贝出一个字节,则 nread
加 \(1\)。现在又允许写入多个字节,所以 piperead
在返回之前调用 wakeup
唤醒睡眠的写进程。wakeup
找到一个睡眠在 $pi->nwrite
上的进程,这个进程之前运行 pipewrite
,但是因为缓冲区满停止运行了。它标记进程为 RUNNNABLE
。
管道代码为读、写进程使用独立的 sleep channels(pi->nread
和 pi->nwrite
);在不太常见的情况下如许多读写进程等待同一个管道,可能使系统更高效。管道代码在检查睡眠条件的循环中 sleep;如果有多个读进程或者写进程,除了第一个进程,其他被唤醒的进程看到的条件仍然是假的,它们会再次睡眠。
Code: Wait, exit, and kill
sleep
和 wakeup
能用于多种等待。Chapter1 介绍的一个有趣的例子,子进程的 exit
和父进程的 wait
相互作用。子进程终止后,父进程可能正在 wait
上睡眠,也可能做其他事情;后一种情况,接下来调用 wait
一定要注意子进程的终止,可能在子进程调用 exit
之后很久才注意到。
xv6 直到 wait
注意到子进程因为 exit
将进程状态标记为 ZOMBIE
,才认为子进程终结,改变子进程的状态为 UNUSED
,复制子进程的 exit 状态,返回子进程的进程 ID 到父进程。如果父进程在子进程 exit 前 exit,则父进程将子进程给 init
进程,init
进程一直调用 wait
;因此每个子进程都有一个父进程清理它。
一个挑战是避免竞争和死锁:父进程 wait
和子进程 exit
同时发生,以及 exit
和 exit
同时发生。
wait
开始先占有 wait_lock
。原因是 wait_lock
作为条件锁帮助确保父进程不会错过一个 exit 的子进程的 wakeup
。
wait
遍历进程表。如果发现一个子进程的状态是 ZOMBIE
,它会释放子进程的资源和 proc
结构体,复制子进程的 exit
状态到 wait
提供的地址(如果不为 \(0\)),返回子进程的进程 ID。如果 wait
找到的子进程都没有 exit,就调用 sleep
等待一个子进程 exit,然后再次遍历进程表。wait
经常持有两个锁,wait_lock
和 一些进程的 np->lock
;死锁避免顺序是先占有 wait_lock
,后占有 np->lock
。
exit
记录 exit 状态,释放一些资源,调用 reparent
将子进程给 init
进程,唤醒正在 wait
的父进程,标记 exit
调用者为僵尸(zombie),永久的让出 CPU。在这个过程中,exit
持有 wait_lock
和 p->lock
:
- 持有
wait_lock
因为它是wakeup(p->parent)
的条件锁,防止wait
的父进程丢失 wakeup ; exit
在这个过程中持有p->lock
,防止wait
的父进程在子进程最终调用swtch
之前发现子进程的状态是ZOMBIE
。- 为避免死锁,
exit
占有这些锁的顺序和wait
相同。
exit
在设置进程状态为 ZOMBIE
之前唤醒父进程看似不正确,实则很安全:尽管 wakeup
可能造成父进程运行,wait
的循环直到子进程的 p->lock
被 scheduler
释放才检查子进程,所以直到 exit
已经设置状态为 ZOMBIE
之后才查找 exit 的进程。
exit
允许一个进程自我终止,而 kill
能让一个进程请求终止另一个进程。对于 kill
来说直接销毁受害者进程可能很复杂,因为受害者可能在另一个 CPU 上执行,可能在执行内核数据结构更新的敏感操作。因此 kill
做的工作很少:仅仅设置受害者进程的 p->killed
,如果该进程正在睡眠,则唤醒它。最终,如果 p->killed
被设置,在 usertrap
中进入或者离开内核的那段代码将调用 exit
。如果受害者进程正运行在用户空间,它将很快进入通过系统调用或者定时器中断(或其他设备中断)进入内核。
如果受害者进程在 sleep
,kill
调用 wakeup
将使得受害者进程从 sleep
返回。这是潜在的危险,因为等待的条件可能不对。然而,xv6 对 sleep
的调用总是被包装在 while
循环中,该循环在 sleep
返回后重新测试条件。一些 sleep
调用在循环中也测试 p->killed
,如果 p->killed
被设置,则放弃当前活动。只有这种放弃是正确的时才能这样做。例如,如果 killed 标志被设置,管道读、写代码返回;最终,代码将返回到 trap,会再次检查 p->killed
并且 exit。
一些 xv6 的 sleep
循环没有检查 p->killed
,因为代码在一个应该被原子执行的多步系统调用的中间。 如 virtio driver: 它没有检查 p->killed
,因为一个硬盘操作可能是文件系统保持正确状态所需的一组写入操作的其中之一。当进程等待硬盘 I/O 时,kill 这个进程,则直到完成当前系统调用并且 usertrap
看到 killed 标志后才 exit。
Process Locking
和每个进程都相关联的锁 p->lock
是 xv6 中最复杂的锁。思考 p->lock
的一个简单方法是:当读、写 struct proc
的域:p->state
、p->chan
、p->killed
、p->xstate
和 p->pid
时,必须占有 p->lock
。这些域能被其他进程、其他核心上的调度线程使用,自然需要一个锁保护。
然而,大多数 p->lock
的使用是为了保护 xv6 的进程数据结构和算法的更高层级方面。以下是 p->lock
所作的全部工作:
- 配合
p->state
,防止为新进程分配proc[]
插槽时竞争 - 隐藏了一个进程的创建和销毁
- 防止一个父进程的
wait
回收一个进程状态被设置为ZOMBIE
,但是没有让出 CPU 的子进程 - 防止其他核心的调度线程在一个放弃 CPU 的进程设置进程状态为
RUNNABLE
但是没有完成swtch
时运行这个进程 - 确保只有一个核心的调度线程能运行一个
RUNNABLE
进程。 - 防止定时器中断造成一个进程在
swtch
时让出 CPU。 - 配合条件锁,防止
wakeup
忽略一个正在调用sleep
但是没有让出 CPU 的进程。 - 防止
kill
掉的受害者进程在kill
检查p->pid
和 设置p->killed
之间 exit 和被重新分配(这会导致 kill 掉另一个进程)。 - 使
kill
的检查和p->state
的写入成为原子性操作。
p->parent
域被全局锁 wait_lock
而不是 p->lock
保护。只有一个进程的父进程能修改 p->parent
,尽管 p->parent
能被进程自己和其他查询他们子进程的进程读取。wait_lock
的目的是作为条件锁,wait
睡眠等待任意子进程 exit。在设置进程状态为 ZOMBIE
、唤醒父进程、放弃 CPU 之前,一个终结的子进程要么占有 wait_lock
要么占有 p->lock
。wait_lock
也序列化父子进程的并发 exit
,所以 init
进程(接受子进程)确保从 wait
中被唤醒。wait_lock
是一个全局锁,而不是单个父进程的锁,因为,只有一个进程持有了这个锁,才能知道它的父进程是谁。
Real world
xv6 的调度器实现了一个简单的调度策略,依次运行每个进程。该策略被称为轮询(\(round\ robin\))。真正的操作系统实现了更成熟的策略,例如,允许进程有优先级。思想是,对于就绪态进程,相对于低优先级的进程,调度器优先选择一个高优先级进程。这些策略可能很快变得很复杂,因为经常存在竞争的目标:如,操作系统想保证公平性(fairness)和高吞吐量(high throughput)。另外,复杂的策略可能导致意外的影响:如优先级反转(\(priority\ inversion\))和锁护航(\(convoys\))。优先级反转:一个低优先级进程和一个高优先级进程使用一个特殊的锁,锁被低优先级占有,阻碍了高优先级继续运行。等待进程的long convey:一个低优先级进程持有一个共享锁,很多需要持有这个锁的高优先级进程需要等待;护航(convey)一旦形成会持续很久。为了避免这类问题,在成熟的调度器中有必要设计额外的机制。
sleep
和 wakeup
是一个简单和高效的同步方法,还有很多其他方法。第一个挑战是在本章开头所看的避免 lost wakeup
问题。最初 Unix 内核的 sleep
简单的关中断就足够了,因为 Unix 运行在一个单 CPU 系统。
因为 xv6 运行在一个多处理器上,它为 sleep
添加了一个显式锁。FreeBSD 的 msleep
采用了相同的方法。Plan 9 的 sleep
使用了一个回调函数,该函数在睡眠前持有调度锁;函数在最后时刻检查睡眠条件,避免 lost wakeups。Linux 内核的 sleep
使用一个显式的进程队列,称为等待队列(wait queue),而不是 wait channel;队列有自己的内部锁。
在 wakeup
中扫描整个进程集效率低。一个更好的方法是用一个数据结构,这个数据结构上有一列睡眠进程,如 Linux 的 wait queue,用这个数据结构来替换 sleep
和 wakeup
中的 chan
。Plan 9 的 sleep
和 wakeup
调用组成了一个集合点(rendezvous point)或者 Rendez
。许多线程库将这一结构作为条件变量;这种情况下,sleep
和 wakeup
被称为 wait
和 signal
。所有这些机制都有一个共同特点:睡眠条件在 sleep 期间被一些锁原子性的保护。
wakeup
的实现唤醒所有在特定 channel 上等待的进程,并且,它可能是许多进程在特定 channel 上等待的例子。操作系统将调度所有这些进程,并且它们将竞争检测 sleep condition。这种方式运行的进程有时称为 \(thundering\ herd\),最好避免。大多数条件变量有两个原语 wakeup: signal
,它们唤醒一个进程,然后 broadcast
唤醒所有的等待进程。
信号量经常用来同步。计数值通常对应于管道缓冲区的可用字节数或者一个进程的僵尸子进程的数量。使用一个显式的计数作为抽象避免了 lost wakeup 问题:wakeup 发生的次数是一个显式的计数值。计数值避免了虚假的 wakeup 和 惊群效应(\(thundering\ herd\))问题。
终止进程并清理它们会为 xv6 引入很多复杂性。大多数操作系统甚至更复杂,因为如,受害者进程可能在内核睡眠的深处,展开它的栈需要小心,因为调用栈上的每个函数都可能需要一些清理。一些语言通过提供 exception 机制帮助处理,但 C 语言没有。此外,即使等待的事件还没有发生,其他时间可能造成一个睡眠进程被唤醒。例如,当一个 Unix 进程睡眠时,另一个进程可能给它发送一个 signal
。这种情况,进程将返回被中断的系统调用,返回值为 \(-1\),错误码设置为 EINTR。应用能检查这些值,并决定做什么。xv6 不支持 signal,没有增加复杂性。
xv6 对 kill
的支持不完全是令人满意的:有的 sleep 循环可能需要检查 p->killed
。一个相关的问题是,尽管 sleep
循环检查 p->killed
,sleep
和 kill
之间有一个竞争;后者可能设置 p->killed
,在受害者进程循环检查 p->killed
之后但在调用 sleep
之前尝试唤醒受害者进程。如果出现问题,则直到等待的条件出现,受害者进程才会注意到 p->killed
。这可能要晚点出现,甚至永远不会出现(如果受害者进程等待 console 的输入,但用户没有键入任何字符)。
一个真正的操作系统在特定时间在一个显式的 free list 中找空闲的 proc
结构体,而不是在 allocproc
中线性查找;xv6 使用线性查找简化实现。