那个司空见惯的sleep:
几乎所有编程语言都以库的形式提供了sleep函数,比如在c语言中
#include<unistd.h>
#include<stdio.h>
int main()
{
printf("before sleep\n");
sleep(5);
printf("after sleep\n");
return 0;
}
曾经以为的实现:
曾经的我,不了解操作系统的任务调度,以为sleep是这样实现的:
#include<stdio.h>
#include<time.h>
void my_sleep(int sec){
int t0 = time(0);
while(time(0)-t0 < sec){
}
return;
}
int main(){
printf("before sleep\n");
my_sleep(5);
printf("after sleep\n");
return 0;
}
即在一个循环中,不停的判断是否已经到期。这个实现最大的缺点,是白白浪费了cpu的资源。其实这种做法叫作“忙碌等待”,甚至在一些嵌入式软件中还真有使用到,因为嵌入式硬件配置低,无法跑操作系统,而且一般只需要跑一个任务。
那操作系统是如何实现sleep的呢?
一言以蔽之:当进程要求sleep时,操作系统将其挂起,并在定时中断中检查是否到期,如到期则将进程唤醒让其继续执行,由于操作系统在挂起、唤醒时,有进程的上下文切换,所以在进程看来它是连续执行的。
进程是怎么挂起的呢?
我们以类unix操作系统xv6的源码来进行分析,由于sleep需要挂起进程,所以它必然是一个系统调用,它的定义在syscall.c的sys_sleep函数中。
int
sys_sleep(void)
{
int n;
uint ticks0;
if(argint(0, &n) < 0)
return -1;
acquire(&tickslock);
ticks0 = ticks;
while(ticks - ticks0 < n){
if(myproc()->killed){
release(&tickslock);
return -1;
}
sleep(&ticks, &tickslock);
}
release(&tickslock);
return 0;
}
argint()函数获取到系统调用的参数:睡眠的滴答数。接着调sleep(至于那个while循环下面再说),其代码如下:
sleep(void *chan, struct spinlock *lk)
{
struct proc *p = myproc();
if(p == 0)
panic("sleep");
if(lk == 0)
panic("sleep without lk");
if(lk != &ptable.lock){
acquire(&ptable.lock);
release(lk);
}
p->chan = chan;
p->state = SLEEPING;
sched();
...
}
其核心代码在后3行,p->chan=chan用来记录进程阻塞的一个标识(因为进程阻塞的原因不只有sleep,还有可能是在等待某个设备上数据的到来),这里的chan是外层函数的ticks,其实是一个表示当前滴答数的全局变量(后面在每次的定时中断中,会遍历所有阻塞在ticks变量上的进程将其激活,这是xv6的一个简化做法,可以从后面的唤醒代码中看出)。
p->state=SLEEPING表示将当前进程状态设置为SLEEP状态,并马上触发一次进程调度sched(),sched()会选择一个状态为RUNNABLE的进程执行,这样当前进程就被停下来了。
那么到期了进程是怎么被唤醒的呢?
我们知道操作系统会定时跳出当前正在执行的代码,去执行一段代码,完成一些如任务调度的工作,这是通过cpu的中断实现的,xv6中的中断处理函数如下:
void trap(struct trapframe *tf){
if(tf->trapno == T_SYSCALL){
if(myproc()->killed)
exit();
myproc()->tf = tf;
syscall();
if(myproc()->killed)
exit();
return;
}
switch(tf->trapno){
case T_IRQ0 + IRQ_TIMER:
if(cpuid() == 0){
acquire(&tickslock);
ticks++;
wakeup(&ticks);
release(&tickslock);
}
lapiceoi();
......
}
......
}
上面的代码判断如果是定时器中断,则执行wakeup(&ticks)来唤醒sleep的进程,wakeup()会调用wakeup1(),其代码如下:
static void wakeup1(void *chan)
{
struct proc *p;
for(p = ptable.proc; p < &ptable.proc[NPROC]; p++)
if(p->state == SLEEPING && p->chan == chan)
p->state = RUNNABLE;
}
上面代码遍历所有进程,将p->chan为ticks的进程唤醒(将进程的state设置为RUNNABLE),当下一次调度时,它就可能会被选中执行(注意xv6唤醒了所有的sleep进程,下面会说)。
操作系统是如何判断sleep的时间到了呢?
xv6的处理略显简陋,在定时中断中将所有sleep的进程都唤醒,然后返回其系统调用代码,系统调用代码判断如果没有到期的话,重新将进程睡眠:
while(ticks - ticks0 < n){
if(myproc()->killed){
release(&tickslock);
return -1;
}
sleep(&ticks, &tickslock);
}
而且他使用的并不是绝对时间(如秒),而是简单的使用了滴答数ticks(tick是系统每中断一次就递增的一个数值,ticks可以转换为绝对时间秒)。在现实中的操作系统(如linux)中,操作系统会在定时中断中就选中哪此进程的sleep到期了(甚至使用红黑树来优化效率),指定的唤醒它们,这样效率会更高。
真的该用sleep吗?
sleep的优点是:他的语义简单明确,能够以同步的思维进行编程。他的缺点也很明显,甚至有时被滥用:比如在等待某个条件时,就必须要设定一个轮询时间,如果设置得太短则cpu空转,如设置得太长则不能及时的响应,正确的作法是使用事件模型,或者使用生产者-消费者模型,将处理任务阻塞在事件等待上,但这样编程会较复杂。
这里有一个关于sleep有害的讨论:language agnostic - Is Sleep() evil? - Stack Overflow