内核角落 - 内核中的睡眠

作者:Kedar Sovani

在 Linux 内核编程中,有许多场合需要进程等待某些事件发生,或者需要唤醒睡眠进程以完成某些工作。有不同的方法来实现这些目标。

本文中的所有讨论均指内核模式执行。提到进程意味着在该进程上下文中在内核空间中执行。

一些内核代码示例已重新格式化以适应此打印格式。行号指的是原始文件中的行数。

schedule() 函数

在 Linux 中,就绪运行的进程维护在运行队列中。就绪运行的进程具有 TASK_RUNNING 状态。一旦运行进程的时间片用完,Linux 调度器将从运行队列中选择另一个合适的进程,并将 CPU 能力分配给该进程。

进程也可以自愿放弃 CPU。进程可以使用 schedule() 函数向调度器自愿指示它可以调度处理器上的其他进程。

一旦进程再次被调度回来,执行将从进程停止的点开始——即,执行从调用 schedule() 函数的位置开始。

有时,进程希望等待直到某个事件发生,例如设备初始化、I/O 完成或定时器到期。在这种情况下,称该进程在该事件上睡眠。进程可以使用 schedule() 函数进入睡眠状态。以下代码使执行进程进入睡眠状态

sleeping_task = current;
set_current_state(TASK_INTERRUPTIBLE);
schedule();
func1();
/* The rest of the code */

现在,让我们看看其中发生了什么。在第一个语句中,我们存储了对此进程任务结构的引用。current,它实际上是一个宏,给出了指向执行进程的 task_structure 的指针。set_current_state 将当前执行进程的状态从 TASK_RUNNING 更改为 TASK_INTERRUPTIBLE。在这种情况下,如上所述,schedule() 函数应该简单地调度另一个进程。但这只有在任务的状态为 TASK_RUNNING 时才会发生。当调用 schedule() 函数且状态为 TASK_INTERRUPTIBLE 或 TASK_UNINTERRUPTIBLE 时,会执行一个额外的步骤:当前执行的进程在调度另一个进程之前从运行队列中移除。这样做的效果是执行进程进入睡眠状态,因为它不再在运行队列中。因此,它永远不会被调度器调度。这就是进程如何进入睡眠状态的。

现在让我们唤醒它。给定一个任务结构的引用,可以通过调用以下函数来唤醒进程

wake_up_process(sleeping_task);

您可能已经猜到,这会将任务状态设置为 TASK_RUNNING,并将任务放回运行队列中。当然,进程只有在调度器下次查看它时才会运行。

所以现在您知道了内核中最简单的睡眠和唤醒方式。

可中断和不可中断的睡眠

进程可以以两种不同的模式睡眠:可中断和不可中断。在可中断睡眠中,进程可以被唤醒以处理信号。在不可中断睡眠中,除了发出显式的 wake_up 之外,进程无法被唤醒。可中断睡眠是首选的睡眠方式,除非在某些情况下完全无法处理信号,例如设备 I/O。

丢失唤醒问题

几乎总是,进程在检查某些条件后进入睡眠状态。丢失唤醒问题源于进程进入条件睡眠时发生的竞争条件。这是操作系统中的一个经典问题。

考虑两个进程,A 和 B。进程 A 从列表中处理数据(消费者),而进程 B 向此列表添加数据(生产者)。当列表为空时,进程 A 进入睡眠状态。当进程 B 向列表添加任何内容时,它会唤醒进程 A。代码如下所示

Process A:
1  spin_lock(&list_lock);
2  if(list_empty(&list_head)) {
3      spin_unlock(&list_lock);
4      set_current_state(TASK_INTERRUPTIBLE);
5      schedule();
6      spin_lock(&list_lock);
7  }
8
9  /* Rest of the code ... */
10 spin_unlock(&list_lock);

Process B:
100  spin_lock(&list_lock);
101  list_add_tail(&list_head, new_node);
102  spin_unlock(&list_lock);
103  wake_up_process(processa_task);

这种情况存在一个问题。可能会发生这种情况:在进程 A 执行第 3 行之后但在执行第 4 行之前,进程 B 在另一个处理器上被调度。在这个时间片中,进程 B 执行其所有指令,从 100 到 103。因此,它对进程 A 执行唤醒操作,而进程 A 尚未进入睡眠状态。现在,进程 A 错误地假设它已安全地执行了 list_empty 的检查,将状态设置为 TASK_INTERRUPTIBLE 并进入睡眠状态。

因此,来自进程 B 的唤醒丢失了。这被称为丢失唤醒问题。进程 A 进入睡眠状态,即使列表上还有节点可用。

可以通过以下方式重构进程 A 的代码来避免此问题

Process A:

1  set_current_state(TASK_INTERRUPTIBLE);
2  spin_lock(&list_lock);
3  if(list_empty(&list_head)) {
4         spin_unlock(&list_lock);
5         schedule();
6         spin_lock(&list_lock);
7  }
8  set_current_state(TASK_RUNNING);
9
10 /* Rest of the code ... */
11 spin_unlock(&list_lock);

