Linux进程的睡眠和唤醒(一个定时信号唤醒睡眠中的进程)

时间:2021-01-09 14:54:45

        突然想到Nginx中时间更新这块处理,Nginx中为了减少调用系统调用gettimeofday这个函数(因为一旦调用了系统调用,就会使得进程从用户态切换到内核态,就会发生上下文切换,这个代价很大且不值得)而设置了系统时间更新的次数,内部时间更新有两种方式,一种就是在配置文件中设置更新的评论,另一种是没有设置更新频率,在后面这种情况下,可以使用epoll_wait()这个函数的最后一个参数来控制反复的时间间隔,在Nginx内部使用了红黑树来管理定时事件,每次从堆顶选择一个最近的定时事件最为epoll_wait函数的最后一个参数。但是第一种方式是如何处理,系统内部设定了一个定时器,定时事件的时间间隔就是系统配置文件中用户自己设定的事件间隔,每次到一定的时间间隔后,都会有一个定时时间发生,如果epoll_wait处于睡眠状态,那么此进程就会被唤醒。这么一说,一个进程如果处于睡眠状态,那么用户也是可以使用别的方式来唤醒一个睡眠中的进程了!

     我们再来回想一下前面有篇介绍EAGAIN和EINTER这两个错误的文章,EINTER这个错误时慢系统调用而触发的,如果慢系统调用睡眠了,这个时候有信号到达了,睡眠的进程被唤醒了,然后习惯性的查看信号队列,然后进程了用户的信号处理函数。这是正常的处理逻辑。所以在Nginx中使用一个定时器来更新时间是可靠的(定时器中的信号处理函数就是更新时间的。)

  接下来我们看一下内核是如何处理进程的睡眠和唤醒的。



休眠(被阻塞)的进程处于一个特殊的不可执行状态。进程休眠由多种原因,但肯定都是为了等待一些事件。事件可能是一段时间从文件I/O读取更多数据,或者是某个硬件事件。一个进程还由可能在尝试获取一个已被占用的内核信号量时*进入休眠。休眠的一个常见原因就是文件I/O —— 如进程对一个文件执行了read()操作,而这需要从磁盘里读取。还有,进程在获取键盘输入的时候也需要等待。无论哪种情况,内核的操作都相同:进程把自己标记成休眠状态,从可执行红黑树中移出,放入等待队列,然后调用schedule()选择和执行一个其他进程。唤醒的过程刚好相反:进程被设置为可执行状态,然后再从等待队列中移到可执行红黑树中。

休眠由两种相关的进程状态:TASK_INTERRUPTIBLE 和 TASK_UNINTERRUPTIBLE 。它们唯一的区别是处于 TASK_UNINTERRUPTIBLE 的进程会忽略信号,而处于 TASK_INTERRUPTIBLE 状态的进程如果接收到一个信号,会被提前唤醒并响应该信号。两种状态的进程位于同一个等待队列上,等待某些事件,不能够运行。

在Linux中,仅等待CPU时间的进程称为就绪进程,它们被放置在一个运行队列中,一个就绪进程的状 态标志位为TASK_RUNNING。一旦一个运行中的进程时间片用完, Linux 内核的调度器会剥夺这个进程对CPU的控制权,并且从运行队列中选择一个合适的进程投入运行。
当然,一个进程也可以主动释放CPU的控制权。函数 schedule()是一个调度函数,它可以被一个进程主动调用,从而调度其它进程占用CPU。一旦这个主动放弃CPU的进程被重新调度占用 CPU,那么它将从上次停止执行的位置开始执行,也就是说它将从调用schedule()的下一行代码处开始执行。
有时候,进程需要等待直到某个特定的事件发生,例如设备初始化完成、I/O 操作完成或定时器到时等。在这种情况下,进程则必须从运行队列移出,加入到一个等待队列中,这个时候进程就进入了睡眠状态。

等待队列

休眠通过等待队列进行处理。等待队列是由等待某些事件发生的进程组成的简单链表。内核用wake_queue_head_t来代表等待队列。等待队列可以通过 DECLARE_WAITQUEUE() 静态创建,也可以由 init_waitqueue_head() 动态创建。进程把自己放入等待队列中并设置成不可执行状态。当与等待队列相关的事件发生的时候,队列上的进程会被唤醒。为了避免产生竞争条件,休眠和唤醒的实现不能有纰漏。

针对休眠,以前曾经使用过一些简单的接口。但那些接口会带来竞争条件:有可能导致在判定条件变为真后,进程却开始了休眠,那样就会使进程无限期的休眠下去。所以,在内核中进行休眠的推荐操作就相对复杂些:

/* 'q' 是我们希望休眠的等待队列 */
DEFINE_WAIT(wait);

add_wait_queue(q, &wait);
while (!condition) { /* 'condition' 是我们在等待的事件 */
        prepare_to_wait(&q, &wait, TASK_INTERRUPTIBLE);
        if (signal_pending(current))
                /* 处理信号 */
        schedule();
}
finish_wait(&q, &wait);

进程通过执行下面几个步骤将自己加入到一个等待队列中:

  1. 调用宏 DEFINE_WAIT() 创建一个等待队列的项。
  2. 调用 add_wait_queue() 把自己加入到队列中(链表操作)。该队列会在进程等待的条件满足时唤醒它。当然我们必须在其他地方撰写相关代码,在事件发生时,对等待队列执行 wake_up() 操作。
  3. 调用 prepare_to_wait() 方法将进程的状态变更为 TASK_INTERRUPTIBLE 或 TASK_UNINTERRUPTIBLE 。而且该函数会在必要的情况下将进程加回到等待队列,这是在接下来的循环遍历中所需要的。
  4. 如果状态被设置为 TASK_INTERRUPTIBLE ,则信号唤醒进程。这就是所谓的伪唤醒(唤醒不是因为事件的发生),因此检查并处理信号。
  5. 当进程被唤醒的时候,它会再次检查条件是否为真。如果是,它就退出循环;如果不是,它再次调用 schedule() 并一直重复这步操作。
  6. 当条件满足后,进程将自己设置为 TASK_RUNNING 并调用 finish_wait() 方法把自己移出等待队列。

如果在进程开始休眠之前条件就已经达成,那么循环会退出,进程不会存在错误的进入休眠的倾向。需要注意的是,内核代码在循环体内常常需要完成一些其他任务,比如,它可能在调用 schedule() 之前需要释放掉锁,而在这以后重新获取它们,或者响应其他事件。

唤醒

唤醒操作通过函数 wake_up() 进行,它会唤醒指定的等待队列上的所有进程。它调用函数 try_to_wake_up() ,该函数负责将进程设置为 TASK_RUNNING 状态,调用 enqueue_task() 将此进程放入红黑树中,如果被唤醒的进程优先级比当前执行的进程优先级高,还要设置 need_resched 标志。通常哪段代码促使等待条件达成,它就要负责随后调用 wake_up() 函数 。举例来说,当磁盘数据到来时,VFS 就要负责对等待队列调用 wake_up() ,以便唤醒队列中等待这些数据的进程。

关于休眠有一点需要注意,存在虚假的唤醒(信号)。有时候进程被唤醒并不是因为它所等待的条件达成了,所以需要用一个循环处理来保证它等待的条件真正达成。图 4-1 描述了每个调度程序状态之间的关系。 Linux进程的睡眠和唤醒(一个定时信号唤醒睡眠中的进程)

需要注意内核处理进程的睡眠和唤醒是怎么处理的。同时睡眠的进程也是有区别的!!