中断下半部和推后执行的工作

在第7章中,我们讨论了内核为处理中断而提供的中断处理程序机制。中断处理程序是内核中很有用的部分。但是,由于本身存在一些局限,所以它只能完成整个中断处理流程的上半部分。这些局限包括:

  1. 中断处理程序以异步方式执行,并且它有可能打断其他重要代码(甚至包括其他中断处理程序)的执行。因此,为了避免被打断的代码停止时间过长,中断执行程序应该执行得越快越好。
  2. 如果当前有一个中断处理程序正在执行,在最好的情况下(如果IRQF_DISABLED没有被设置),与该中断同级的其他中断会被屏蔽,在最坏的情况下(如果IRQF_DISABLED有被设置),当前处理器上所有其他中断都会被屏蔽。因为禁止中断后硬件与操作系统无法通信,因此,中断处理程序执行得越快越好。
  3. 由于中断处理函数往往需要对硬件进行操作,所以它们通常有很高的时限要求。
  4. 中断处理程序不在进程上下文中运行,所以它们不能阻塞。这限制了它们所做得事情。

现在,为什么中断处理程序只能作为整个硬件中断处理流程一部分的原因就很明显了。操作系统必须有一个快速、异步、简单的机制负责对硬件做出迅速响应并完成那些时间要求很严格的操作。中断处理程序很适合于实现这些功能,可是,对于那些其他的、对时间要求相对宽松的任务,就应该推后到中断被激活以后再去运行。
这样,整个中断处理流程就被分为了两个部分,或叫两半。第一个部分是中断处理程序(上半部),就像我们在第7章讨论的那样,内核通过对它的异步执行完成对硬件中断的即时响应。在本章中,我们要研究的是中断处理流程中的另外那一部分,下半部(bottom halves)。

下半部

下半部的工作就是执行与中断处理密切相关但中断处理程序本身不执行的工作。在理想情况下,最好是中断处理程序将所有工作都交给下半部分执行,因为我们希望在中断处理程序中完成的工作越少越好(也就是越快越好)。我们期望中断处理程序能够尽可能快的返回。
但是,中断处理程序注定要完成一部分工作。例如,中断处理程序几乎都需要通过操作硬件对中断的到达进行确认,有时它还会从硬件拷贝数据。因为这些工作对时间非常敏感,所以只能靠中断处理程序自己去完成。
剩下的几乎所有其他工作都是下半部执行的目标。例如,如果你在上半部中把数据从硬件拷贝到内存,那么当然应该在下半部中处理它们。遗憾的是,并不存在严格明确的规定来说明到底什么任务应该在哪个部分中完成–如何做决定完全取决于驱动程序开发者自己的判断。尽管在理论上不存在什么错误,但是轻率的实现效果往往不很理想。记住,中断处理程序会异步执行,并且在最好的情况下它也会锁定当前的中断线。因此将中断处理程序持续执行的时间缩短到最小程度显得非常重要。对于在上半部和下半部之间划分工作,尽管不存在某种严格的规则,但还是有一些提示可供借鉴:

  1. 如果一个任务对时间非常敏感,将其放在中断处理程序中执行。
  2. 如果一个任务和硬件相关,将其放在中断处理程序中执行。
  3. 如果一个任务要保证不被其他中断(特别是相同的中断)打断,将其放在中断处理程序中执行。
  4. 其他所有任务,考虑放置在下半部执行。

当你开始尝试写自己的驱动程序的时候,读一下别人的中断处理程序和相应的下半部可能会让你受益匪浅。在决定怎样把你的中断处理流程中的工作划分到上半部和下半部中去的时候,问问自己什么必须放进上半部而什么可以放进下半部。通常,中断处理程序要执行得越快越好。

为什么要用下半部

理解为什么要让工作推后执行以及在什么时候推后执行非常关键。你希望尽量减少中断处理程序中需要完成的工作量,因为它在运行的时候,当前的中断线在所有处理器上都会被屏蔽。更糟糕的是,如果一个处理程序是IRQF_DISABLED类型,它执行的时候会禁止所有本地中断(而且把本地中断线全局的屏蔽掉)。而缩短中断被屏蔽的时间对系统的响应能力和性能都至关重要。再加上中断处理程序要与其他程序(甚至是其他的中断处理程序)异步执行,所以很明显,我们必须尽力缩短中断处理程序的执行。解决的办法就是把一些工作放到以后去做。
但具体放到以后什么时候去做呢?在这里,以后仅仅用来强调不是马上而已,理解这一点相当重要,下半部并不需要指明一个确切时间,只要把这些任务推迟一点,让它们在系统不太繁忙的并且中断恢复后执行就可以了。通常下半部在中断处理程序一返回就会马上运行。下半部执行的关键在于当它们运行的时候,允许响应所有的中断。
不仅仅是Linux,许多操作系统也把处理硬件中断的过程分为两部分。上半部分简单快速,执行的时候禁止一些或全部中断。下半部分稍后执行,而且执行期间可以响应所有的中断。这种设计可使系统处于中断屏蔽状态的时间尽可能的短,以此来提高系统的响应能力。

下半部的环境

和上半部只能通过中断处理程序实现不同,下半部可以通过多种机制实现。这些用来实现下半部的机制分别由不同的接口和子系统组成。在第7章中,我们了解到实现中断处理程序的方法只有一种,但在本章中你会发现,实现一个下半部会有许多不同的方法。实际上,在Linux发展的过程中曾经出现过多种下半部机制。让人备受困扰的是,其中不少机制名字起得很相像,甚至还有一些机制名字词不达意。这就需要专门的程序员来给下半部命名。
在本章中,我们将要讨论2.6版本的内核中的下半部机制是如何设计和实现的。同时我们也会讨论怎么在自己编写的内核代码中使用它们。而那些过去使用的、已经废除了有一段时间的机制,由于曾经闻名遐迩,所以在相关的时候我们还会有所提及。

下半部的起源

最早的Linux只提供“bottom half”这种机制用于实现下半部。这个名字在那时毫无异义,因为当时它是将工作推后的唯一方法。这种机制也被称为“BH”,我们现在也这么叫它,以避免和“下半部”这个通用词汇混淆。像过往的那段美好岁月中的许多东西一样,BH接口也非常简单。它提供了一个静态创建、由32个bottom havles组成的链表。上半部通过一个32位整数中的一位来标识出哪个bottom half可以执行。每个BH都在全局范围内进行同步。即使分属于不同的处理器,也不允许任何两个bottom half同时执行。这种机制使用方便却不够灵活,简单却有性能瓶颈。

任务队列

不久,内核开发者们就引入了任务队列(task queue)机制来实现工作的推后执行,并用它来代替BH机制。内核为此定义了一组队列,其中每个队列都包含一个由等待调用的函数组成链表。根据其所处队列的位置,这些函数会在某个时刻执行。驱动程序可以把它们自己的下半部注册到合适的队列上去。这种机制表现得还不错,但仍不够灵活,没法代替整个BH接口。对于一些性能要求较高的子系统,像网络部分,它也不能胜任。

软中断和tasklet

