《刨根问底系列》:sleep到底是怎么让程序“睡眠”的?

那个司空见惯的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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值