我们在前面的“中断处理”博文中提到,在由内核执行的几个中断任务之间有些不是紧急的:在必要情况下它们可以延迟一段时间。回忆一下,一个中断处理程序是急迫的,调用do_IRQ将几个中断服务例程串行执行,并且通常在一个中断的处理程序结束前,不应该再次出现这个中断,我们叫它“上半部分”。相反,可延迟中断可以在开中断的情况下执行。把可延迟中断从中断处理程序中抽出来有助于使内核保持较短的响应时间。
Linux 2.6对后者的处理叫做下半部分(“bottom half”),是通过两种非紧迫、可中断内核函数:所谓的可延迟函数(包括软中断与tasklets)和通过工作队列来执行的函数。
软中断和tasklet有密切的关系,tasklet是在软中断之上实现。这里先清理一些概念,出现在内核代码中的术语“软中断(softirq)”常常表示可延迟函数的所有种类。另外一种被广泛使用的术语是“中断上下文”:表示内核当前正在执行一个中断处理程序或一个可延迟的函数。
软中断的分配是静态的(即在编译时定义),而tasklet的分配和初始化可以在运行时进行(例如:安装一个内核模块时)。软中断(即便是同一种类型的软中断)可以并发地运行在多个CPU上。因此,软中断是可重入函数而且必须明确地使用自旋锁保护其数据结构。tasklet不必担心这些问题,因为内核对tasklet的执行进行了更加严格的控制。相同类型的tasklet总是被串行地执行,换句话说就是:不能在两个CPU上同时运行相同类型的tasklet。但是,类型不同的tasklet可以在几个CPU上并发执行。tasklet的串行化使tasklet函数不必是可重入的,因此简化了设备驱动程序开发者的工作。
一般而言,在可延迟函数上可以执行四种操作:
初始化(initialization)
定义一个新的可延迟函数;这个操作通常在内核自身初始化或加载模块时进行。
激活(activation)
标记一个可延迟函数为“挂起”(在可延迟函数的下一轮调度中执行)。激活可以在任何时候进行(即使正在处理中断)。
屏蔽(masking)
有选择地屏蔽一个可延迟函数,这样,即使它被激活,内核也不执行它。
技行(execution)
执行一个挂起的可延迟函数和同类型的其他所有挂起的可延迟函数;执行是在特定的时间进行的。
Linux 2.6使用有限个软中断。其实,Linux更倾向于用tasklet,因为大多数场合tasklet是足够用的,且更容易编写,因为tasklet不必是可重入的。如下表所示,目前只定义了六种软中断。
软中断 |
下标 (优先级) |
说明 |
HI_SOFTIRQ |
0 |
处理高优先级的tasklet |
TIMER_SOFTIRQ |
1 |
和时钟中断相关的tasklet |
NET_TX_SOFTIRQ |
2 |
把数据包传送到网卡 |
NET_RX_SOFTIRQ |
3 |
从网卡接收数据包 |
SCSI_SOFTIRQ |
4 |
SCSI命令的后台中断处理 |
TASKLET_SOFTIRQ |
5 |
处理常规tasklet |
一个软中断的下标决定了它的优先级:低下标意味着高优先级,因为软中断函数将从下标0开始执行。
软中断所使用的数据结构
表示软中断的主要数据结构是softirq_vec数组,该数组包含类型为softirq_action的32个元素。一个软中断的优先级是相应的softirq_action元素在数组内的下标。如上表所示,只有数组的前六个元素被有效地使用。softirq_action数据结构包括两个字段:指向软中断函数的一个action指针和指向软中断函数需要的通用数据结构的data指针。
另外一个关键的字段是32位的 preempt_count字段,用它来跟踪内核抢占和内核控制路径的嵌套,该字段存放在每个进程描述符的thread_info字段中。如下表所示,preempt_count字段的编码表示三个不同的计数器和一个标志。
位 |
说明 |
0~7 |
Preemption counter抢占计数器 (max value = 255) |
8~15 |
Softirq counter软中断计数器 (max value = 255). |
16~27 |
Hardirq counter硬中断计数器 (max value = 4096) |
28 |
PREEMPT_ACTIVE 标志 |
第一个计数器记录显式禁用本地CPU内核抢占的次数,值等于0表示允许内核抢占。第二个计数器表示可延迟函数被禁用的程度(值为0表示可延迟函数处于激活状态)。第三个计数器表示在本地CPU上中断处理程序的嵌套数(irq_enter()宏递增它的值,irq_exit()宏递减它的值)。
给preempt_count字段起这个名字的理由是很充分的:当内核代码明确不允许发生抢占(抢占计数器不等于0)或当内核正在中断上下文中运行时,必须禁用内核的抢占功能。因此,为了确定是否能够抢占当前进程,内核快速检查preempt_count字段中的相应值是否等于0。
宏in_interrupt()检查current_thread_info()->preempt_count字段的硬中断计数器和软中断计数器,只要这两个计数器中的一个值为正数,该宏就产生一个非零值,否则产生一个零值。如果内核不使用多内核栈,则该宏只检查当前进程的thread_info描述符的preempt_count字段。但是,如果内核使用多内核栈,则该宏可能还要检查本地CPU的irq_ctx联合体中thread_info描述符的preempt_count字段。在这种情况下,由于该字段总是正数值,所以宏返回非零值。
实现软中断的最后一个关键的数据结构是每个CPU都有的32位掩码(描述挂起的软中断),它存放在irq_cpustat_t数据结构的__softirq_pending字段中。为了获取或设置位掩码的值,内核使用宏local_softirq_pending(),它选择本地CPU的软中断位掩码。
处理软中断
open_softirq()函数处理软中断的初始化。它使用三个参数:软中断下标、指向要执行的软中断函数的指针及指向可能由软中断函数使用的数据结构的指针。open_softirq()限制自己初始化softirq_vec数组中适当的元素。
raise_softirq()函数用来激活软中断,它接受软中断下标作为参数。
1. 执行local_irq_save宏以保存eflags寄存器IF标志的状态值并禁用本地CPU上的中断。
2. 把软中断标记为挂起状态,这是通过设置本地CPU的软中断掩码中与下标nr相关的位来实现的。
3. 如果in_interrupt()产生为1的值,则跳转到第5步。这种情况说明:要么已经在中断上下文中调用了raise_softirq(),要么当前禁用了软中断。
4. 否则,就在需要的时候去调用wakeup_softirqd()以唤醒本地CPU的ksoftirqd内核线程(见后面)。
5. 执行local_irq_restore宏,恢复在第1步保存的IF标志的状态值。
应该周期性地(但又不能太频繁地)检查活动(挂起)的软中断,检查是在内核代码的几个点上进行的。这在下列几种情况下进行(注意,检查点的个数和位置随内核版本和所支持的硬件结构而变化):
- 当内核调用local_bh_enable()函数激活本地CPU的软中断时。
- 当do_IRQ()完成了I/O中断的处理时或调用irq_exit()宏时。
- 如果系统使用I/O APIC,则当smp_apic_timer_interrupt()函数处理完本地定时器中断时。
- 在多处理器系统中,当CPU处理完被CALL_FUNCTION_VECTOR处理器间中断所触发的函数时。
- 当一个特殊的ksoftirqd/n内核线程被唤醒时(见后面)。
do_softirq()函数:如果在这样的一个检查点(local_softirq_pending()不为0)检测到挂起的软中断,内核就调用do_softirq()来处理它们。具体代码不去分析了。
ksoftirq内核线程
在最近的内核版本中,每个CPU都有自己的ksoftirqd/n内核线程(这里,n为CPU的逻辑号)。每个ksoftirqd/n内核线程都运行ksoftirqd()函数,这个函数实际上执行下列循环:
for(;;) {
set_current_state(TASK_INTERRUPTIBLE );
schedule( );
/* now in TASK_RUNNING state */
while (local_softirq_pending( )) {
preempt_disable();
do_softirq( );
preempt_enable();
cond_resched( );
}
}
当内核线程被唤醒时,就检查local_softirq_pending()中的软中断位掩码并在必要时调用do_softirq( )。如果没有挂起的软中断,函数把当前进程状态置为TASK_INTERRUPTIBLE,随后,如果当前进程需要就调用cond_resched()函数来实现进程切换。(当前thread_info的TIF_NEED_RESCHED标志被设置)
ksoftirqd/n内核线程的目的是:为重要而难以平衡的多CPU负载问题提供了解决方案。如果感兴趣的兄弟们可以深入研究一下,这里还是很有意思的。
tasklet是I/O驱动程序中实现可延迟函数的首选方法。
如前所述,tasklet建立在两个叫做HI_SOFTIRQ和TASKLET_SOFTIRQ的软中断之上。几个tasklet可以与同一个软中断相关联,每个tasklet执行自己的函数。两个软中断之间没有真正的区别,只不过do_softirq()先执行HI_SOFTIRQ的tasklet,后执行TASKLET_SOFTIRQ的tasklet。
tasklet和高优先级的tasklet分别存放在tasklet_vec和tasklet_hi_vec数组中。二者都包含类型为tasklet_head的NR_CPUS个元素,每个元素都由一个指向tasklet描述符链表的指针组成。tasklet描述符是一个tasklet_struct类型的数据结构。
tasklet是个很重要的概念,特别对于那些编写驱动程序的兄弟们。让我们假定,你正在写一个设备驱动程序,且想使用tasklet,应该做些什么呢?首先,你应该分配一个新的tasklet_struct数据结构,并调用tasklet_init()初始化它;该函数接收的参数为tasklet描述符的地址、tasklet函数的地址和它的可选整型参数。
随后,我们可以调用tasklet_disable_nosync()或tasklet_disable()可以选择性地禁止tasklet。这两个函数都增加tasklet描述符的count字段,但是后一个函数只有在tasklet函数己经运行的实例结束后才返回。为了重新激活你的tasklet,可以调用tasklet_enable()。
为了激活tasklet,你应该根据自己tasklet需要的优先级,调用tasklet_schedule()函数或tasklet_hi_schedule()函数。这两个函数非常类似,每个都执行下列操作:
1. 检查TASKLET_STATE_SCHED标志;如果设置则返回(tasklet已经被调度)。
2. 调用local_irq_save保存IF标志的状态并禁用本地中断。
3. 在tasklet_vec[n]或tasklet_hi_vec[n]指向的链表的起始处增加tasklet描述符(n表示本地CPU的逻辑号)。
4. 调用raise_softirq_irqoff()激活TASKLET_SOFTIRQ或HI_SOFTIRQ类型的软中断。(这个函数与raise softirq()函数类似,只是raise_softirq_irqoff()函数假设已经禁用了本地中断。)
5. 调用local_irq_restore恢复IF标志的状态。
最后,让我们看一下tasklet如何被执行。我们从前一节知道,软中断函数一旦被激活,就由do_softirq()函数执行。与HI_SOFTIRQ软中断相关的软中断函数叫做tasklet_hi_action () ,而与TASKLET_SOFTIRQ相关的函数叫做tasklet_action()。这两个函数非常相似,它们都执行下列操作:
1. 禁用本地中断。
2. 获得本地CPU的逻辑号n。
3. 把tasklet_vec[n]或tasklet_hi_vec[n]指向的链表的地址存入局部变量list。
4. 把tasklet_vec[n]或tasklet_hi_vec[n]的值赋为NULL,因此,已调度的tasklet描述符的链表被清空。
5. 打开本地中断。
6. 对于list指向的链表中的每个tasklet描述符:
a) 在多处理器系统上,检查tasklet的TASKLET_STATE_RUN标志。
i. 如果该标志被设置,说明同类型的一个tasklet正在另一个CPU上运行,因此,就把任务描述符重新插入到由tasklet_vec[n]或tasklet_hi_vec[n]指向的链表中,并再次激活TASKLET_SOFTIRQ或HI SOFTIRQ软中断。这样,当同类型的其他tasklet在其他CPU上运行时,这个tasklet就被延迟。
ii. 如果TASKLET_STATE_RUN标志未被设置,tasklet就没有在其他CPU上运行,就需要设置这个标志,以便tasklet函数不能在其他CPU上执行。
b) 通过查看tasklet描述符的count字段,检查tasklet是否被禁止。如果是,就清TASKLET_STATE_RUN标志,并把任务描述符重新插入到由tasklet_vec[n]或tasklet_hi_vec[n]指向的链表中,然后函数再次激活TASKLET_SOFTIRQ或HI_SOFTIRQ软中断。
c) 如果tasklet被激活,清TASKLET_STATE-SCHED标志,并执行tasklet函数。
注意,除非tasklet函数重新激活自己,否则,tasklet的每次激活至多触发tasklet函数的一次执行。
在Linux 2.6中引入了工作队列。它允许内核函数被激活,而且稍后由一种叫做工作者线程(worker thread)的特殊内核线程来执行。
注意!千万不要混淆,工作队列不是可延迟函数。可延迟函数和工作队列非常相似,但是它们的区别还是很大的。主要区别在于:可延迟函数运行在中断上下文中,而工作队列中的函数运行在进程上下文中。那么,为什么要发明工作队列这个东西呢,因为有一些执行可阻塞的函数(例如:需要访问磁盘数据块的函数)的唯一方式是在进程上下文中运行。因为在中断上下文中不可能发生进程切换。可延迟函数和工作队列中的函数都不能访问进程的用户态地址空间。事实上,一方面,可延迟函数被执行时不可能有任何正在运行的进程。另一方面,工作队列中的函数是由内核线程来执行的,因此,根本不存在它要访问的用户态地址空间。
工作队列的数据结构
与工作队列相关的主要数据结构是名为workqueue_struct的描述符,它包括一个有NR_CPUS个元素的数组,NR_CPUS是系统中CPU的最大数量(在多处理器系统中复制工作队列数据结构的原因是每CPU本地数据结构产生更有效的代码)。每个元素都是cpu_workqueue_struct类型的描述符,cpu_workqueue_struct结构的worklist字段是双向链表的头,链表集中了工作队列中的所有挂起函数。work_struct数据结构用来表示每一个挂起函数,它的字段如下所示:
pending:如果函数已经在工作队列链表中,该字段值设为1,否则设为0
entry:指向挂起函数链表前一个或后一个元素的指针
func:挂起函数的地址
data:传递给挂起函数的参数,是一个指针
wq_data:通常是指向cpu_workqueue_struct描述符的父结点的指针
timer:用于延迟挂起函数执行的软定时器
工作队列函数
create_workqueue("foo")函数接收一个字符串作为参数,返回新创建工作队列的workqueue_struct描述符的地址。该函数还创建n个工作者线程(n是当前系统中有效运行的CPU的个数),并根据传递给函数的字符串为工作者线程命名,如:foo/0, foo/1等等。
create_singlethread_workqueue()函数与之相似,但不管系统中有多少个CPU,create_singlethread_workqueue()函数都只创建一个工作者线程。内核调用destroy_workqueue()函数撤消工作队列,它接收指向workqueue_struct数组的指针作为参数。
queue_work()(封装在work_struct描述符中)把函数插入工作队列,它接收wq和work两个指针。wq指向workqueue_struct描述符,work指向work_struct描述符。queue_work()主要执行下面的步骤:
1.检查要插入的函数是否已经在工作队列中(work->pending字段等于1),如果是就结束。
2.把work_struct描述符加到工作队列链表中,然后把work->pending置为1。
3.如果工作者线程在本地CPU的cpu_workqueue_struct描述符的more_work等待队列上睡眠,该函数唤醒这个线程。
queue_delayed_work()函数和queued_work()几乎是相同的,只是queue_delayed_work()函数多接收一个以系统滴答数来表示时间延迟的参数,它用于确保挂起函数在执行前的等待时间尽可能短。事实上,queue_delayed_work()依靠软定时器(work_struct描述符的timer字段)把work_struct描述符插入工作队列链表的实际操作向后推迟了。如果相应的work_struct描述符还没有插入工作队列链表,cancel_delayed_work()就删除曾被调度过的工作队列函数。
每个工作者线程在worker_thread()函数内部不断地执行循环操作,因而,线程在绝大多数时间里处于睡眠状态并等待某些工作被插入队列。工作线程一旦被唤醒就调用run_workqueue()函数,该函数从工作者线程的工作队列链表中删除所有work_struct描述符并执行相应的挂起函数。由于工作队列函数可以阻塞,因此,可以让工作者线程睡眠,甚至可以让它迁移到另一个CPU上恢复执行。
有些时候,内核必须等待工作队列中的所有挂起函数执行完毕。flush_workqueue()函数接收workqueue_struct描述符的地址,并且在工作队列中的所有挂起函数结束之前使调用进程一直处于阻塞状态。但是该函数不会等待在调用flush_workqueue()之后新加入工作队列的挂起函数,每个cpu_workqueue_struct描述符的remove_sequence字段和insert_sequence字段用于识别新增加的挂起函数。
预定义工作队列
在绝大多数情况下,为了运行一个函数而创建整个工作者线程开销太大了。因此,内核引入叫做events的预定义工作队列,所有的内核开发者都可以随意使用它。预定义工作队列只是一个包括不同内核层函数和I/O驱动程序的标准工作队列,它的workqueue_struct描述符存放在keventd_wq数组中。为了使用预定义工作队列,内核提供下表中列出的函数:
预定义工作队列函数 |
等价的标准工作队列函数 |
schedule_work(w) |
queue_work(keventd_wq,w) |
schedule_delayed_work(w,d) |
queue_delayed_work(keventd_wq,w,d) (在任何CPU上) |
schedule_delayed_work_on(cpu,w,d) |
queue_delayed_work(keventd_wq,w,d) (在任何 CPU上) |
flush_scheduled_work( ) |
flush_workqueue(keventd_wq) |
当函数很少被调用时,预定义工作队列节省了重要的系统资源。另一方面,不应该使在预定义工作队列中执行的函数长时间处于阻塞状态。因为工作队列链表中的挂起函数是在每个CPU上以串行的方式执行的,而太长的延迟对预定义工作队列的其他用户会产生不良影响。
除了一般的events队列,在Linux2.6中你还会发现一些专用的工作队列。其中最重要的是块设备层使用的kblockd工作队列。