在2.3这个开发版本中,内核开发者引入了软中断和tasklet。如果无需考虑和过去开发的驱动程序兼容的话,软中断和tasklet可以完全代替BH接口。软中断是一组静态定义的下半部接口,有32个,可以在任何处理器上同时执行–即使两个类型相同也可以。tasklet这一名称起得很糟糕,让人费解,它们是一种基于软中断实现的灵活性强、动态创建的下半部实现机制。两个不同类型的tasklet可以在不同的处理器上同时执行,但类型相同的tasklet不能同时执行。tasklet其实是一种在性能和易用性之间寻求平衡的产物。对于大多数下半部处理来说,用tasklet就足够了,像网络这样对性能要求非常高的情况下才需要使用软中断。可是,使用软中断需要特别小心,因为两个相同的软中断有可能同时被执行。此外,软中断还必须在编译期间就进行静态注册。与此相反,tasklet可以通过代码进行动态注册。
有些人别被这些概念彻底搞糊涂了,他们把所有的下半部都当成是软件产生的中断或软中断。换句话说,就是他们把软中断机制和下半部统统都叫软中断。软中断和BH与tasklet并驾齐名。
在开发2.5版本的内核时,BH接口最终被弃置了,所有的BH使用者必须转而使用其他下半部接口。此外,任务队列接口也被工作队列接口取代了。工作队列是一种简单但很有用的方法,它们先对要推后执行的工作排队,稍后在进程上下文中执行它们。
综上所述,在2.6这个当前版本中,内核提供了三种不同形式的下半部实现机制:软中断、tasklets和工作队列。内核过去曾经用过的BH和任务队列接口,现在已经湮没在记忆中了。

内核定时器
另外一个可以用于将工作推后执行的机制是内核定时器。不像本章到目前为止介绍到的所有这些机制,内核定时器把操作推迟到某个确定的时间段之后执行。也就是说,尽管本章讨论的其他机制可以把操作推后到除了现在以外的任何时间进行,但是当你必须保证在一个确定的时间段过去之后再运行时,你应该使用内核定时器。

混乱的下半部概念

这些东西确实把人搅得很混乱,但它们其实只不过是一些起名的问题,让我们再来梳理一遍。
“下半部(bottom half)”是一个操作系统通用词汇,用于指代中断处理流程中推后执行的那一部分,之所以这样命名,是因为它表示中断处理方案一半的第二部分或者下半部。在Linux中,这个词目前确实就是这个含义。所有用于实现将工作推后执行的内核机制都被称为“下半部机制”。一些人错误地把所有的下半部机制都叫做“软中断”,真是自寻烦恼。
“下半部”这个词也指代Linux最早提供的那种将工作推后执行的实现机制。由于该机制也被叫做“BH”,所以,我们就使用它的这个名称,而让“下半部”这个词仍然保持它通常的含义。BH机制很早之前就被反对使用了,在2.5版本的内核中,它就被完全去除了。
当前,有三种机制可以用来实现将工作推后执行:软中断、tasklet、工作队列。tasklet通过软中断实现,而工作队列与它们完全不同。下表揭示了下半部机制的演化历程。

下半部机制 状态
BH 在2.5中去除
任务队列(Task queues) 在2.5中去除
软中断(Softirq) 在2.3中开始引入
tasklet 在2.3中开始引入
工作队列(Work queues) 在2.5中开始引入

在搞清楚这些混乱的命名之后,让我们开始具体研究各个机制。

软中断

我们的讨论从实际的下半部实现–软中断方法开始。软中断使用得比较少;而tasklet是下半部更常用的一种形式。但是,由于tasklet是通过软中断实现的,所以我们先来研究软中断。软中断的代码位于kernel/softirq.c文件中。

软中断的实现

软中断是在编译期间静态分配的。它不像tasklet那样能被动态地注册或注销。软中断由softirq_action结构表示,它定义在<linux/interrupt.h>中:

1
2
3
struct softirq_action {
void (*action)(struct softirq_action *);
};

kernel/softirq.c中定义了一个包含有32个该结构体的数组。

1
static struct softirq_action softirq_vec[NR_SOFTIRQS];

每个被注册的软中断都占据该数组的一项,因此最多有可能有32个软中断。注意,这是一个定值–注册的软中断数目的最大值没法动态改变。在当前的内核版本中,这32个项中只用到9个。

软中断处理程序

软中断处理程序action的函数原型如下:

1
void softirq_handler(struct softirq_action *);

当内核运行一个软中断处理程序的时候,它就会执行这个action函数,其唯一的参数为指向相应的softirq_action结构体的指针。例如,如果my_softirq指向softirq_vec数组的某项,那么内核会用如下的方式调用软中断处理程序中的函数:

1
my_softirq->action(my_softirq);

当你看到内核把整个结构体都传递给软中断处理程序而不是仅仅传递数据值的时候,你可能会很吃惊。这个小技巧可以保证将来在结构体中加入新的域时,无须对所有的软中断处理程序都进行变动。如果需要,软中断处理程序可以方便地解析它的参数,从数据成员中提取数值。
一个软中断不会抢占另一个软中断。实际上,唯一可以抢占软中断的是中断处理程序。不过,其他的软中断(甚至是相同类型的软中断)可以在其他处理器上同时执行。

执行软中断

一个注册的软中断必须在被标记后才会执行。这被称为触发软中断(raising the softirq)。通常,中断处理程序会在返回前标记它的软中断,使其在稍后被执行。于是,在合适的时刻,该软中断就会运行。在下列地方,待处理的软中断会被检查和执行:

  1. 从一个硬件中断代码出返回。
  2. 在ksoftirqd内核线程中。
  3. 在那些显式检查和执行待处理的软中断的代码中,如网络子系统中

不管是用什么方法唤起,软中断都要在do_softirq()中执行。该函数很简单。如果有待处理的软中断,do_softirq()会循环遍历每一个,调用它们的处理程序。让我观察一下do_softirq()经过简化后的核心部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
u32 pending;

pending = local_softirq_pending();
if(pending) {
struct softirq_action *h;

//重设待处理的位图
set_softirq_pending(0);

h = softirq_vec;
do {
if(pending & l)
h->action(h);
h++;
pending >>= 1;
}while(pending);
}

以上摘录的是软中断处理的核心部分。它检查并执行所有待处理的软中断,具体要做的包括:

  1. 用局部变量pending保存local_softirq_pending()宏的返回值。它是待处理的软中断的32位位图–如果第n位被设置为1,那么第n位对应类型的软中断等待处理。
  2. 现在待处理的软中断位图已经被保存,可以将实际的软中断位图清零了。
  3. 将指针n指向softirq_vec的第一项。
  4. 如果pending第一位被置为1,则h->action(h)被调用。
  5. 指针加1,所以现在它指向softirq_vec数组的第二项。
  6. 位掩码pending右移一位。这样会丢弃第一位,然后让其他各位依次向后移动一个位置。于是,原来的第二位现在就在第一位的位置上了。
  7. 现在指针h指向数组的第二项,pending位掩码的第二位现在也到了第一位上。重复执行上面的步骤。
  8. 一直重复下去,直到pengding变为0,这表明已经没有待处理的软中断了,我们的任务也就完成了。注意,这种检查足以保证h总指向softirq_vec的有效项,因为pending最多只可能设置32位,循环最多也只执行32次。