这段代码避免了丢失唤醒问题。为什么?我们在测试条件之前,已将当前状态更改为 TASK_INTERRUPTIBLE。那么,发生了什么变化?变化是,每当为状态为 TASK_INTERRUPTIBLE 或 TASK_UNINTERRUPTIBLE 的进程调用 wake_up_process,并且该进程尚未调用 schedule() 时,该进程的状态会自动更改回 TASK_RUNNING。

因此,在上面的示例中,即使进程 B 在进行 list_empty 检查后的任何时刻传递了唤醒信号,进程 A 的状态也会自动更改为 TASK_RUNNING。因此,调用 schedule() 不会将进程 A 置于睡眠状态;它只是像前面讨论的那样暂时将其调度出去。因此,唤醒不再丢失。

这是一个来自 Linux 内核的真实示例代码片段 (linux-2.6.11/kernel/sched.c: 4254)

4253  /* Wait for kthread_stop */
4254  set_current_state(TASK_INTERRUPTIBLE);
4255  while (!kthread_should_stop()) {
4256          schedule();
4257          set_current_state(TASK_INTERRUPTIBLE);
4258  }
4259  __set_current_state(TASK_RUNNING);
4260 return 0;

此代码属于 migration_thread。在 kthread_should_stop() 函数返回 1 之前,线程无法退出。线程在等待函数返回 0 时进入睡眠状态。

从代码中可以看出,只有在状态为 TASK_INTERRUPTIBLE 之后才检查 kthread_should_stop 条件。因此,在条件检查之后但在调用 schedule() 函数之前收到的唤醒信号不会丢失。

等待队列

等待队列是一种更高级别的机制,用于使进程进入睡眠状态并唤醒它们。在大多数情况下,您都使用等待队列。当多个进程想要在发生一个或多个事件时进入睡眠状态时,就需要它们。

事件的等待队列是节点列表。每个节点指向一个等待该事件的进程。此列表中的单个节点称为等待队列条目。想要在事件发生时进入睡眠状态的进程在进入睡眠状态之前将自己添加到此列表中。在事件发生时,列表中的一个或多个进程被唤醒。唤醒后,进程从列表中移除自身。

可以按以下方式定义和初始化等待队列

wait_queue_head_t my_event;
init_waitqueue_head(&my_event);

通过使用此宏可以达到相同的效果

DECLARE_WAIT_QUEUE_HEAD(my_event);

任何想要等待 my_event 的进程都可以使用以下任一选项

  1. wait_event(&my_event, (event_present == 1) );

  2. wait_event_interruptible(&my_event, (event_present == 1) );

上面的选项 2 的可中断版本使进程进入可中断睡眠,而另一个版本(选项 1)使进程进入不可中断睡眠。

在大多数情况下,进程仅在检查资源是否可用的某些条件后才进入睡眠状态。为了方便这一点,这两个函数都将一个表达式作为第二个参数。仅当表达式的计算结果为 false 时,进程才进入睡眠状态。已采取措施避免丢失唤醒问题。

旧内核版本使用函数 sleep_on() 和 interruptible_sleep_on(),但这​​两个函数可能会引入不良的竞争条件,因此不应使用。

现在让我们看一下一些用于唤醒在等待队列上睡眠的进程的调用

  1. wake_up(&my_event);:仅从等待队列中唤醒一个进程。

  2. wake_up_all(&my_event);:唤醒等待队列上的所有进程。

  3. wake_up_interruptible(&my_event);:仅从等待队列中唤醒一个处于可中断睡眠状态的进程。

等待队列:整合在一起

让我们看一个等待队列如何在实际中使用的示例。smbiod 是为 SMB 文件系统执行 I/O 操作的 I/O 线程。以下是 smbiod 线程的代码片段 (linux-2.6.11/fs/smbfs/smbiod.c: 291)

291 static int smbiod(void *unused)
292 {
293     daemonize("smbiod");
294
295     allow_signal(SIGKILL);
296
297     VERBOSE("SMB Kernel thread starting "
                "(%d)...\n", current->pid);
298
299     for (;;) {
300             struct smb_sb_info *server;
301             struct list_head *pos, *n;
302
303             /* FIXME: Use poll? */
304             wait_event_interruptible(smbiod_wait,
305                     test_bit(SMBIOD_DATA_READY,
                                 &smbiod_flags));
...
...             /* Some processing */
312
313             clear_bit(SMBIOD_DATA_READY,
                          &smbiod_flags);
314
...             /* Code to perform the requested I/O */
...
...
337     }
338
339     VERBOSE("SMB Kernel thread exiting (%d)...\n",
                current->pid);
340     module_put_and_exit(0);
341 }
342

