细说进程为什么需要睡眠?

科技   2024-10-11 09:02   浙江  

进程是操作系统中的 执行上下文。说白了,执行上下文 包括了要执行的代码与其相关的资源。要执行的代码比较容易理解,就是我们编写的程序代码,例如:

int total = 10 + 20;

上面的代码将 10 加 20 的结果赋值给 total 变量,这就是进程要执行的代码。

而进程相关的资源包括:使用的内存打开的文件使用的CPU时间 等等。执行的代码与其相关的资源组成了 执行上下文,也称为 进程。如果将人比作为进程的话,那么我们的日常行为对应的就是执行代码,而我们拥有的各种社会资源(如金钱、房产、车子等)对应的就是进程占用的资源。

进程为什么需要睡眠

由于 CPU 是执行代码的主体,所以执行进程的代码需要占用 CPU 时间。但有时候进程的执行需要某些资源提供数据来源,而这些资源可能需要从外部获取。如在网络程序中,服务器需要等待客户端发生请求才能进行下一步操作。但服务器什么时候接收到客户端的请求是不确定的,有可能一整天也没有收到客户端的请求。

服务端程序可以通过两种方式等待客户端的请求:忙等待 和 睡眠与通知

1. 忙等待

忙等待 是指通过死循环来不断检测数据是否准备好,如下面伪代码:

for (;;) {
    if (如果数据准备好) {
        读取数据;
        break;
    }
}

从上面代码可以看出,忙等待需要消耗非常多的 CPU 时间来检测数据是否准备好。

举个日常生活中 忙等待 的例子,例如我们在外面吃饭,当餐厅生意非常好的时候,我们可能需要等位。在等位的过程中,我们需要通过不断询问服务员来了解空位的状况。

2. 睡眠与唤醒

可以看出,忙等待是一个非常低效和浪费时间的方式。那么有没有更高效的方式呢?答案是肯定的。

我们还是以在餐厅等位作为例子,如果餐厅提供一个通知客人的设备,当有空位时,服务员可以通过这个设备通知等待中的客人。那么,客人就不需要不断去询问服务员空位的状况,可以利用等待这段时间小瞌一会。

类似的,服务端程序在等待客户端请求到来这段时间,操作系统可以先把服务端进程挂起(睡眠),然后执行其他可运行的进程。当有客户端请求到来时,操作系统唤醒服务端进程来处理客户端的请求。

如下图所示:

当 进程M 在等待系统中某些资源变为就绪状态时,操作系统会把 进程M 的从 CPU 中切换出去,然后把 进程M 防止到睡眠队列中。接着操作系统会从可运行队列中,选择一个最合适的进程(如图中的 进程1)调度到 CPU 中运行。

当 进程M 等待的资源变为就绪状态后,操作系统操作系统便会把 进程M 放置回可运行队列中。这样,进程M 就可以在下一个调度周期中争夺 CPU 的运行时间。如下图所示:

在上图中,当客户端请求到来后,操作系统便会唤醒等待客户端请求的 进程M,然后把其放回到可运行队列中。

这个就是操作系统中的睡眠与唤醒机制。

Linux 的睡眠与唤醒机制实现

在 Linux 内核中,很多系统调用和内核函数都可能会导致进程睡眠,如 I/O 相关的系统调用、sleep 类内核函数、内存分配函数等。

下面我们以 sleep 类内核函数来分析 Linux 是如何实现睡眠与唤醒机制的。

1. 睡眠函数的使用

在 Linux 内核中,如果想让一个进程进入睡眠状态,可以调用 schedule_timeout_interruptible() 内核函数。其原型如下:

signed long __sched schedule_timeout_interruptible(signed long timeout);

参数 timeout 表示希望进程睡眠多长时间,此函数会让进程睡眠 timeout 个时钟节拍(tick)的时间。例如,如果希望进程睡眠 1 秒,可以使用如下代码实现:

...
schedule_timeout_interruptible(1 * HZ);
// 1秒后进程被唤醒,继续执行下面代码
...

2. 睡眠函数的实现

接下来,我们来分析一下 schedule_timeout_interruptible() 内核函数是如何让进程进入睡眠状态的。其代码如下所示:

signed long __sched schedule_timeout_interruptible(signed long timeout)
{
    __set_current_state(TASK_INTERRUPTIBLE);
    return schedule_timeout(timeout);
}