使用软中断

软中断保留给系统中对时间要求最严格以及最重要的下半部使用。目前,只有两个子系统(网络和SCSI)直接使用软中断。此外,内核定时器和tasklet都是建立在软中断上的。如果你想加入一个新的软中断,首先应该问问自己为什么用tasklet实现不了。tasklet可以动态生成,由于它们对加锁的要求不高,所以使用起来也很方便,而且它们的性能也非常不错。当然,对于时间要求严格并能自己高效地完成加锁工作的应用,软中断会是正确的选择。

分配索引

在编译期间,通过在<linux/interrupts.h>中定义的一个枚举类型来静态地声明软中断。内核用这些从0开始的索引表示一种相对优先级。索引号小的软中断在索引号大的软中断之前执行。
建立一个新的软中断必须在此枚举类型中加入新的项。而加入时,你不能像在其他地方一样,简单地把新项加到列表的末尾。相反,你必须根据希望赋予它地优先级来决定加入的位置。习惯上,HI_SOFTIRQ通常作为第一项,而RCU_SOFTIRQ作为最后一项。新项可能插在BLOCK_SOFTIRQ和TASKLET_SOFTIRQ之间。下表列出了已有的tasklet类型。
tasklet类型列表

tasklet 优先级 软中断描述
HI_SOFTIRQ 0 优先级高的tasklet
TIMER_SOFTIRQ 1 定时器的下半部
NET_TX_SOFTIRQ 2 发送网络数据包
NET_RX_SOFTIRQ 3 接收网络数据包
BLOCK_SOFTIRQ 4 BLOCK装置
TASKLET_SOFTIRQ 5 正常优先级的tasklets
SCHED_SOFTIRQ 6 调度程度
HRTIMER_SOFTIRQ 7 高分辨率定时器
RCU_SOFTIRQ 8 RCU锁定

注册你的处理程序

接着,在运行时通过调用open_softirq()注册软中断处理程序,该函数有两个参数:软中断的索引号和处理函数。如网络子系统,在net/coreldev.c通过以下方式注册自己的软中断:

1
2
open_softirq(NET_TX_SOFTIRQ,net_tx_action);
open_softirq(NET_RX_SOFTIRQ,net_rx_action);

软中断处理程序执行的时候,允许响应中断,但它自己不能休眠。在一个处理程序运行的时候,当前处理器上的软中断被禁止。但其他的处理器仍可以执行别的软中断。实际上,如果同一个软中断在它被执行的同时再次被触发了,那么另外一个处理器可以同时运行其处理程序。这意味着任何共享数据(甚至是仅在软中断处理程序内部使用的全局变量)都需要严格的锁保护。这点很重要,它也是tasklet更受青睐的原因。单纯地禁止你的软中断处理程序同时执行不是很理想。如果仅仅通过互斥的加锁方式来防止它自身的并发执行,那么使用软中断就没有任何意义了。因此,大部分中断处理程序,都通过采取单处理器数据(仅属于某一个处理器的数据,因此根本不需要加锁)或其他的一些技巧来避免显式的加锁,从而提供更出色的性能。
引入软中断的主要原因是其可扩展性。如果不需要扩展到多个处理器,那么,就使用tasklet吧。tasklet本质上也是软中断,只不过同一个处理程序的多个实例不能在多个处理器上同时运行。

触发你的软中断

通过在枚举类型的列表中添加新项以及调用open_softirq()进行注册之后,新的软中断处理程序就能够运行。raise_softirq()函数可以将一个软中断设置为挂起状态,让它在下次调用do_softirq()函数时投入运行。举个例子,网络子系统可能会调用:

1
raise_softirq(NET_TX_SOFTIRQ);

这会触发NET_TX_SOFTIRQ软中断。它的处理程序net_tx_action()就会在内核下一次执行软中断时投入运行。该函数在触发一个软中断之前先要禁止中断,触发后再恢复原来的状态。如果中断本来就已经被禁止了,那么可以调用另一函数raise_softirq_irqoff(),这会带来一些优化效果。如:

1
2
//中断已经被禁止
raise_softirq_irqoff(NET_TX_SOFTIRQ);

在中断处理程序中触发软中断是最常见的形式。在这种情况下,中断处理程序执行硬件设备的相关操作,然后触发相应的软中断,最后退出。内核在执行完中断处理程序之后,马上就会调用do_softirq()函数。于是软中断开始执行中断处理程序留给它去完成的剩余任务。在这个例子中,“上半部”和“下半部”名字的含义一目了然。

tasklet

tasklet是利用软中断实现的一种下半部机制。我们之前提到过,它和进程没有任何关系。tasklet和软中断在本质上很相似,行为表现也相近,但是,它的接口更简单,锁保护也比较低。
选择到底是用软中断还是tasklet其实很简单:通常你应该用tasklet。就像我们在前面看到的,软中断的使用者屈指可数。它只在那些执行频率很高和连续性要求很高的情况下才需要使用。而tasklet却有更广泛的用途。大多数情况下用tasklet效果都还不错,而且它们还非常容易使用。

tasklet的实现

因为tasklet是通过软中断实现的,所以它们本身也是软中断。前面讨论过了,tasklet由两类软中断代表:HI_SOFTIRQ和TASKLET_SOFTIRQ。这两者之间唯一的实际区别在于,HI_SOFTIRQ类型的软中断先于TASKLET_SOFTIRQ类型的软中断执行。

tasklet结构体

tasklet由tasklet_struct结构表示。每个结构体单独代表一个tasklet,它在<linux/interrupt.h>中定义为:

1
2
3
4
5
6
7
struct tasklet_struct {
struct tasklet_struct *next; //链表中的下一个tasklet
unsigned long state; //tasklet的状态
atomic_t count; //引用计数器
void (*func)(unsigned long); //tasklet处理函数
unsigned long data; //给tasklet处理函数的参数
};

结构体中的func成员是tasklet的处理程序(像软中断中的action一样),data是它唯一的参数。
state成员只能在0、TASKLET_STATE_SCHED和TASKLET_STATE_RUN之间取值。TASKLET_STATE_SCHED表明tasklet已被调度,正准备投入运行,TASKLET_STATE_RUN表示该tasklet正在运行。TASKLET_STATE_RUN只有在多处理器的系统上才会作为一种优化来使用,单处理器系统任何时候都清楚单个tasklet是不是正在运行(它要么就是当前正在执行的代码,要么不是)。
count成员是tasklet的引用计数器。如果它不为0,则tasklet被禁止,不允许执行;只有当它为0时,tasklet才被激活,并且被设置为挂起状态时,该tasklet才能够被执行。

调度tasklet