从代码中可以清楚地看出,smbiod 是一个在连续循环中运行以处理 I/O 请求的线程。当没有 I/O 请求要处理时,该线程在等待队列 smbiod_wait 上进入睡眠状态。这是通过调用 wait_event_interruptible(第 304 行)实现的。只有在设置了 DATA_READY 位时,此调用才会导致 smbiod 进入睡眠状态。如前所述,wait_event_interruptible 注意避免丢失唤醒问题。

现在,当进程想要完成某些 I/O 操作时,它会设置 smbiod_flags 中的 DATA_READY 位,并唤醒 smbiod 线程以执行 I/O。这可以在以下代码片段中看到 (linux-2.6.11/fs/smbfs/smbiod.c: 57)

57 void smbiod_wake_up(void)
58 {
59     if (smbiod_state == SMBIOD_DEAD)
60         return;
61     set_bit(SMBIOD_DATA_READY, &smbiod_flags);
62     wake_up_interruptible(&smbiod_wait);
63 }

wake_up_interruptible 唤醒一个在 smbiod_wait 等待队列上睡眠的进程。当 smb_add_request (linux-2.6.11/fs/smbfs/request.c: 279) 添加新的处理请求时,它会调用 smbiod_wake_up 函数。

惊群问题

由于使用了 wake_up_all 函数,因此出现了另一个经典的操作系统问题。让我们考虑这样一种情况:一组进程在等待队列上睡眠,想要获取锁。

一旦获取到锁的进程完成操作,它就会释放锁并唤醒等待队列上所有睡眠的进程。所有进程都尝试获取锁。最终,只有其中一个获取到锁,其余的进程又回到睡眠状态。

这种行为对性能不利。如果我们已经知道只有一个进程将恢复,而其余进程将再次回到睡眠状态,那么为什么要首先唤醒它们呢?它消耗了宝贵的 CPU 周期并导致上下文切换开销。这个问题称为惊群问题。这就是为什么应该谨慎使用 wake_up_all 函数的原因,只有当您知道需要它时才使用。否则,请继续使用 wake_up 函数,该函数一次仅唤醒一个进程。

那么,何时应该使用 wake_up_all 函数呢?它用于进程想要对某些内容采用共享锁的情况。例如,等待读取页面数据的进程可以同时被唤醒。

定时睡眠

您可能经常需要延迟进程的执行指定的时间量。可能需要让硬件赶上进度,或者在指定的时间间隔后执行活动,例如轮询设备、将数据刷新到磁盘或重新传输网络请求。这可以通过 schedule_timeout(timeout) 函数来实现,它是 schedule() 的变体。此函数使进程进入睡眠状态,直到 timeout 个节拍过去。节拍是一个内核变量,每次定时器中断都会递增。

与 schedule() 一样,在调用此函数之前,进程的状态必须更改为 TASK_INTERRUPTIBLE/TASK_UNINTERRUPTIBLE。如果进程在 timeout 个节拍过去之前被唤醒,则返回剩余的节拍数;否则,返回零。

让我们看一个真实的例子 (linux-2.6.11/arch/i386/kernel/apm.c: 1415)

1415  set_current_state(TASK_INTERRUPTIBLE);
1416  for (;;) {
1417     schedule_timeout(APM_CHECK_TIMEOUT);
1418     if (exit_kapmd)
1419         break;
1421      * Ok, check all events, check for idle
....      * (and mark us sleeping so as not to
....      * count towards the load average)..
1423      */
1424      set_current_state(TASK_INTERRUPTIBLE);
1425      apm_event_handler();
1426  }

此代码属于 APM 线程。该线程以 APM_CHECK_TIMEOUT 节拍的间隔轮询 APM BIOS 以获取事件。从代码中可以看出,线程调用 schedule_timeout() 以睡眠给定的时间长度,之后它调用 apm_event_handler() 来处理任何事件。

您也可以使用更方便的 API,您可以使用它以毫秒和秒为单位指定时间

  1. msleep(time_in_msec);

  2. msleep_interruptible(time_in_msec);

  3. ssleep(time_in_sec);

msleep(time_in_msec); 和 msleep_interruptible(time_in_msec); 接受以毫秒为单位的睡眠时间,而 ssleep(time_in_sec); 接受以秒为单位的睡眠时间。这些更高级别的例程在内部将时间转换为节拍,适当地更改进程的状态并调用 schedule_timeout(),从而使进程进入睡眠状态。

我希望您现在对进程如何在内核中安全地睡眠和唤醒有一个基本的了解。要了解等待队列的内部工作原理和高级用法,请查看 init_waitqueue_head 以及 wait_event 和 wake_up 变体的实现。

致谢

Greg Kroah-Hartman 审阅了本文的草稿并提出了宝贵的建议。

Kedar Sovani (www.geocities.com/kedarsovani) 在 Kernel Corporation 担任内核开发人员。他的兴趣领域包括安全、文件系统和分布式系统。

加载 Disqus 评论