schedule_timeout_interruptible() 内核函数主要做了如下两件事情:

  • 调用 __set_current_state() 函数将进程设置为可 中断睡眠状态。需要注意的是,这个步骤只是将进程的状态设置为可中断睡眠状态,但此时进程还没有被内核调度程序移出 CPU。
  • 调用 schedule_timeout() 函数使进程真正进入睡眠状态(放弃 CPU 的使用权限)。

从上面代码可以看出,schedule_timeout() 函数才是使进程进入睡眠的主体。那么,我们继续来分析schedule_timeout() 函数的实现:

signed long __sched schedule_timeout(signed long timeout)
{
    struct timer_list timer;
    unsigned long expire;

    ...
    expire = timeout + jiffies;

    // 1. 将当前进程添加到定时器中,定时器的超时时间为expire,回调函数为process_timeout
    setup_timer_on_stack(&timer, process_timeout, (unsigned long)current);
    __mod_timer(&timer, expire, false, TIMER_NOT_PINNED);

    // 2. 主动触发内核进行进程调度
    schedule();

    // 3. 将进程从定时器中删除
    del_singleshot_timer_sync(&timer);
    destroy_timer_on_stack(&timer);

    timeout = expire - jiffies;

 out:
    return timeout < 0 ? 0 : timeout;
}

schedule_timeout() 函数的逻辑主要分为以下三个步骤:

  • 将当前进程添加到定时器中,定时器的超时时间设置为 expire,回调函数为 process_timeout()。那么当定时器超时时,便会触发调用 process_timeout() 函数。
  • 调用 schedule() 函数触发内核进行进程调度。由于当前进程在 schedule_timeout_interruptible() 函数中被设置为 可中断睡眠状态,所以当调度器发现当前进程是 可中断睡眠状态 时,将会把当前进程移出可运行队列,并且让出 CPU 的使用权限。
  • 当进程被唤醒后,将会把进程从定时器中删除。

从上面的分析可知,在调用 schedule_timeout() 函数时,内核会为当前进程创建一个定时器,其超时时间被设置为 schedule_timeout() 函数传入的参数加上当前时间。当定时器到期后,便会触发调用 process_timeout() 函数,而 process_timeout() 函数最终会调用 try_to_wake_up() 函数来唤醒进程。

我们接着来分析下 try_to_wake_up() 函数的实现,看看其如何唤醒进程的:

static int
try_to_wake_up(struct task_struct *p, unsigned int state, int wake_flags)
{
    unsigned long flags;
    int cpu, success = 0;

    ...
    // 1. 为进程挑选一个最合适的 CPU 运行
    cpu = select_task_rq(p, p->wake_cpu, SD_BALANCE_WAKE, wake_flags);
    ...
    // 2. 把进程添加到 CPU 的可运行队列中
    ttwu_queue(p, cpu);
    ...
    return success;
}

在上面的代码中,我们只保留了核心的代码。可以看出 try_to_wake_up() 函数主要完成 2 件事情:

  • 调用 select_task_rq() 函数为进程挑选一个最合适的 CPU 运行。
  • 调用 ttwu_queue() 函数把进程添加到 CPU 的可运行队列中。

被唤醒的进程添加到 CPU 的可运行队列后,并不会立即被执行。内核会在下一个调度周期中,选择合适的进程进行调度时,被唤醒的进程才有可能被选中运行。

总结

本文主要介绍了进程为什么需要有睡眠和唤醒功能,并且分析了进程睡眠与唤醒的实现原理。进程睡眠与唤醒功能主要为了解决进程在等待某些资源变为可用时,需要不断探测资源的状态。这种探测既白白浪费了宝贵的 CPU 时间,而且还影响了系统的吞吐量。

而进程睡眠可以在进程等待资源变为可用状态时,主动放弃 CPU 的使用权限,这时 CPU 便可运行其他可运行的进程,从而使 CPU 的利用率达到最优。当进程等待的资源变为可用时,内核主动唤醒等待中的进程,进程便可以继续运行。


推荐阅读  点击标题可跳转

1、inline,一个被聊烂了的特性

2、一个进程最多可以创建多少个线程?

3、Linux 中的各种栈:进程栈 线程栈 内核栈 中断栈

CPP开发者
我们在 Github 维护着 9000+ star 的C语言/C++开发资源。日常分享 C语言 和 C++ 开发相关技术文章,每篇文章都经过精心筛选,一篇文章讲透一个知识点,让读者读有所获~
 最新文章