已调度的tasklet(等同于被触发的软中断)存放在两个单处理器数据结构:tasklet_vec(普通tasklet)和tasklet_hi_vec(高优先级的tasklet)。这两个数据结构都是由tasklet_struct结构体组成的链表。链表中的每个tasklet_struct代表一个不同的tasklet。
tasklet_struct结构体构成的链表。链表中的每个tasklet_struct代表一个不同的tasklet。
tasklet由tasklet_schedule()和tasklet_hi_schedule()函数进行调度,它们接受一个指向tasklet_struct结构的指针作为参数。两个函数非常相似(区别在于一个使用TASKLET_SOFTIRQ,而另一个用HI_SOFTIRQ)。在接下来的内容中我们将仔细研究怎么编写和使用tasklet。现在,让我们先考察一下task_schedule()的细节:
tasklet_schedule()的执行步骤:

  1. 检查tasklet的状态是否为TASKLET_STATE_SCHED。如果是,说明tasklet已经被调度过了,函数立即返回。
  2. 调用_tasklet_schedule()。
  3. 保存中断状态,然后禁止本地中断。在我们执行tasklet代码时,这么做能够保证当tasklet_struct()处理这些tasklet时,处理器上的数据不会弄乱。
  4. 把需要调度的tasklet加到每个处理器一个的tasklet_vec链表或tasklet_hi_vec链表的表头上去。
  5. 唤起TASKLET_SOFTIRQ或HI_SOFTIRQ软中断,这样在下一次调用do_softirq()时就会执行该tasklet。
  6. 恢复中断到原状态并返回。

在前面的内容中我们曾经提起过挂起,do_softirq()会尽可能早地在下一个合适地时机运行。由于大部分tasklet和软中断都是在中断处理程序中被设置成待处理状态,所以最近一个中断返回的时候看起来就是执行do_softirq()的最佳时机。因为TASKLET_SOFTIRQ和HI_SOFTIRQ已经被触发了,所以do_softirq()会执行相应的软中断处理程序。而这两个处理程序,taklet_action()和tasklet_hi_action(),就是tasklet处理的核心。让我们观察它们做了什么:

  1. 禁止中断(没有必要首先保存其状态,这是因为这里的代码总是作为软中断被调用,而且中断总是被激活的),并为当前处理器检索tasklet_vec或tasklet_hi_vec链表。
  2. 将当前处理器上的该链表设置为NULL,达到清空的效果。
  3. 允许响应中断。没有必要再恢复它们回原始状态,这是因为这段程序本身就是作为软中断处理程序被调用的,所以中断是应该被允许的。
  4. 循环遍历获得链表上的每一个待处理的tasklet。
  5. 如果是多处理器系统,通过检查TASKLET_STATE_RUN来判断这个tasklet是否正在其他处理器上运行。如果它正在运行,那么现在就不要执行,跳到下一个待处理的tasklet去(回忆一下,同一时间里,相同类型的tasklet只能有一个执行)。
  6. 如果当前这个tasklet没有执行,将其状态设置为TASKLET_STATE_RUN,这样别的处理器就不会再去执行它了。
  7. 检查count值是否为0,确保tasklet没有被禁止。如果tasklet被禁止了,则跳到下一个挂起的tasklet去。
  8. 我们已经清楚地知道这个tasklet没有在其他地方执行,并且被我们设置成执行状态,这样它在其他部分就不会被执行,并且引用计数为0,现在可以执行tasklet的处理程序了。
  9. tasklet运行完毕,清除tasklet的state域的TASKLET_STATE_RUN状态标志。
  10. 重复执行下一个tasklet,直至没有剩余的等待处理的tasklet。

taslet的实现很简单,但非常巧妙。我们可以看到,所有的tasklet都通过重复运用HI_SOFTRIRQ和TASKLET_SOFTIRQ这两个软中断实现。当一个tasklet被调度时,内核就会唤起这两个软中断中的一个。随后,该软中断会被特定的函数处理,执行所有已调度的tasklet。这个函数保证同一时间里只有一个给定类别的tasklet会被执行(但其他不同的tasklet可以同时执行)。所有的这些复杂性都被一个简洁的接口隐藏起来了。

使用tasklet

大多数情况下,为了控制一个寻常的硬件设备,tasklet机制都是实现自己的下半部的最佳选择。tasklet可以动态创建,使用方便,执行起来还算快。此外,尽管它们的名字使人混淆,但能加深你的印象。

声明你自己的tasklet

你既可以静态地创建tasklet,也可以动态地创建它。选择哪种方式取决于你到底是有(或者想要)一个对tasklet的直接引用还是间接引用。如果你准备静态地创建一个tasklet(也就是有一个它的直接引用),使用下面<linux/interrupt.h>中定义的两个宏中的一个:

1
2
DECLARE_TASKLET(name,func,data)
DECLARE_TASKLET_DISABLE(name,func,data);

这两个宏都能根据给定的名称静态的创建一个tasklet_struct结构。当该tasklet被调度之后,给定的函数func就会被执行,它的参数由data给出。这两个宏之间的区别在于引用计数器的初始值设置不同。前面一个宏把创建的tasklet引用计数器设置为0,该tasklet处于激活状态。另一个把引用计数器设置为1,所以该tasklet处于禁止状态。下面是一个例子:

1
DECLARE_TASKLET(my_tasklet,my_tasklet_handler,dev);

这行代码其实等价于

1
2
3
4
struct tasklet_struct my_tasklet = {
NULL,0,ATOMIC_INIT(0),
my_tasklet_handler,dev
};

这样就创建了一个名为my_tasklet,处理程序为tasklet_handler并且是已被激活的tasklet。当处理程序被调用的时候,dev就会被传递给它。
还可以通过将一个间接引用(一个指针)赋给一个动态创建的tasklet_struct结构的方式来初始化一个tasklet_init():

1
tasklet_init(t,tasklet_handler,dev);//动态而不是静态创建

编写你自己的tasklet处理程序

tasklet处理程序必须符合规定的函数类型:

1
void tasklet_handler(unsigned long data)

因为是靠软中断实现,所以tasklet不能睡眠。这意味着你不能在tasklet中使用信号量或者其他什么阻塞式的函数。由于tasklet运行时允许响应中断,所以你必须做好预防工作(如屏蔽中断然后获取一个锁),如果你的tasklet和中断处理程序之间共享了某些数据的话。两个相同的tasklet绝不会同时执行,这点和软中断不同–尽管两个不同的tasklet可以在两个处理器上同时执行。如果你的tasklet和其他的tasklet或者软中断共享了数据,你必须进行适当地锁保护。

调度你自己的tasklet

通过调用tasklet_schedule()函数并传递给它相应的tasklet_struct的指针,该tasklet就会被调度以便执行:

1
tasklet_schedule(&my_tasklet);//把my_tasklet标记为挂起

在tasklet被调度之后,只要有机会它就会尽可能早地运行。在它还没得到运行机会之前,如果有一个相同的tasklet又被调度了,那么它仍然只会运行一次。而如果这时它已经开始运行了,比如说在另外一个处理器上,那么这个新的tasklet会被重新调度并再次被运行。作为一种优化措施,一个tasklet总在调度它的处理器上执行–这是希望能更好地利用处理器的高速缓存。
你可以调用tasklet_disable()函数来禁止某个指定的tasklet。如果该tasklet当前正在执行,这个函数会等到它执行完毕再返回。你也可以调用tasklet_disable_nosync()函数,它也用来禁止指定的tasklet,不过它无须在返回前等待tasklet执行完毕。这么做往往不太安全,因为你无法估计该tasklet是否仍在执行。调用tasklet_enable()函数可以激活一个tasklet,如果希望激活DECLARE_TASKLET_DISABLE()创建的tasklet,你也得调用这个函数,如:

