睡眠与唤醒机制
当进程需要等待某个条件时,例如磁盘I/O完成,某个资源可用时,可以使用睡眠机制让出CPU。当条件满足时,再由别的进程唤醒。核心函数是sleep_prepare()、sleep()和wakeup()
sleep_prepare()函数将进程的睡眠状态联系于某个变量chan中,这个变量由进程自己选取。随后调用sleep()真正进入睡眠,本质上就是把进程状态设置为SLEEPING,调用sched()让出CPU。wakeup()函数则是遍历所有进程,找到所有系在chan上的进程,将其状态有条件地设置为RUNNABLE,等待调度器调度运行
sleep_prepare
// Register current process as waiting for wakeups on chan.
void
sleep_prepare(void *chan)
{
struct proc *p = myproc();
acquire(&p->lock);
if (chan == 0)
panic("sleep_prepare: zero chan");
p->chan = chan;
release(&p->lock);
}sleep_prepare()做的事情非常简单,就是将chan记录到进程结构体中的p->chan字段中。函数只执行登记,没有进行睡眠,状态仍然是RUNNING
sleep
// Put the thread to sleep. Assumes sleep_prepare() was called before.
// If the channel registered by sleep_prepare() has been woken up in
// the meantime, do not go to sleep, and instead return immediately.
void
sleep(void)
{
struct proc *p = myproc();
acquire(&p->lock);
if (p->chan != 0) {
p->state = SLEEPING;
sched();
}
release(&p->lock);
}sleep()函数假设sleep_prepare()已经被调用过了。当检查到p->chan不为0时,说明进程需要睡眠,设置状态,之后调用sched()让出CPU
wakeup
void wakeup(void *chan) {
struct proc *p;
for (p = proc; p < &proc[NPROC]; p++) { // 暴力扫描整个进程表
acquire(&p->lock);
if (p->chan == chan) {
p->chan = 0; // 无条件清票据
if (p->state == SLEEPING) // 只有真睡下才改状态
p->state = RUNNABLE;
}
release(&p->lock);
}
}一次wakeup会命中通道上所有进程,即广播。它简单将对应SLEEPING进程的状态设置为RUNNABLE,使之可以被调度器调度运行,同时还将p->chan清零
防丢唤醒
int n = 0; // 共享:缓冲区里有几个数据
// 进程1
if (n == 0) { // 检查条件
sleep_prepare(chan); // 登记等待:p->chan = chan
// 此处可能被唤醒
sleep(); // 睡
}
// 进程2
n = 1; // 条件变真
wakeup(chan); // 通知简单的睡眠实现可能类似上面这样,进程1登记之后直接睡了,进程2唤醒进程1
这样会出现丢唤醒的问题,假如进程1的sleep_prepare和sleep之间的时间间隔内,进程2执行wakeup,如果sleep是无条件直接睡眠,那么在睡眠期间进程1永远也不会被唤醒。解决方法的重点在于sleep函数,当p->chan为0时直接返回,不进入睡眠状态
范式
XV6中通用的睡眠写法如下
// 睡觉侧
acquire(&条件锁);
while (条件不满足) { // 必须用循环!
sleep_prepare(chan); // 先登记
release(&条件锁); // 再放条件锁(自旋锁不能跨睡眠持有)
sleep();
acquire(&条件锁); // 醒来重拿
}
// 条件满足,操作资源
release(&条件锁);
// 唤醒侧
acquire(&条件锁);
修改条件;
wakeup(chan); // 必须持条件锁
release(&条件锁);要求是睡眠端和唤醒端需要能够持有同一把锁,使得一个时间端之内,只能有一个进程唤醒,或者一个进程切换自身状态进入睡眠
下面是几个注意点
- 睡眠侧在循环条件前先持有锁,之后登记睡眠通道,在
sleep之前释放锁。这是由于sleep函数中的sched函数要求不得持有p->lock以外的锁,以避免死锁。此外,唤醒侧也需要持有同样的锁,如果带锁睡觉唤醒侧就无法唤醒,永远死锁。 - 被唤醒之后再次尝试持有锁,然后再循环一遍检查条件。这是因为可能有多个进程在同一个通道上睡眠,唤醒之后只有一个进程能够持有锁,之后检查条件通过,跳出,再释放锁,其余进程再次获取锁,再检查新的条件,如果满足同前面跳出,不满足继续进行睡眠,使得进程一个一个地释放。本质原因在于
wakeup是广播唤醒,而资源可能只够一个或者部分进程使用 - 将睡眠侧条件判断与
sleep_prepare用锁包裹,使得判断条件与通道通道成为原子性操作,条件不通过的进程必定登记了通道。若将release(&条件锁)放在sleep_prepare之前,可能出现release之后,唤醒测直接wakeup,此时睡眠测还没有执行sleep_prepare,之后睡眠测继续执行sleep,就会出现丢唤醒问题 - 唤醒侧必须持有锁,保证了睡觉和唤醒两个动作互相不交错。若唤醒侧不持锁,那么睡眠侧的锁根本挡不住唤醒
sleep函数,当p->chan为0时直接返回,解决了sleep_prepare和sleep之间时间来唤醒时候的丢唤醒问题
管道
管道是上述睡眠机制的实施范例
结构
管道的核心是一个环形缓冲区与两个不回绕的递增读写计数器
#define PIPESIZE 512
struct pipe {
struct spinlock lock;
char data[PIPESIZE];
uint nread; // 累计读
uint nwrite; // 累计写
int readopen;
int writeopen;
};它拥有两个独立的等待通道
- 读:当缓冲区空时睡眠,由写者在写完时唤醒
- 写:当缓冲区满时睡眠,由读者在读走时唤醒
读写只允许一次一个进程进入,其余进程自旋等待。睡眠用于本身缓冲区空或者满的情况
pipewrite
acquire(&pi->lock);
while (i < n) {
if (pi->readopen == 0 || killed(pr)) { release(&pi->lock); return -1; }
if (pi->nwrite == pi->nread + PIPESIZE) { // 满
wakeup(&pi->nread); // 先叫醒读者来排水
sleep_prepare(&pi->nwrite);
release(&pi->lock);
sleep();
acquire(&pi->lock);
} else {
char ch;
if (copyin(pr->pagetable, pr->sz, &ch, addr + i, 1) == -1) { ... }
pi->data[pi->nwrite++ % PIPESIZE] = ch;
i++;
}
}
wakeup(&pi->nread); // 写完通知读者
release(&pi->lock);与上文论述一致,当缓冲区满时,写者不能再写,进入睡眠
当从睡眠中唤醒,重新拿到锁,进入else分支,此后一直持有锁,一次写入一个字节,不断循环,直到写完或者缓冲区又满了,两种情况都会唤醒读者,然后释放锁。保证了管道读写的互斥性
piperead
acquire(&pi->lock);
while (pi->nread == pi->nwrite && pi->writeopen) { // 空且写端还开
if (killed(pr)) { release(&pi->lock); return -1; }
sleep_prepare(&pi->nread);
release(&pi->lock);
sleep();
acquire(&pi->lock);
}
for (i = 0; i < n; i++) {
if (pi->nread == pi->nwrite) break;
ch = pi->data[pi->nread % PIPESIZE];
if (copyout(...) == -1) { ... }
pi->nread++;
}
wakeup(&pi->nwrite); // 读走通知写者
release(&pi->lock);piperead与pipewrite是对称关系
睡眠锁
struct sleeplock {
uint locked; // Is the lock held?
struct spinlock lk; // spinlock protecting this sleep lock
// For debugging:
char *name; // Name of lock.
int pid; // Process holding lock
};void acquiresleep(struct sleeplock *lk)
{
acquire(&lk->lk);
while (lk->locked) {
sleep_prepare(lk);
release(&lk->lk);
sleep();
acquire(&lk->lk);
}
lk->locked = 1;
lk->pid = myproc()->pid;
release(&lk->lk);
}void releasesleep(struct sleeplock *lk)
{
acquire(&lk->lk);
lk->locked = 0;
lk->pid = 0;
wakeup(lk);
release(&lk->lk);
}睡眠锁便是睡眠范例的直接应用,自旋锁便是睡眠锁的保护锁。原理与上文一致,这里不再次赘述
wait/exit/kill
wait:父进程等待子进程退出,收集子进程的退出状态exit:子进程退出,醒父进程,设置自己状态为ZOMBIEkill:杀死进程,设置标志位killed
wait
子进程退出后变成ZOMBIE,等父进程wait处理
全局自旋锁wait_lock保护父子关系
int kwait(uint64 addr)
{
acquire(&wait_lock);
for (;;) {
havekids = 0;
for (pp = proc; pp < &proc[NPROC]; pp++) {
if (pp->parent == p) {
acquire(&pp->lock);
havekids = 1;
if (pp->state == ZOMBIE) {
pid = pp->pid;
if (addr != 0) copyout(..., &pp->xstate, ...);
pp->parent = 0;
freeproc(pp);
release(&pp->lock); release(&wait_lock);
return pid; // 收一个就返回
}
release(&pp->lock);
}
}
if (!havekids || killed(p)) { release(&wait_lock); return -1; }
sleep_prepare(p); // 通道 = 父进程自己
release(&wait_lock);
sleep();
acquire(&wait_lock);
}
}kwait在持有wait_lock的情况下,遍历所有进程,找到所有子进程,如果没有子进程或者自己被杀死,直接退出
如果找到了一个状态为ZOMBIE的子进程,释放子进程再释放锁之后返回子进程pid退出
如果有子进程但是没有ZOMBIE,则登记睡眠通道为父进程自己,释放锁,进入睡眠。被唤醒之后重新获取锁,继续循环
一次kwait只会收一个子进程,之后返回。若父进程想收所有子进程,需要循环调用kwait
exit
void
kexit(int status)
{
// 前面是相关清理操作,比如关文件
acquire(&wait_lock);
reparent(p); // 孩子过继给 init,并 wakeup(initproc)
wakeup(p->parent); // 唤醒可能在 wait 中睡的父进程
acquire(&p->lock);
p->xstate = status;
p->state = ZOMBIE; // 必须在 p->lock 下设
release(&wait_lock);
sched(); // 永不返回
panic("zombie exit");
}kexit把自己的子进程托管给init进程,然后唤醒父进程,之后设置自己的状态
整个过程中都持有wait_lock锁,让父进程被叫醒后自旋等待状态被改好
kill
int kkill(int pid)
{
// 用循环找到对应进程,下面是循环体内部的
acquire(&p->lock);
if (p->pid == pid) {
p->killed = 1;
if (p->state == SLEEPING)
p->state = RUNNABLE; // 直接改状态,没有 wakeup
release(&p->lock);
return 0;
}
// 之后处理没找的,释放锁
}kill设置标志位killed为1,如果进程处于睡眠状态,直接将其状态改为RUNNABLE,使得进程下次被调度之后的某个时机被杀死
不使用wakeup的原因在于
kill不需要唤醒所有睡眠在同一通道的进程,只需要唤醒一个进程即可- 持
p->lock时调wakeup会再次acquire(&p->lock)同一把锁,死锁
kill是惰性的,kkill不负责杀死进程。进程下一次进入内核、准备返回用户态时被杀死。具体位于usertrap的出口
if (killed(p))
kexit(-1);调用kexit杀死进程