Kernel Korner - 2.6 内核中的新工作队列接口

作者:Robert Love

对于大多数 UNIX 系统(包括 Linux),设备驱动程序通常将处理中断的工作分为两个部分或两半。第一部分,上半部分,是熟悉的中断处理程序,内核响应来自硬件设备的中断信号时会调用它。不幸的是,中断处理程序名副其实:它会中断硬件设备发出中断时正在执行的任何代码。也就是说,中断处理程序(上半部分)相对于当前正在执行的代码异步运行。由于中断处理程序会中断已在执行的代码(无论是其他内核代码、用户空间进程,甚至是另一个中断处理程序),因此它们必须尽可能快地运行,这一点非常重要。

更糟糕的是,一些中断处理程序(在 Linux 中称为快速中断处理程序)在本地处理器上禁用所有中断的情况下运行。这样做是为了确保中断处理程序在不受中断的情况下尽可能快地运行。更重要的是,所有中断处理程序都在所有处理器上禁用其当前中断线的情况下运行。这确保了同一中断线的两个中断处理程序不会同时运行。这也避免了设备驱动程序编写者必须处理递归中断,这会使编程复杂化。但是,如果禁用了中断,则其他中断处理程序将无法运行。中断延迟(内核响应硬件设备的中断请求所需的时间)是系统性能的关键因素。再次强调,中断处理程序的速度至关重要。

为了方便编写小型且快速的中断处理程序,中断处理的第二部分或下半部分用于尽可能多地将工作从上半部分推迟到稍后的时间。下半部分在启用所有中断的情况下运行。因此,正在运行的下半部分不会阻止其他中断运行,也不会对中断延迟产生不利影响。

几乎每个设备驱动程序都以某种形式或另一种形式使用下半部分。设备驱动程序使用上半部分(中断处理程序)来响应硬件并执行任何必要的时间敏感操作,例如重置设备寄存器或将数据从设备复制到主存储器。然后,中断处理程序标记下半部分,指示内核尽快运行它,然后退出。

在大多数情况下,实际工作发生在下半部分。稍后——通常在中断处理程序返回后立即——内核执行下半部分。然后,下半部分运行,执行中断处理程序未完成的所有剩余工作。上半部分和下半部分之间的实际工作划分由设备驱动程序的作者决定。通常,设备驱动程序作者试图尽可能多地将工作推迟到下半部分。

令人困惑的是,Linux 提供了许多用于实现下半部分的机制。目前,2.6 内核提供了 softirqs、tasklets 和工作队列作为可用的下半部分类型。在以前的内核中,还有其他形式的下半部分可用;它们包括 BHs 和任务队列。本文仅讨论新的工作队列接口,该接口在 2.5 开发系列期间引入,以取代任务队列接口中运行不佳的 keventd 部分。

工作队列简介

工作队列之所以有趣,主要有两个原因。首先,它们是所有下半部分机制中最简单的。其次,它们是唯一在进程上下文中运行的下半部分机制;因此,当工作队列的下半部分必须休眠时,工作队列通常是设备驱动程序编写者拥有的唯一选择。此外,工作队列机制是全新的,而新的东西总是很酷的。

让我们讨论一下工作队列在进程上下文中运行的事实。这与其他所有在中断上下文中运行的下半部分机制形成对比。在中断上下文中运行的代码无法休眠或阻塞,因为中断上下文没有可用于重新调度的后备进程。因此,由于中断处理程序未与进程关联,因此调度程序无法使其进入休眠状态,更重要的是,调度程序无法将其唤醒。因此,中断上下文无法执行可能导致内核使当前上下文进入休眠状态的某些操作,例如 down 信号量、复制到或从用户空间内存或非原子地分配内存。由于工作队列在进程上下文中运行(它们由内核线程执行,我们稍后会看到),因此它们完全能够休眠。实际上,内核调度在工作队列中运行的下半部分,与系统上的任何其他进程相同。与任何其他内核线程一样,工作队列可以休眠、调用调度程序等等。

通常,一组默认的内核线程处理工作队列。每个处理器运行一个这些默认内核线程,这些线程被命名为 events/n,其中 n 是线程绑定到的处理器编号。例如,单处理器机器将只有一个 events/0 内核线程,而双处理器机器也将有一个 events/1 线程。