1
2
3
tasklet_diasble(&my_tasklet);   //tasklet现在被禁止
//我们毫无疑问地知道tasklet不能运行
tasklet_enable(&my_tasklet); //tasklet现在被激活

你可以通过调用tasklet_kill()函数从挂起的队列中去掉一个tasklet。该函数的参数是一个指向某个tasklet的tasklet_struct的长指针。在处理一个经常调度它自身的tasklet的时候,从挂起的队列中移去已调度的tasklet会很有用。这个函数首先等待该tasklet执行完毕,然后再将它移去。当然,没有什么可以阻止其它地方的代码重新调度该tasklet。由于该函数可能会引起休眠,所以禁止在中断上下文中使用它。

ksoftirq

每个处理器都有一组辅助处理软中断(或tasklet)的内核线程。当内核中出现大量软中断的时候,这些内核线程就会辅助处理它们。因为tasklet通过用软中断实施,下面的讨论同样适用于软中断和tasklet。简洁起见,我们将主要参考软中断。
我们前面阐述过,对于软中断,内核会选择在几个特殊时机进行处理。而在中断处理程序返回时是最常见的。软中断被触发的频率有时可能很高(像在进行大流量的网络通信期间)。更不利的是,处理函数有时还会自行重复触发。也就是说,当一个软中断执行的时候,它可以重新触发自己以便再次得到执行(事实上,网络子系统就会这么做)。如果软中断本身出现的频率就高,再加上它们又将自己重新设置为可执行状态的能力,那么就会导致用户空间进程无法获得足够的处理器时间,因而处于饥饿状态。而且,单纯的对重新触发的软中断采取不立即处理的策略,也无法让人接受。当软中断最初提出时,就是一个让人进退维谷的问题,亟待解决,而直观的解决方案又都不理想。首先,就让我们看看两种最容易想到的直观的方案。
第一种方案是,只要还有被触发并等待处理的软中断,本次执行就要负责处理,重新触发的软中断也在本次执行返回前被处理。这样做可以保证对内核的软中断采取即时处理的方式,关键在于,对重新触发的软中断也会立即处理。当负载很高的时候这样做就会出问题,此时会有大量被触发的软中断,而它们本身又被重复触发。系统可能会一直处理软中断,根本不能完成其他任务,用户空间的任务被忽略了–实际上,只有软中断和中断处理程序轮流执行,而系统的用户只能等待。只有在系统永远处于低负载的情况下,这种方案才会有理想的运行效果;只要系统有哪怕是中等程度的负载量,这种方案就无法让人满意。用户空间根本不能容忍有明显的停顿出现。
第二种方案选择不处理重新触发的软中断。在从中断返回的时候,内核和平常一样,也会检查所有挂起的软中断并处理它们。但是,任何自行重新触发的软中断都不会马上处理。它们会被放到下一个软中断执行时去处理。而这个时机通常也就是下一次中断返回的时候,这等于就是说,一定得等一段时间,新的(或者重新触发的)软中断才能被执行。可是,在比较空闲的系统中,立即处理软中断才是比较好的做法。很不幸,这个方案显然又是一个时好时坏的选择。尽管它能保证用户空间不处于饥饿状态,但它却让软中断忍受饥饿的痛苦,而根本没有好好利用闲置的系统资源。
在设计软中断时,开发者就意识到需要一些折中。最终在内核实现的方案是不会立即处理重新触发的软中断。而作为改进,当大量软中断出现的时候,内核会唤醒一组内核线程来处理这些负载。这些线程在最低的优先级上运行(nice值为19),这能避免它们跟其他重要的任务抢夺资源。但是它们最终肯定会被执行,所以,这个这种方案能够保证软中断负担很重的时候,用户程序不会因为得不到处理时间而处于饥饿状态。相应的,也能保证“过量”的软中断终究会得到处理。最后,在空闲系统上,这个方案同样表现良好,软中断处理的非常迅速(因为仅存的内核线程肯定会马上调度)。
每个处理器都有一个这样的线程。所有的线程名字都叫做ksoftirqd/n,区别在于n,它对应的是处理器的编号。在一个双CPU的机器上就有两个这样的线程,分别叫做ksoftirqd/0和ksoftirqd/1。为了保证只要有空闲的处理器,它们就会处理软中断,所以给每个处理器都分配一个这样的线程。一旦该线程被初始化,它就会执行类似下面这样的死循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
for(;;) {
if(!softirq_pending(cpu))
schedule();

set_current_state(TASK_RUNNING);

while(softirq_pending(cpu)) {
do_softirq();
if(need_resched())
schedule();
}

set_current_state(TASK_INTERRUPTIBLE);
}

只要有待处理的软中断(由softirq_pending()函数负责发现),ksoftirq就会调用do_softirq()去处理它们。通过重复执行这样的操作,重复触发的软中断也会被执行。如果有必要的话,每次迭代后都会调用schedule()以便让更重要的进程得到处理机会。当所有需要执行的操作都完成以后,该内核进程将自己设置为TASK_INTERRUPT状态,唤起调度程序选择其他可执行进程投入运行。
只要do_softirq()函数发现已经执行过的内核线程重新触发了它自己,软中断内核线程就会被唤醒。

老的BH机制

尽管BH机制令人欣慰的退出了历史舞台,在2.6版内核中已经难觅踪迹。可是,它毕竟曾经经历了漫长的时光–从最早版本的内核就开始了。由于其余威尚存,所以仅仅不经意地提起它是不够的,尽管在2.6版本中已经不再使用它了,但历史就是历史,应该被了解。
BH很古老,但它能揭示一些东西。所有BH都是静态定义的,最多可以有32个。由于处理函数必须在编译时就被定义好,所以实现模块时不能直接使用BH接口。不过业已存在的BH倒是可以利用。随着时间的推移,这种静态要求和最大为32个的数目限制最终妨碍了它们的应用。
每个BH处理程序都严格的按顺序执行–不允许任何两个BH处理程序同时执行,即使它们的类型不同。这样做倒是使同步变得简单了,可是却不利于多处理的可扩展性,也不利于大型SMP的性能。使用BH的驱动程序很难从多个处理器上收益,特别是网络层,可以说为此饱受困扰。
除了这些特点,BH机制和tasklet就很像了。实际上,在2.4内核中,BH就是基于tasklet实现的。所有可能的32个BH都通过在<linux/interrupt.h>中定义的常量表示。如果需要将一个BH标志为挂起状态,可以把相应的BH号传给mark_bh()函数。在2.4内核中,这将导致随后调度BH tasklet,具体工作是由函数bh_action()完成的。而在2.4内核之前,BH机制独立实现,不依赖任何低级BH机制,这和现在的软中断很像。
由于这种形式的下半部机制存在缺点,内核开发者们希望引入任务队列机制来代替它。尽管任务队列得到了不少使用者的认可,但它实际上并没有达成这个目的。在2.3版本的内核中,引入了新的软中断和tasklet机制也就结束了对BH的使用。BH机制基于tasklet重新实现。不幸的是,因为新接口本身降低了对执行的序列化保障,所以从BH接口移植到tasklet或软中断接口上操作起来非常复杂。在2.5版中,这种移植最终在定时器和SCSI(最后的BH使用者)转换到软中断机制后完成了。于是内核开发者们立即除去了BH接口。

