我们前面在”中断处理”一节提到,在由内核执行的几个任务之间有些不是紧急的的;在必要情况下它们可以廷迟一段时间。回忆一下,一个中断处理程序的几个中断服务例程之间的串行执行的。并且通常在一个中断的处理程序结束前,不应该再次出现这个中断。相反,可廷迟中断可以在开中断的情况下执行。把可廷迟中断从中断处理程序中抽出来有助于使内核保持较短的响应时间。这对于那些期望它们的中断能在几毫秒内得到处理的”急迫”应用来说是非常重要的。
Linux2.6迎接这种挑战是通过两种非紧迫、可中断内核函数:所谓的可廷迟函数和通过工作队列来执行的函数。
软中断和tasklet有密切的关系,tasklet是在软中断之上实现。事实上,出现在内核代码中的术语”软中断”常常表示可廷迟函数的所有种类。另外一种被广泛使用的术语是”中断上下文”;表示内核当前正执行一个中断处理程序或一个可廷迟的函数。
软中断的分配是静态的,而tasklet的分配和初始化可以在运行是进行。软中断可以并发地运行在多个CPU上。因此,软中断是可重入函数而且必须明确地使用自旋锁保护其数据结构。tasklet不必担心这些问题,因为内核对tasklet的执行了更加严格的控制。相同类型的tasklet总是被串行地执行,换句话说就是:不能在两个CPU上同时运行相同类型的tasklet。但是,类型不同的tasklet可以在几个CPU上并发执行。tasklet的串行化使tasklet函数不必是可重入的,因此简化了设备驱动程序开发者的工作。
一般而言,在可廷迟函数上可以执行四种操作:
初始化
定义一个新的可廷迟函数;这个操作通常在内核自身初始化或加载模块时进行。
激活
标记一个可廷迟函数为”挂起”。激活可以在任何时候进行。
屏蔽
有选择地屏蔽一个可延迟函数,这样,即使它被激活,内核也不执行它。我们会在第五章”禁止和激活可延迟函数”一节看到,禁止可延迟函数有时是必要的。
执行
执行一个挂起的可延迟函数和同类型的其它所有挂起的可延迟函数;执行是在特定的时间进行的,这将在后面”软中断”一节解释。
激活和执行不知何故总是捆绑在一起;由给定CPU激活的一个可延迟函数必须在同一个CPU上执行。没有什么明显的理由说明这条规则对系统性能是有益的。把可延迟函数绑定在激活CPU上从理论上说可以理好利用CPU的硬件高速缓存。毕竟,可以想象,激活的内核线程访问的一些数据结构,可延迟函数也可能会使用。然后,当可延迟函数运行时,因为它的执行可以延迟一段时间,因此相关高速缓存行很可能就不再在高速缓存中了。此外,把一个函数绑定在一个CPU上总是有潜在”危险的”操作,因为一个CPU可能忙死而其它CPU又无所事事。
软中断
Linux2.6使用有限个软中断。在很多场合,tasklet是足够用的,且更容易编写,因为tasklet不必是可重入的。
事实上,如表4-9所示,目前只定义了六种软中断。
一个软中断的下标决定了它的优先级:低下标意味着高优先级,因为软中断函数将从下标0开始执行。
软中断所使用的数据结构
表示软中断的主要数据结构是softirq_vec数组,该数组包含类型为softirq_action的32个元素,一个软中断的优先级是相应的softirq_action元素在数组内的下标。如表4-9所示,只有数组的前六个元素被有效地使用。softirq_action数据结构包括两个字段;指向软中断函数的一个action指针和指向软中断函数需要的通过数据结构的data指针。
另外一个关键的字段是32位的preempt_count字段,用它来跟踪内核抢占和内核控制路径的嵌套,该字段存放在每个进程描述符的thread_info字段中。如表4-10所示,preempt_count字段的编码表示三个不同的计数器和一个标志。
第一个计数器记录显式禁用本地CPU内核抢占的次数,值等于0表示允许内核抢占。第二个计数器表示可延迟函数被禁用的程度。第三个计数器表示在本地CPU上中断处理程序的嵌套数。
给preempt_count字段起这个名字的理由是很充分的:当内核代码明确不允许发生抢占或当内核下在中断上下文中运行是,必须禁用内核的抢占功能。因此,为了确定是否能够抢占当前进程,内核快速检查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()函数用来激活软中断,它接受软中断下标nr做为参数,执行下面的操作:
执行local_irq_save宏以保存eflags寄存器IF标志的状态值并禁用本地CPU上的中断。
把软中断标记为挂起状态,这是通过设置本地CPU的软中断掩码中与下标nr相关位来实现的。
如果in_interrupt()产生为1的值,则跳转到第5步。这种情况说明:要么已经在中断上下文中调用了raise_softirq(),要么当前禁用了软中断。
否则,就在需要的时候去调用wakeup_softirqd()以唤醒本地CPU的ksoftirqd内核线程。
执行local_irq_restore宏,恢复在第1步保存的IF标志的状态值。
应该周期性地检查活动的软中断,检查是在内核代码的几个点上进行的。这在下列几种情况下进行。
当内核调用local_bh_enable()函数激活本地CPU的软中断时。
当do_IRQ()完成了I/O中断的处理是或调用irq_exit()宏时。
如果系统使用I/OAPIC,则当smp_apic_timer_interrupt()函数处理完本地定时器中断时。
在多处理器系统中,当CPU处理完被CALL_FUNCTION_VECTOR处理器间中断所触发的函数时。
当一个特殊的ksoftirqd/n内核线程被唤醒时。
do_softirq()函数
如果在这样的一个检查点检测到挂起的软中断,内核就调用do_softirq()来处理它们。这个函数执行下面的操作。
如果in_interrup()产生的值是1,则函数返回。这种情况说明要么在中断上下文中调用了do_softirq()函数,要么当前禁用软中断。
执行local_irq_save以保存IF标志的状态值,并禁止本地CPU上的中断。
如果thread_union的结构大小为4KB,那么在需要情况下,它切换到软中断请求栈。这一步与前面”I/O中断处理”一节do_IRQ()的第2步很相似,当然这里面使用的是数组softirq_ctx而不hardirq_ctx。
调用__do_softirq()函数。
如果在上面第3步成功切换到软中断请求栈,则把最初的栈指针恢复到esp寄存器中,这样就切换回到以前使用的异常栈。
执行local_irq_restore以恢复在第2步保存的IF标志的状态值并返回。
__do_softirq()函数
__do_softirq()函数读取本地CPU的软中断掩码并执行行与每个设置位相关的可延迟函数。由于正在执行一个软中断函数时可能出现新挂起的软中断,所以为了保证可延迟函数的低延迟性,__do_softirq()一直运行到执行完所有挂起的软中断。但是,这种机制可能迫使__do_softirq()运行很长一段时间,因而大大延迟用户态进程的执行。因此,__do_softirq()只做固定次数的循环,然后就返回。如果还有其余挂起的软中断,那么下一节要描述的内核线程ksoftirqd将会在预期的时间内处理它们。下面简单描述__do_softirq()函数执行的操作:
把循环计数器的值初始为10。
把本地CPU软中断的位掩码复制到局部变量pending中。
调用local_bh_disable()增加软中断计数器的值。在可延迟函数开始执行之前应该禁用它们,这似乎有点违反直觉,但确实极有意义。因为在绝大多数情况下可能会产生新的中断。当do_IRQ()执行irq_exit()宏时,可能有另外一个__do_softirq()函数的实例开始执行。这种情况是应该避免的,因为可延迟函数必须以串行的方式在CPU上运行。因此,__do_softirq()函数的第一实例禁用可延迟函数,以使每个新的函数实例将会在__do_softirq()函数的第1步就退出。
清除本地CPU的软中断位图,以便可以激活新的软中断。
执行local_irq_enable()来激活本地中断。
根据局部变量pending每一位的设置,执行对应的软中断处理函数。回忆一下,下标n的软中断函数的地址存放在softirq_vec[n]->action变量中。
执行local_irq_disable()以禁用本地中断。
把本地CPU的软中断位掩码复制到局部变量pending中,并且再次递减循环计数器。
如果pending不为0,那么从最后一次循环开始,至少有一个软中断被激活,而且循环计数器仍然是正数,跳转回到第4步。
如果还有更多的挂起软中断,则调用wakeup_softirqd()唤醒内核线程来处理本地CPU的软中断。
软中断计数器减1,因而重新激活可延迟函数。
Ksoftirqd内核线程
在最近的内核版本中,每个CPU都有自己的ksoftirqd内核线程。每个ksoftirqd/n内核线程都运行ksoftirqd()函数,该函数实际上执行下列的循环:
当内核线程被唤醒时,就检查local_softirq_pending()中的软中断位掩码并在必要时调用do_softirq()。如果没有挂起的软中断,函数把当前进程状态置为TASK_INTERRUPTIBLE,最后,如果当前进程需要就调用cond_resched()函数来实现进程切换。
ksoftirqd/n内核线程为重要而难以平衡的问题提供了解决方案。
软中断函数可以重新激活自己,实际上,网络软中断和tasklet软中断都可以这么做。此外,像网卡上数据包泛滥这样的外部事件可能以高频激活软中断。
软中断的连续高流量可能会产生问题,该问题就是由引入的内核线程来解决的。没有内核线程,开发者实际上就面临两种选择策略。
第一种策略就是忽略do_softirq()运行时新出现的软中断。换句话说,do_softirq()函数开始执行时,确定哪些软中断是挂起的,然后执行这些软中断的函数。接下来,do_softirq()不再重新检查挂起的软中断就终止。这种解决方法不是很好。假设一个软中断函数在do_softirq()执行期间被重新激活。在最坏情况下,即使机器空闲,也只有在下一次时钟中断到来时,该中断才被再执行。结果,对网络开发都来说,软中断的等待时间是不可接受的。
第二种策略在于不断地重新检查挂起的软中断。do_softirq()函数一直检索挂起的软中断,只有在没有挂起的软中断是才终止。尽管这种解决方法可能满足了网络开发者的愿望,但是,它肯定会使系统中的普通用户感到恼怒:如果网卡接收高频率的数据包流,或者如果一个软中断函数总是激活自己,那么,do_softirq()函数就会永远不返回,用户态程序实际上就停止执行。
ksoftirqd/n内核线程试图解决这种很难平衡的问题。do_softirq()函数确定哪些软中断是挂起的,并执行它们的函数。如果已经执行的软中断又被激活,do_softirq()函数则唤醒内核线程并终止。内核线程有较低的优先级,因此用户程序就有机会运行;但是,如果机器空闲,挂起的软中断就很快被执行。
Tasklet
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_CUPS个元素,每个元素都由一个指向tasklet描述符链表的指针组成。tasklet描述符是一个tasklet_struct类型的数据结构,其字段如表4-11所示。
Tasklet描述符的state字段含有两个标志:
TASKLET_STATE_SCHED
该标志被设置时,表示tasklet是挂起的;也意味着tasklet描述符被插入到tasklet_vec和tasklet_hi_vec数组的其中一个链表中。
TASKLET_STATE_RUN
该标志被设置是,表示tasklet正在被执行;在单处理器系统上不使用这个标志,因为没有必要检查特定的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()函数。这两个函数非常类似,其中每个都执行下列操作:
检查TASKLET_STATE_SCHED标志;如果设置则返回
调用local_irq_save保存IF标志的状态并禁用本地中断。
在tasklet_vec[n]或tasklet_hi_vec[n]指向的链表的起始处增加tasklet描述符。
调用raise_softirq_irqoff()激活TASKLET_SOFTIRQ或HI_SOFTIRQ类型的软中断。
调用local_irq_restore恢复IF标志的状态。
最后,让我们看一下tasklet如何被执行。我们从前一节知道,软中断函数一旦被激活,就由do_softirq()函数执行。与HI_SOFTIRQ软中断相关的软中断函数叫做tasklet_hi_action()。而与TASKLET_SOFTIRQ相关的函数叫做tasklet_action()。这两个函数非常相似。它们都执行下列操作:
禁用本地中断。
获得本地CPU的逻辑号。
把tasklet_vec[n]或tasklet_hi_vec[n]指向的链表的地址存入局部变量list.
把tasklet_vec[n]或tasklet_hi_vec[n]的值赋为NULL,因此,已调度的tasklet描述符的链表被清空。
打开本地中断。
对于list指向的链表中的每个tasklet描述符
注意,除非tasklet函数重新激活自己,否则,tasklet的每次激活至多触发tasklet函数的一次执行。
工作队列
在linux2.6中引入了工作队列,它与Linux2.4中的任务队列具有相似的构造,用来代替任务队列。它们允许内核函数被激活,而且稍后由一咱叫做工作者线程的特殊内核线程来执行。
尽管可延迟函数和工作队列非常相似,但它们的区别还是很大的。主要区别在于:可延迟函数运行在中断上下文中,而工作队列中的函数运行在进程上下文中。执行可阻塞函数的唯一方式是在进程上下文中运行。因为。在中断上下文中不可能发生进程切换。可延迟函数和工作队列中的函数都不能访问进程的用户态地址空间。事实上,可延迟函数被执行时不可能有任何正在运行的进程。另一方面,工作队列中的函数是由内核线程来执行的。因此,根本不存在它要访问的用户态地址空间。
工作队列的数据结构
与工作队列相关的主要数据结构是名为workqueue_struct的描述符,它包括一个有NR_CPUS个元素的数组,NR_CPUS是系统中CPU的最大数量。每个元素都是cpu_workqueue_struct类型的描述符,有关数据结构的字段如表4-12所示。
cpu_workqueue_struct结构的worklist字段是双向链表的头,链表集中了工作队列中的所有挂起函数。work_struct数据用来表示每一个挂起函数,它的字段如表4-13所示。
工作队列函数
create_workqueue(“foo”)函数接收一个字符串作为参数,返回新创建工作队列的workqueue_struct描述符的地址。该函数还创建n个工作者线程,并根据传递给函数的字符串为工作者线程命名,如:foo/0,foo/1等等。create_singlethread_workqueue()函数与之相似,但不管系统中有多少个CPU,create_singlethread_workqueue()函数都只创建一个工作者线程。内核调用destroy_workqueue()函数撤消工作队列,它接收指向workqueue_struct数组的指针作为参数。
queue_work()把函数插入工作队列,它接收wq和work两个指针。wq指向workqueue_struct描述符,work指向work_struct描述符。queue_work()主要执行下面的步骤:
检查要插入的函数是否已经在工作队列中,如果是就结束。
把work_struct描述符加到工作队列链表中,然后把work->pending置为1。
如果工作者线程在本地CPU的cpu_workqueue_struct描述符的more_work等待队列上睡眠,该函数唤醒这个线程。
queue_delayed_work()函数和queue_work()几乎是相同的,只有queue_delayed_work()函数多接收一个以系统滴答数来表示时间延迟的参数,它用于确保挂起函数在执行前的等待时间尽可能短。事实上,queue_delay_work()依靠软定时器把work_struct描述符插入工作队列链表的实际操作作向后推迟了。如果相应的work_struct描述符还没有插入工作队列链表。cancel_delayed_work()就删除曾被调度过的工作队列函数。
每个工作队列线程在worker_thread()函数内部不断地执行循环操作,因而,线程在大多数时间里处于睡眠状态并等待某些工作被插入队列。工作线程一旦被唤醒就调用run_workqueue()函数,该函数从工作都线程的工作队列链表中删除所有work_struct描述符并执行相应的挂起函数。由于工作队列函数可以阻塞,因此,可以让工作都线程睡眠,甚至可以让它迁移到另一个CPU上恢复执行。
有些时候,内核必须等待工作队列中的所有挂起函数执行完毕。flush_workqueue()函数接收workqueue_struct描述符的地址,并且在工作队列中的所有挂起函数结束之前使调用进程一直处于阻塞状态。但是该函数不会等待在调用flush_work_queue()之后新加入工作队列的挂起函数,每个cpu_workqueue_struct描述符的remove_sequence字段和insert_sequence字段用于识别新增加的挂起函数。
预定义工作队列
在绝大多数情况下,为了运行一个函数而创建整个工作者线程开销太大了。因此,内核引入叫做events的预定义工作队列,所有的内核开发者都可以随意使用它。预定义工作队列中是一个包括不同内核层函数和I/O驱动程序的标准工作队列,它的workqueue_struct描述述存放在keventd_wq数组中。为了使用预定义工作队列。内核提供表4-14中列出的函数。
当函数很少调用时,预定义工作队列节省了重要的系统资源。另一方面,不应该使在预定义工作队列中执行的函数长时间处于阻塞状态。因为工作队列链表中的挂起函数是在每个CPU上以串行方式执行的,而太长的延迟对预定义工作队列的其它用户会产生不良影响。
除了一般的的events队列,在Linux2.6中你还会发现一些专用的工作队列。其中最重要的是块设备层使用的kblockd工作队列。