但是,可以在您自己的内核线程中运行工作队列。每当您的下半部分被激活时,您的唯一内核线程(而不是默认线程之一)会唤醒并处理它。只有在某些对性能至关重要的情况下,拥有唯一的工作队列线程才有用。对于大多数下半部分,使用默认线程是首选解决方案。尽管如此,我们稍后将介绍如何创建新的工作队列线程。

工作队列线程将您的下半部分作为特定的函数(称为工作队列处理程序)执行。工作队列处理程序是您实现下半部分的地方。使用工作队列接口很容易;唯一困难的部分是编写下半部分(即工作队列处理程序)。

工作队列接口

使用工作队列的第一步是创建工作队列结构。工作队列结构由 struct work_struct 表示,并在 linux/workqueue.h 中定义。值得庆幸的是,两个不同的宏之一可以轻松完成创建工作队列结构的工作。如果要静态创建工作队列结构(例如,作为全局变量),可以直接使用以下方法声明它:

DECLARE_WORK(name, function, data)

此宏创建一个 struct work_struct 并使用给定的工作队列处理程序 function 初始化它。您的工作队列处理程序必须匹配以下原型:

void my_workqueue_handler(void *arg)

arg 参数是一个指针,每次调用时内核都会将其传递给您的工作队列处理程序。它由 DECLARE_WORKQUEUE() 宏中的 data 参数指定。通过使用参数,设备驱动程序可以为多个工作队列使用单个工作队列处理程序。data 参数可用于区分工作队列。

如果您不想直接创建工作队列结构,而是想动态创建,也可以这样做。如果您只有对工作队列结构的间接引用,例如,因为您使用 kmalloc() 创建了它,则可以使用以下方法初始化它:

INIT_WORK(p, function, data)

在这种情况下,p 是指向 work_struct 结构的指针,function 是工作队列处理程序,data 是内核在调用时传递给它的唯一参数。

工作队列结构的创建通常只进行一次——例如,在驱动程序的初始化例程中。内核使用工作队列结构来跟踪系统上的各种工作队列。您需要跟踪该结构,因为稍后会用到它。

您的工作队列处理程序

基本上,您的工作队列处理程序可以执行您想要的任何操作。毕竟,它是您的下半部分。唯一的规定是处理程序的函数符合正确的原型。由于您的工作队列处理程序在进程上下文中运行,因此如果需要,它可以休眠。

因此,您有一个工作队列数据结构和一个工作队列处理程序——如何安排它运行?要将给定的工作队列处理程序排队以便在内核最早可能的方便时间运行,请调用以下函数,并将您的工作队列结构传递给它:

int schedule_work(struct work_struct *work)

如果工作成功排队,此函数返回非零值;如果出错,则返回零。该函数可以从进程上下文或中断上下文调用。

有时,您可能不希望计划的工作立即运行,而只希望在指定的延迟时间过后运行。在这些情况下,请使用:

int schedule_delayed_work(struct work_struct *work,
                          unsigned long delay)

在这种情况下,与给定工作队列结构关联的工作队列处理程序至少在 delay 个节拍内不会运行。例如,如果您有一个名为 my_work 的工作队列结构,并且希望将其执行延迟五秒钟,请调用:

schedule_delayed_work(&my_work, 5*HZ)

通常,您会从中断处理程序调度工作队列处理程序,但是没有任何东西阻止您从任何想要的位置调度它。在正常实践中,中断处理程序和下半部分作为一个团队协同工作。它们各自执行处理设备中断所涉及的特定份额的职责。中断处理程序作为解决方案的上半部分,通常为下半部分准备剩余的工作,然后调度下半部分运行。但是,您可能会将工作队列用于下半部分处理以外的其他作业。

工作队列管理

当您将工作排队时,它会在工作线程下次唤醒时执行。有时,您需要在内核代码中保证在继续之前已完成排队的工作。这对于模块尤其重要,模块需要确保在卸载之前已执行任何挂起的下半部分。为了满足这些需求,内核提供了一个函数来等待工作线程的所有挂起工作:

void flush_scheduled_work(void)

由于此函数等待工作线程的所有挂起工作,因此可能需要相对较长的时间才能完成。在等待工作线程完成执行所有挂起工作时,调用会休眠。因此,您必须仅从进程上下文中调用此函数。除非您确实需要确保已执行计划的工作并且不再挂起,否则不要调用它。