工作队列

工作队列(work queue)是另外一种将工作推后执行的形式,它和我们前面讨论的所有其他形式都不相同。工作队列可以把工作推后,交给一个内核线程去执行–这个下半部总是会在进程上下文中执行。这样,通过工作队列执行的代码能占尽进程上下文的所有优势。最重要的就是工作队列允许重新调度甚至是睡眠。
通常,在工作队列和软中断tasklet中做出选择非常容易。如果推后执行的任务需要睡眠,那么就选择工作队列。如果推后执行的任务不需要睡眠,那么就选择软中断或tasklet。实际上,工作队列通常可以用内核线程去替换。但是由于内核开发者们非常反对创建新的内核线程(在有些场合,使用这种冒失的办法可能会吃到苦头),所以我们也推荐使用工作队列。当然,这种接口也的确很容易使用。
如果你需要用一个可以重新调度的实体来执行你的下半部处理,你应该使用工作队列。它是唯一能在进程上下文中运行的下半部实现机制,也只有它才可以睡眠。这意味着在你需要获得大量的内存时,在你需要获取信号量时,在你需要执行阻塞式的I/O操作时,它都会非常有用,如果你不需要用一个内核线程来推后执行工作,那么就考虑使用tasklet吧。

工作队列的实现

工作队列子系统是一个用于创建内核线程的接口,通过它创建的进程负责执行由内核其他部分排到队列里的任务。它创建的这些内核线程称作工作者线程(worker thread)。工作队列可以让你的驱动程序创建一个专门的工作者线程来处理需要退后的工作。不过,工作队列子系统提供了一个缺省的工作者线程来处理这些工作。因此,工作队列最基本的表现形式,就转变成了一个把需要推后执行的任务交给特定的通用线程的这样一种接口。
缺省的工作者线程叫做events/n,这里n是处理器的编号;每个处理器对应一个线程。例如,单处理器的系统只有events/0这样一个线程。而双处理器的系统就会多一个events/1线程。缺省的工作者线程会从多个地方得到被推后的工作。许多内核驱动程序都把它们的下半部交给缺省的工作者线程去做。除非一个驱动程序或者子系统必须建立一个属于它自己的内核线程,否则最好使用缺省线程。
不过并不存在什么东西能够阻止代码创建属于自己的工作者线程。如果你需要在工作者线程中执行大量的处理操作,这样做或许会带来好处。处理器密集型和性能要求严格的任务会因为拥有自己的工作者线程而获得好处。此时这么做也有助于减轻缺省线程的负担,避免工作队列中其他需要完成的工作处于饥饿状态。

表示线程的数据结构

工作者线程用workqueue_struct结构表示:

1
2
3
4
5
6
7
8
9
10
11
12
/*
外部可见的工作队列抽象是
由每个CPU的工作队列组成的数据
*/
struct workqueue_struct {
struct cpu_workqueue_struct cpu_wq[NR_CPUS];
struct list_head list;
const char *name;
int singlethread;
int freezeable;
int rt;
};

该结构内是一个由cpu_workqueue_struct结构组成的数组,它定义在kernel/workqueue.c中,数组的每一项对应系统中的一个处理器。由于系统中每个处理器对应一个工作者线程,所以对于给定的某台计算机来说,就是每个处理器,每个工作者线程对应一个这样的cpu_workqueue_struct结构体。cpu_workqueue_struct是kernel/workqueue.c中的核心数据结构:

1
2
3
4
5
6
7
8
9
10
struct cpu_workqueue_struct {
spinlock_t lock; //锁保护这种结构

struct list_head worklist; //工作列表
wait_queue_head_t more_work;
struct work_struct *current_struct;

struct workqueue_struct *wq; //关联工作队列结构
task_t *thread; //关联线程
};

注意,每个工作者线程类型关联一个自己的workqueue_struct。在结构体里面,给每个线程分配一个cpu_workqueue_struct,因而也就是给每个处理器分配一个,因为每个处理器都有一个该类型的工作者线程。

表示工作的数据结构

所有的工作者线程都是普通的内核线程实现的,它们都要执行worker_thread()函数。在它初始化完之后,这个函数执行一个死循环并开始休眠。当有操作被插入到队列里的时候,线程就会被唤醒,以便执行这些操作。当没有剩余的操作时,它又会继续休眠。
工作用<linux/workqueue.h>中定义的work_struct结构体表示:

1
2
3
4
5
struct work_struct {
atomic_long_t data;
struct list_head entry;
work_func_t func;
};

这些结构体被连接成链表,在每个服务器上的每种类型的队列都对应这样一个链表。比如,每个处理器上用于执行被推后的工作的那个通用线程都有一个这样的链表。当一个工作者线程被唤醒时,它会执行它的链表上的所有工作。工作被执行完毕,它就将相应的work_struct对象从链表上移去,当链表上不再有对象时,它就会继续休眠。
我们可以看一下worker_thread()函数的核心流程,简化如下:

1
2
3
4
5
6
7
for(;;) {
prepare_to_wait(&cwq->more_work,&wait,TASK_INTERRUPTIBLE);
if(list_empty(&cwq->worklist))
schedule();
finish_wait(&cwq->more_work,&wait);
run_workqueue(cwq);
}

该函数在死循环中完成了以下功能:

  1. 线程将自己设置为休眠状态(state被设成TASK_INTERRUPTIBLE),并把自己加入到等待队列中。
  2. 如果工作链表是空的,线程调用schedule()函数进入睡眠状态。
  3. 如果链表中有对象,线程不会睡眠。相反,它将自己设置成TASK_RUNNNING,脱离等待队列。
  4. 如果链表非空,调用run_workqueue()函数执行被推后的工作。

下一步,由run_workqueue()函数来实际完成推后到此的工作:

1
2
3
4
5
6
7
8
9
10
11
while(!list_empty(&cwq->worklist)) {
struct work_struct *work;
work_func_t f;
void *data;

work = list_entry(cwq->worklist.next,struct work_struct,entry);
f = work->func;
list_del_init(cwq->worklist.next);
work_clear_pending(work);
f(work);
}

该函数循环遍历链表上每个待处理的工作,执行链表每个节点上的workqueue_struct中的成员函数:

  1. 当链表不为空时,选取下一个节点对象。
  2. 获取我们希望执行的函数func及其参数data。
  3. 把该节点从链表上解下来,将待处理标志位pending清零。
  4. 调用函数。
  5. 重复执行。

工作队列实现机制的总结

