进程是操作系统中的 执行上下文
。说白了,执行上下文
包括了要执行的代码与其相关的资源。要执行的代码比较容易理解,就是我们编写的程序代码,例如:
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 的利用率达到最优。当进程等待的资源变为可用时,内核主动唤醒等待中的进程,进程便可以继续运行。