此函数不会刷新任何挂起的延迟工作。如果您使用延迟调度了工作,并且延迟尚未结束,则需要在刷新工作队列之前取消延迟:

int cancel_delayed_work(struct work_struct *work)

此外,此函数还会取消与给定工作队列结构关联的计时器——其他工作队列不受影响。您只能从进程上下文中调用 cancel_delayed_work(),因为它可能会休眠。如果有任何未完成的工作被取消,它将返回非零值;否则,它返回零。

创建新的工作线程

在极少数情况下,默认工作线程可能不足。值得庆幸的是,工作队列接口允许您创建自己的工作线程并使用这些线程来调度您的下半部分工作。要创建新的工作线程,请调用函数:

struct workqueue_struct *
create_workqueue(const char *name)

例如,在系统初始化时,内核使用以下命令创建默认队列:

keventd_wq = create_workqueue("events");

此函数创建所有每个处理器的工作线程。它返回指向 struct workqueue_struct 的指针,该指针用于将此工作队列与其他工作队列(例如默认工作队列)区分开。创建工作线程后,您可以像使用默认工作线程排队工作一样排队工作:

int queue_work(struct workqueue_struct *wq,
               struct work_struct *work)

此处,wq 是指向您使用 create_workqueue() 调用创建的特定工作队列的指针,而 work 是指向您的工作队列结构的指针。或者,您可以延迟调度工作:

int
queue_delayed_work(struct workqueue_struct *wq,
                   struct work_struct *work,
                   unsigned long delay)

此函数的工作方式与 queue_work() 相同,不同之处在于它将工作的排队延迟 delay 个节拍。这两个函数类似于 schedule_work() 和 schedule_delayed_work(),不同之处在于它们将给定的工作排队到给定的工作队列中,而不是默认的工作队列中。这两个函数在成功时都返回非零值,在失败时都返回零。这两个函数都可以从中断上下文和进程上下文调用。

最后,您可以使用以下函数刷新特定的工作队列:

void flush_workqueue(struct workqueue_struct *wq)

此函数等待 wq 工作队列上的所有排队工作完成后才返回。

结论

自 2.5.41 起,工作队列接口已成为内核的一部分。在那段时间里,大量的驱动程序和子系统已将其作为推迟工作的方法。但它是否是适合您的下半部分?如果您需要在进程上下文中运行下半部分,则工作队列是您的唯一选择。此外,如果您正在考虑创建内核线程,则工作队列可能是更好的选择。但是,如果您不需要可以休眠的下半部分呢?在这种情况下,您可能会发现 tasklets 是更好的选择。它们也易于使用,但它们不在内核线程中运行。由于它们不在进程上下文中运行,因此它们的执行没有相关的上下文切换开销;因此,它们可能会为您提供更少的开销。

工作队列函数参考

静态创建工作队列结构

DECLARE_WORK(name, function, data)

动态初始化工作队列结构

INIT_WORK(p, function, data)

创建新的工作线程

struct workqueue_struct
*create_workqueue(const char *name)

销毁工作线程

void
destroy_workqueue(struct workqueue_struct *wq)

将工作排队到给定的工作线程

int
queue_work(struct workqueue_struct *wq,
           struct work_struct *work)

在给定的延迟后,将工作排队到给定的工作线程

int
queue_delayed_work(struct workqueue_struct *wq,
                   struct work_struct *work,
                   unsigned long delay)

等待给定工作线程上的所有挂起工作

void
flush_workqueue(struct workqueue_struct *wq)

将工作调度到默认工作线程

int
schedule_work(struct work_struct *work)

在给定的延迟后,将工作调度到默认工作线程

int
schedule_delayed_work(struct work_struct *work,
                      unsigned long delay)

等待默认工作线程上的所有挂起工作

void
flush_scheduled_work(void)

取消给定的延迟工作

int
cancel_delayed_work(struct work_struct *work)

Robert Love 是一名内核黑客,参与各种项目。他是佛罗里达大学的数学和计算机科学专业的学生,也是 MontaVista Software 的内核工程师。可以通过 rml@tech9.net 联系他。

加载 Disqus 评论