这些数据之间的关系确实让人觉得混乱,难以理清头绪。下面的示意图把所有的这些关系放在一起进行解释。
img not found
位于最高一层的是工作者线程。系统允许有多种类型的工作者线程存在。对于指定的一个类型,系统的每个CPU上都有一个该类的工作者线程。内核中有些部分可以根据需要来创建工作者线程,而在默认情况下内核只有event这一种类型的工作者线程。每个工作者线程都由一个cpu_workqueue_struct结构体表示。而workqueue_struct结构体则表示给定类型的所有工作者线程。
例如,除系统默认的通用events工作者类型之外,我还自己加入了一种falcon工作者类型。并且使用的是一个拥有四个处理器的计算机。那么,系统中现有四个event类型的线程(因而也就有四个cpu_workqueue_struct结构体)和四个falcon类型的线程(因而会有另外四个cpu_workqueue_struct)。同时,有一个对应event类型的workqueue_struct和一个对应falcon类型的workqueue_struct。
工作处于最底层,让我们从这里开始。你的驱动程序创建这些需要推后执行的工作。它们用work_struct结构来表示。这个结构体中最重要的部分是一个指针,它指向一个函数,而正是该函数负责处理需要推后执行的具体任务。工作会被提交给某个具体的工作者线程–在这种情况下,就是特殊的falcon线程。然后这个工作者线程会被唤醒并执行这些排好的工作。
大部分驱动程序都使用的是现存的默认工作者线程。它们使用起来简单、方便。可是,在有些要求更严格的情况下,驱动程序需要自己的工作者线程。比如说XFS文件系统就为自己创建了两种新的工作者线程。

使用工作队列

工作队列的使用非常简单。我们先来看一下缺省的events任务队列,然后再看看创建新的工作者线程。

创建推后的工作

首先要做的是实际创建一些需要推后完成的工作。可以通过DECLARE_WORK在编译时静态的创建该结构体:

1
DECLARE_WORK(name,void(*func)(void *),void *data);

这样就会静态地创建一个名为name,处理函数为func,参数为data的work_struct结构体。
同样,也可以在运行时通过指针创建一个工作:

1
INIT_WORK(struct work_struct *work,void (*func),void *data);

这会动态的初始化一个由work指向的工作,处理函数为func,参数为data。

工作队列处理函数

工作队列处理函数的原型是:

1
void work_handler(void *data)

这个函数会由一个工作者线程执行,因此,函数会运行在进程上下文中。默认情况下,允许响应中断,并且不持有任何锁。如果需要,函数可以睡眠。需要注意的是,尽管操作处理函数运行在进程上下文中,但它不能访问用户空间,因为内核线程在用户空间没有相关的内存映射。通常发生在系统调用时,内核会代表用户空间的进程运行,此时它才能访问用户空间,也只有在此时它才会映射用户空间的内存。
在工作队列和内核其他部分之间使用锁机制就像在其他的进程上下文中使用锁机制一样方便。这使编写处理函数变得相对容易。

对工作进行调度

现在工作已经被创建,我们可以调度它了。想要把给定工作的处理函数提交给缺省的events工作线程,只需调用:

1
schedule_work(&work);

work马上就会被调度,一旦其所在的处理器上的工作者线程被唤醒,它就会被执行。
有时候你并不希望工作马上就被执行,而是希望它经过一段延迟以后在执行。在这种情况下,你可以调度它在指定的时间执行:

1
schedule_delayed_work(&work,delay);

这时,&work指向的work_struct直到delay指定的时钟节拍用完以后才会执行。

刷新操作

排入队列的工作会在工作者线程下一次被唤醒的时候执行。有时,在继续下一步工作之前,你必须保证一些操作已经执行完毕了。这一点对模块来说就很重要,在卸载之前,它就有可能需要调用下面的函数。而在内核的其他部分,为了防止竞争条件的出现,也可能需要确保不再有待处理的工作。
处于以上目的,内核准备了一个用于刷新指定工作队列的函数:

1
void flush_schedule_work(void);

函数会一直等待,直到队列中所有对象都被执行以后才返回。在等待所有待处理的工作执行的时候,该函数会进入休眠状态,所以只能在进程上下文中使用它。
注意,该函数并不取消任何延迟执行的工作。就是说,任何通过schedule_delayed_work()调度的工作,如果其延迟时间未结束,它并不会因为调用flush_schedule_work()而被刷新掉。取消延迟执行的工作应该调用:

1
int cancle_delayed_work(struct work_struct *work);

这个函数可以取消任何与work_struct相关的挂起工作。

创建新的工作队列

如果缺省的队列不能满足你的需要,你应该创建一个新的工作队列和与之相应的工作者线程。由于这么做会在每个处理器上都创建一个工作者线程,所以只有在你明确了必须要靠自己的一套线程来提高性能的情况下,在创建自己的工作队列。
创建一个新的任务队列和与之相关的工作者线程,你只需调用一个简单的函数:

1
struct workqueue_struct *create_workqueue(const char *name);

name参数用于该内核线程的命名。比如,缺省的events队列的创建就调用的是:

1
2
struct workqueue_struct *keventd_wq;
keventd_wq = create_workqueue("events");

这样就会创建所有的工作者线程(系统中的每个处理器都有一个),并且做好所有开始处理工作之前的准备工作。
创建一个工作的时候无须考虑工作队列的类型。在创建之后,可以调用下面列举的函数。这些函数与schedule_work()以及schedule_delayed_work()相近,唯一的区别就在于它们针对给定的工作队列而不是缺省的events队列进行操作。

1
2
3
4
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);

最后,你可以调用下面的函数刷新指定的工作队列:

1
flush_workqueue(struct workqueue_struct *wq);

该函数和前面讨论过的flush_schedule_work()作用相同,只是在它返回前等待清空的是给定的队列。

老的任务队列机制

像BH接口被软中断和tasklet代替一样,由于任务队列接口存在的种种缺陷,它也被工作队列接口取代了。像tasklet一样,任务队列接口(内核中常常被称为tq)其实也和进程没什么相关之处。任务队列接口的使用者在2.5开发版本中分为两部分,其中一部分转向了使用tasklet,还有另一部分继续使用任务队列接口。而目前任务队列接口剩余的部分已经演化成了工作队列接口。由于任务队列在内核中曾经使用过一段时间,出于了解历史的目的,我们对它进行了大体回顾。
任务队列机制通过定义一组队列来实现其功能。每个队列都有自己的名字,比如调度程序队列、立即队列和定时器队列。不同的队列在内核中的不同场合使用。keventd内核线程负责执行调度程序队列的相关任务。它是整个工作队列接口的先驱。定时器队列会在系统定时器的每个时间节拍执行,而立即队列能够得到双倍的运行机会,以保证它能够“立即执行”。当然,还有其他一些队列。此外,你还可以动态地创建自己的新队列。
这些听起来都挺有用,但任务队列,接口实际上是一团乱麻。这些队列基本上都是些随意创建的抽象概念,散落在内核各处,就像飘散在空气中。唯有调度队列有点意义,它能用来把工作推后到进程上下文完成。
任务队列唯一的好处就是接口特别简单。如果不考虑这些队列的数量和执行时随心所欲的规则,它的接口确实够简单。但这也就是全部意义所在了–任务队列剩下的东西乏善可陈。
许多任务队列接口的使用者都已经转向使用其他的下半部实现机制了,大部分选择了tasklet,只有调度程序队列的使用者在苦苦支撑。最终,keventd代码演化成了我们今天使用的工作队列机制,而任务队列最终推出了历史舞台。

下半部机制的选择

在各种不同的下半部实现机制之间做出选择是很重要的。在当前的2.6版的内核中,有三种可能的选择:软中断、tasklet和工作队列。tasklet基于软中断实现,所以两者很相近。工作队列机制与它们完全不同,它靠内核线程实现。
从设计的角度考虑,软中断提供的执行序列化的保障最少。这就要求软中断处理函数必须格外小心地采取一些步骤确保共享数据的安全,两个甚至更多相同类别的软中断有可能在不同的处理器上同时执行。如果被考察的代码本身多线索化的工作就做得非常好,比如网络子系统,它完全使用单处理器变量,那么软中断就是非常好的选择。对于时间要求严格和执行频率很高的应用来说,它执行得也最快。
如果代码多线索化考虑得并不充分,那么选择tasklet意义更大。它的接口非常简单,而且,由于两个同种类型的tasklet不能同时执行,所以实现起来也会简单一些。tasklet是有效的软中断,但不能并发运行。驱动程序开发者应当尽可能选择tasklet而不是软中断,当然,如果准备利用每一处理器上的变量或者类似的情形,以确保中断能安全地在多个处理器上并发地运行,那么还是选择软中断。
如果你需要把任务推后到进程上下文中完成,那么这三者中就只能选择工作队列了。如果进程上下文并不是必须的条件(就是如果并不需要睡眠),那么软中断和tasklet可能更适合。工作队列造成的开销最大,因为它要牵扯到内核线程甚至是上下文切换。这并不是说工作队列的效率低,如果每秒有几千次中断,就像网络子系统时常经历的那样,那么采用其他的机制可能更合适一些。尽管如此,针对大部分情况,工作队列都能提供足够的支持。
如果讲到易于使用,工作队列就当仁不让了。使用缺省的events队列简直不费吹灰之力。接下来是tasklet,它的接口也很简单。最后才是软中断,它必须静态创建,并且需要慎重考虑其实现。
对下半部的比较

下半部 上下文 顺序执行保障
软中断 中断 没有
tasklet 中断 同类型不能同时执行
工作队列 进程 没有(和进程上下文一样被调度)

简单的说,一般的驱动程序的编写者需要做出两个选择。首先,你是不是需要一个可调度的实体来执行需要推后完成的工作--从根本上来说,你有休眠的需要吗?要是有,工作队列就是你的唯一选择。否则最好用tasklet。要是必须专注于性能的提高,那么就考虑软中断吧。

在下半部之间加锁

到现在为止,我们还没讨论过锁机制,这是一个非常有趣且广泛的话题。在使用下半部机制时,即使是在一个单处理器的系统上,避免共享数据被同时访问也是至关重要的。记住,一个下半部实际上可能在任何时候执行。
使用tasklet的一个好处在于,它自己负责执行的序列化保障:两个相同类型的tasklet不允许同时执行,即使在不同的处理器上也不行。这意味着你无须为intra-tasklet的同步问题操心了。tasklet之间的同步(就是当两个不同类型的tasklet共享同一数据时)需要正确使用锁机制。
如果进程上下文和一个下半部共享数据,在访问这些数据之前,你需要禁止下半部的处理并得到锁的使用权。做这些是为了本地和SMP的保护并且防止死锁的出现。
如果中断上下文和一个下半部共享数据,在访问数据之前,你需要禁止中断并得到锁的使用权。所做的这些也是为了本地和SMP的保护并且防止死锁的出现。
任何在工作队列中被共享的数据也需要使用锁机制。其中有关锁的要点和在一般内核代码中没什么区别,因为工作队列本来就是在进程上下文中执行的。

禁止下半部

一般单纯禁止下半部的处理是不够的。为了保证共享数据的安全,更常见的做法是,先得到一个锁然后再禁止下半部的处理。驱动程序中通常使用的都是这种方法。然而,如果你编写的是内核的核心代码,你也可能仅需要禁止下半部就可以了。
如果需要禁止所有的下半部处理(明确点说,就是所有的软中断和所有的tasklet),可以调用local_bh_disable()函数。允许下半部进行处理,可以调用local_bh_enale()函数。没错,这些函数的命名也有问题;可是既然BH接口早就让位给软中断了,那么谁又会去改这些名称呢?

下半部机制控制函数的清单

函数 描述
void local_bh_disable() 禁止本地处理器的软中断和tasklet的处理
void local_bh_enale() 激活本地处理器的软中断和tasklet的处理

这些函数有可能被嵌套使用–最后被调用的local_bh_enable()最终被激活下半部。比如,第一次调用local_bh_disable(),则本地软中断处理被禁止;如果local_bh_disable()被调用三次,则本地处理仍然被禁止;只有当第四次调用local_bh_enable()时,软中断处理才被重新激活。
函数通过preempt_count(很有意思,还是这个计数器,内核抢占的时候用的也是它)为每个进程维护一个计数器。当计数器变为0时,下半部才能够被处理。因为下半部的处理已经被禁止,所以local_bh_enable()还需要检查所有现存的待处理的下半部并执行它们。
这些函数与硬件体系结构相关,它们位于<asm/softirq.h>中,通常由一些复杂的宏实现。下面是为那些好奇的人准备了C语言的近似描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*
通过增加preempt_count禁止本地下半部
*/
void local_bh_disable(void)
{
struct thread_info *t = current_thread_info();
t->preempt_count += SOFTIRQ_OFFSET;
}

/*
减少preempt_count如果返回值为0,将导致自动激活下半部
执行挂起的下半部
*/
void local_bh_enable(void)
{
struct thread_info *t = current_thread_info();
t->preempt_count -= SOFTIRQ_OFFSET;
/*
preempt_count是否为0,另外是否有挂起的下半部,如果都满足,则执行
待执行的下半部
*/
if(unlikely(!t->preemt_count && softirq_pending(smp_processer_id())))
do_softirq();
}

这些函数并不能禁止工作队列的执行。因为工作队列是在进程上下文中执行的,不会涉及异步执行的问题,所以也就没有必要禁止它们执行。由于软中断和tasklet是异步发生的(就是说,在中断处理返回的时候),所以,内核代码必须禁止它们。另一方面,对于工作队列来说,它保护共享数据所做的工作和其他进程上下文中所做的都差不多。

小结

在本章中,我们涵盖了用于延迟Linux内核工作的三种机制:软中断、tasklet和工作队列。我们考察了其设计和实现,讨论了如何把这些机制应用到代码中,也调侃了易于混淆的命名。为了完整起见,我们也考察了曾经的下半部机制:BH和任务队列–这些用在以前的Linux内核版本中。
因为下半部中相当程度地用到了同步和并发,所以本章谈了很多相关的话题。我们甚至围绕本章讨论了禁止下半部的问题,这是由并发保护引起的,这一话题到此只是刚刚引入的。