1、摘要 本文主要从使用者的角度对 Linux 2.6 内核的下半部机制 softirq、tasklet 和 workqueue进行分析,对于这三种机制在内核中的具体实现并未进行深入分析,倘若读者有兴趣了解,可以直接阅读 Linux 内核源代码的相关部分。1 概述中断服务程序往往都需要在 CPU 关中断的情况下运行,以避免中断嵌套而使控制复杂化,但是关中断的时间又不能太长,否则会造成中断信号的丢失。为此,在 Linux 中,将中断处理程序分为两部分,即上半部和下半部。上半部通常用于执行跟硬件关系密切的关键程序,这部分执行时间非常短,而且是在关中断的环境下运行的。对时间要求不是很严格,而且通常比较耗
2、时的一些操作,则交给下半部来执行,这部分代码是在开中断中执行的。上半部处理硬件相关,称为硬件中断,这通常需要立即执行。下半部则可以延迟一定时间,在内核合适的时间段来执行程序,这就是我们这里要讨论的软中断。本文以目前最新版本的 Linux 内核 2.6.22 为例,来讨论 Linux 的中断下半部机制。在 2.6 版本的内核中,下半部机制主要由 softirq、tasklet 和 workqueue 来实现,下面着重对这 3 种机制进行分析。2 Linux 2.6 内核中断下半部机制老版本的 Linux 内核中,下半部是以一种叫做 Bottom Half(简称为 BH)的机制来实现的,最初它是借
3、助中断向量来实现的,在系统中用一组(共 32 个)函数指针,分别表示32 个中断向量,这种实现方式目前在 2.4 版本的内核中还可以看到它的身影。但是目前在2.6 版本的内核中已经看不到它了。现在的 Linux 内核,一般以一种称为 softirq 的软中断机制来实现下半部。2.1 softirq 机制原来的 BH 机制有两个明显的缺陷:一是系统中一次只能有一个 CPU 可以执行 BH 代码,二是 BH 函数不允许嵌套。这在单处理器系统中或许没关系,但在 SMP 系统中却是致命的缺陷。但是软中断机制就不一样了。Linux 的 softirq 机制与 SMP 是紧密相连的,整个softirq 机
4、制的设计与实现始终贯穿着一个思想:“谁触发,谁执行” (Who marks, who runs) ,也就是说,每个 CPU 都单独负责它所触发的软中断,互不干扰。这就有效地利用了 SMP 系统的性能和特点,极大地提高了处理效率。Linux 在 include/linux/interrupt.h 中定义了一个 softirq_action 结构来描述一个 softirq 请求,如下所示:structsoftirq_actionvoid (*action)(struct softirq_action *);void *data;其中,函数指针 action 指向软中断请求的服务函数,而 data
5、则指向由服务函数自行解释的参数数据。基于上述结构,系统在 kernel/softirq.c 中定义了一个全局的 softirq 软中断向量表softirq_vec32,对应 32 个 softirq_action 结构表示的软中断描述符。但实际上,Linux 并没有使用到 32 个软中断向量,内核预定义了一些软中断向量的含义供我们使用:enumHI_SOFTIRQ=0,TIMER_SOFTIRQ,NET_TX_SOFTIRQ,NET_RX_SOFTIRQ,BLOCK_SOFTIRQ,TASKLET_SOFTIRQ,SCHED_SOFTIRQ,#ifdef CONFIG_HIGH_RES_TIM
6、ERSHRTIMER_SOFTIRQ,#endif;其中 HI_SOFTIRQ 用于实现高优先级的软中断,比如高优先级的 hi_tasklet,而TASKLET_SOFTIRQ 则用于实现诸如 tasklet 这样的一般性软中断。关于 tasklet,我们在后面会进行介绍。我们不需要使用到 32 个软中断向量,事实上,内核预定义的软中断向量已经可以满足我们绝大多数应用的需求。其他向量保留给今后内核扩展使用,我们不应去使用它们。要使用 softirq,我们必须先初始化它。我们使用 open_softirq()函数来开启一个指定的软中断向量 nr,初始化 nr 对应的描述符 softirq_vec
7、nr,设置所有 CPU 的软中断掩码的相应位为 1。函数 do_softirq()负责执行数组 softirq_vec32中设置的软中断服务函数。每个CPU 都是通过执行这个函数来执行软中断服务的。由于同一个 CPU 上的软中断服务例程不允许嵌套,因此,do_softirq()函数一开始就检查当前 CPU 是否已经正处在中断服务中,如果是则立即返回。在同一个 CPU 上,do_softirq()是串行执行的。使用 open_softirq()注册完一个软中断之后,我们需要触发它。内核使用函数 raise_softirq()来触发一个软中断。对于一个指定的 softirq 来说,只会有一个处理函
8、数,这个处理函数是所有 CPU 共享的。由于同一个 softirq 的处理函数可能在不同的 CPU 上同时执行,并产生竞争条件,处理函数本身的同步机制是非常重要的。激活一个软中断一般在中断的上半部中执行。当一个中断处理程序想要激活一个软中断时,raise_softirq()就会被调用。在后来的某个时刻,当 do_softirq()在某个 CPU 上运行时,就会调用相关的软中断处理函数。需要注意的是,在 softirq 机制中,还包含有一个很小的内核线程 ksoftirqd。这是为了平衡系统负载而设的。试想,如果系统一直不断触发软中断请求,CPU 就会不断地去处理软中断,因为至少每次时钟中断都会
9、执行一次 do_softirq()。这样一来,系统中其他重要任务不是要因长期得不到 CPU 而一直处于饥饿状态吗?在系统繁忙的时候,这个小小的内核线程就显得特别有用了,过多的软中断请求会被放到系统合适的时间段执行,给其他进程更多的执行机会。在 2.6 内核中,do_softirq()被放到 irq_exit()中执行。在中断上半部的处理中,只在 irq_exit()中才调用 do_softirq()进行软中断的处理,这非常有利于软中断模块的升级和移植。如果需要在我们的 NGSA 中移植 Linux 的软中断,这样的处理确实给了我们许多便利,因为我们只需要对我们的中断上半部的执行作很小的改动。如
10、果在中断上半部有许多软中断调用的入口,那我们的移植岂不是会很痛苦?可能有人会产生这样的疑问:系统中最多可以有 32 个 softirq,那么这么多 softirq,CPU 是如何查找的呢?显然,我们在执行 raise_softirq()对软中断进行触发时,必须要有一个很好的机制保证这个触发动作能够快速准确地进行。在 Linux 中,我们使用一种结构irq_cpustat_t 来组织软中断。它在 include/asm-xxx/hardirq.h 中定义,其中 xxx 表示相应的处理器体系结构。比如对于 PowerPC 处理器,这个结构在 include/asm-powerpc/hardirq.
11、h 中定义如下:typedef struct unsigned int _softirq_pending; /* set_bit is used on this */unsigned int _last_jiffy_stamp; _cacheline_aligned irq_cpustat_t;extern irq_cpustat_t irq_stat; /* defined in asm/hardirq.h */#define _IRQ_STAT(cpu, member) (irq_statcpu.member)其中,_softirq_pending 成员使用 bit map 的方式来指示相
12、应的 softirq 是否激活(即是否处于 pending 状态) 。raise_softirq 的主要工作就是在_softirq_pending 中设置 softirq 的相应位,它的实现如下:void fastcall raise_softirq(unsigned int nr)unsigned long flags;local_irq_save(flags);raise_softirq_irqoff(nr);local_irq_restore(flags);inline fastcall void raise_softirq_irqoff(unsigned int nr)_raise_s
13、oftirq_irqoff(nr);if (!in_interrupt()wakeup_softirqd(); /* 唤醒内核线程 ksoftirqd */#define _raise_softirq_irqoff(nr) do or_softirq_pending(1UL func = (_func); while (0)#define INIT_WORK(_work, _func) do (_work)-data = (atomic_long_t) WORK_DATA_INIT(); INIT_LIST_HEAD( PREPARE_WORK(_work), (_func); while (
14、0)其实只要用到 INIT_WORK 即可,PREPARE_WORK 在 INIT_WORK 中调用。工作队列的使用,其实也很简单。首先你需要建立一个工作队列,这一般通过函数create_workqueue(name)来实现,其中 name 是工作队列的名字。它会为每个 CPU 创建一个工作线程。当然,如果你觉得单线程用来处理你的工作已经足够,你也可以使用函数create_singlethread_workqueue(name)来创建单线程的工作队列。然后你需要把你所要做的工作提交给该工作队列。首先创建工作队列的任务,这在上面已经讲过了,接着使用函数queue_work(wq, work)把创
15、建好的任务提交给工作队列,其中 wq 是要提交任务的工作队列,work 是一个 work_struct 结构,就是你所要提交的任务。当你想要延后一段时间再提交你的任务,那么你可以使用 queue_delayed_work(wq, work, delay)来提交,delay 是你要延后的时间,以 tick 为单位, delay 保证你的任务至少在指定的最小延迟之后才可能得到执行。当然了,由于 delay 任务的提交需要用到 timer,因此你应当用另外一个结构 delayed_work 来替代 work_struct,它实际上是在 work_struct 结构的基础上再增加一个 timer 而已
16、:struct delayed_work struct work_struct work;struct timer_list timer;相应地,初始化工作任务的接口应该改为 DECLARE_DELAYED_WORK 和INIT_DELAYED_WORK:#define DECLARE_DELAYED_WORK(n, f) struct delayed_work n = _DELAYED_WORK_INITIALIZER(n, f)#define PREPARE_DELAYED_WORK(_work, _func) PREPARE_WORK( init_timer( while (0)工作队列
17、中的任务由相关的工作线程执行,可能是在一个无法预期的时间段内执行,这要取决于系统的负载、中断等等因素,或者至少要在延迟一段时间以后执行。如果你的任务在一个工作队列中等待了无限长的时间都无法得到运行,那么你可以用下面的方法取消它:int cancel_delayed_work(struct delayed_work *work);如果当一个取消操作的调用返回时任务正在执行,那么这个任务将会继续执行下去,不会因为你的取消而终止,但是它不会再加入到工作队列中来。你可以使用下面的方法清除工作队列中的所有任务:void flush_workqueue(struct workqueue_struct *w
18、q);如果工作队列中还有已经提交的任务还没执行完,那么内核会进入等待,直到所有提交的任务都执行完毕为止。flush_workqueue 确保所有提交的任务都能执行完,这在设备驱动关闭时候的处理程序中特别有用。当你用完了一个工作队列,你可以销毁它:void destroy_workqueue(struct workqueue_struct *queue);需要注意的是,destroy 一个 workqueue 时,如果队列上还有未完成的任务,该函数首先会执行它们。destroy 操作保证所有未处理的任务在工作队列被销毁之前都能顺利完成,所以你不必担心,当你想要销毁工作队列时,是否还有工作未完成。
19、由于工作队列运行在内核进程的上下文中,执行过程可能休眠,因此,工作队列处理的应该是那些不是很紧急的任务,通常在系统空闲时执行。在 workqueue 的初始化函数中,定义了一个针对内核中所有线程可用的事件工作队列keventd_wq,其他内核线程建立的事件工作结构就都挂到该队列上来:static struct workqueue_struct *keventd_wq _read_mostly;void _init init_workqueues(void)/* */keventd_wq = create_workqueue(“events“);/* */使用内核提供的事件工作队列 kevent
20、d_wq,事实上,你提交工作任务只需要使用schedule_work(work)或 schedule_delayed_work(work)即可。我们在编写设备驱动的时候,并非所有驱动程序都需要有自己的工作队列的。事实上,一个工作队列,在许多情况下,都不需要建立自己的工作队列。如果只偶尔提交任务给工作队列,简单地使用内核提供的共享的缺省工作队列,或许会更有效。不过,由于这个工作队列可能是由很多驱动程序共享的,任务可能会需要比较长的一段时间后才能开始执行。为了解决这个问题,工作函数的延迟应该保持最小,或者干脆不要。对于工作队列,有必要补充说明的一点是,工作队列是在 2.5 内核开发版本中引入的用来
21、替代任务队列的,它的数据结构比较复杂。或许到现在,你还对上面 3 个数据结构的关系感到混乱,理不出头绪来。在这里,我们把 3 个数据结构放在一起,对它们的关系进行一点说明。这 3 个数据结构的关系如下图所示:从上面的图可以看出,位于最高一层的是工作者线程(worker_thread) ,就是我们在cpu_workqueue_struct 结构中看到的 thread 成员。内核为每个 CPU 创建了一个工作者线程,关联一个 cpu_workqueue_struct 结构。每个工作者线程都是一个特定的内核线程,它们都会执行 worker_thread()函数,它初始化完毕后,就开始执行一个死循环并
22、休眠。当有任务提交给工作队列时,线程会被唤醒,以便执行这些任务,否则就继续休眠。工作处于最底层,用 work_struct 结构来描述。这个结构体最重要的一个部分是一个指针,它指向一个函数,正是该函数负责处理需要延后执行的具体任务。工作被提交给工作队列后,实际上是提交给某个具体的工作者线程,然后该线程会被唤醒并执行提交的工作。我们编写设备驱动的时候,通常大部分的驱动程序都是使用系统默认的工作者线程,它们使用起来简单、方便。但是在有些要求更严格的情况下,驱动程序需要使用自己的工作者线程。在这种情况下,系统允许驱动程序根据需要来创建工作者线程。也就是说,系统允许有多个类型的工作者线程存在,对于每种
23、类型,系统在每个 CPU 上都有一个该类的工作者线程,对应于一个 cpu_workqueue_struct 结构。而 workqueue_struct 结构则用于表示给定类型的所有工作者线程。这样,在一个 CPU 上就可能存在多个工作队列,每一个工作队列维护一个 cpu_workqueue_struct 结构,也就是关联一种类型的工作者线程。举个例子,我们的驱动在系统已有的默认工作者 events 类型(这是在 init_workqueues 中创建的系统默认工作者)的基础上,再自己加入一个 falcon 工作者类型:struct workqueue_struct *mydriver_wq;m
24、ydriver_wq = create_workqueue(“falcon“);并且我们在一台具有 4 个处理器的计算机上工作。那么现在系统中就有 4 个 events 类型的线程和 4 个 falcon 类型的线程(相应的,就有 8 个 cpu_workqueue_struct 结构体,分别对应 2 种类型的工作者。同时,会有一个对应 events 类型的 workqueue_struct 和一个对应falcon 类型的 workqueue_struct。在提交工作的时候,我们的工作会提交给一个特殊的falcon 线程,由它进行处理。3 几种下半部机制的比较Linux 内核提供的几种下半部机
25、制都用来推后执行你的工作,但是它们在使用上又有诸多差异,各自有不同的适用范围,使用时应该加以区分。Linux 2.6 内核提供的几种软中断机制都贯穿着“谁触发,谁执行”的思想,但是它们各自有不同的特点。softirq 是整个软中断框架体系的核心,是最底层的一种机制,内核程序员很少直接使用它,大部分应用,我们只需要使用 tasklet 就行了。内核提供了 32 个 softirq,但是仅仅使用了其中的几个。softirq 是在编译期间静态分配的,它不像 tasklet 那样能够动态地创建和删除。softirq 的软中断向量通过枚举对其含义进行预定义,这我们在前面 2.1节中可以看到。其中,HI_
26、SOFTIRQ 和 TASKLET_SOFTIRQ 这两个软中断都是通过 tasklet来实现的,而且也是用得最普遍的软中断。在 SMP 系统中,不同的 tasklet 可以在多个CPU 上并行执行,但是同一个 tasklet 在同一时刻只能在一个 CPU 上执行,这一点和softirq 不一样,softirq 都可以在多个 CPU 上同时执行,不管是不同的 softirq 还是同一softirq 的不同实例。tasklet 是利用软中断来实现的,它和 softirq 在本质上非常相近,行为表现也很接近,但是它的接口更简单,锁保护的要求也较低,因而也获得了更广泛的用途。通常,只有在那些执行频率
27、很高和连续性要求很高的情况下,我们才需要使用 softirq。HI_SOFTIRQ 和 TASKLET_SOFTIRQ 两个软中断依靠 tasklet 来实现,它们的差别仅仅在于 HI_SOFTIRQ 的优先级高于 TASKLET_SOFTIRQ,因此它会优先执行。前者称为高优先级的 tasklet,而后者则称为一般的 tasklet。workqueue 是另外一种能够使你的工作延后执行的机制。实际上它不是一种软中断机制,因为它和前面的两种机制都不一样,softirq 和 tasklet 通常运行于中断上下文中,而workqueue 则运行于内核进程的上下文中。之所以把它们放在一起讨论,是因为
28、它们都是用于把中断处理剩下的工作推后执行的一种下半部机制。工作队列可以把工作推后,交由一个内核线程来执行,因此它允许重新调度,甚至是睡眠,这在 softirq 和 tasklet 一般都是不允许的。如果你推后执行的任务不需要睡眠,那么你可以选择 softirq 或者 tasklet,但是如果你需要一个可以重新调度的实体来执行你的下半部处理,你应该使用工作队列。这是一种唯一能在进程上下文中运行的下半部实现机制,也只有它才可以睡眠。除了上面所说的差异,工作队列和软中断还有一点明显的不同,就是它可以指定一个明确的时间间隔,用来告诉内核你的工作至少要延迟到指定的时间间隔之后才能开始执行。另外,工作队列
29、在默认情况下和软中断一样,由最初提交工作的处理器负责执行延后的工作,但是它另外提供了一个接口 queue_delayed_work_on(cpu, wq, work, delay)用来提交任务给一个特定的处理器(如果是使用默认的工作队列,相应的可以使用 schedule_delayed_work_on(cpu, work, delay)来提交) 。这一点,也是工作队列和软中断不一样的地方。4 下半部机制的选择在各种下半部实现机制之间作出选择是很重要的。在目前的 2.6 版本内核中,有 3 种可能的选择,就是本文讨论的 3 种机制:softirq,tasklet,以及工作队列。tasklet 基
30、于softirq 实现,因此两者非常相近,而 workqueue 则不一样,它依靠内核线程来实现。从设计的角度考虑,softirq 提供的执行序列化保障是最少的,两个甚至更多个相同类别的softirq 可能在不同的处理器上同时执行,因此你必须格外小心地采取一些步骤确保共享数据的安全。如果被考察的代码本身多线索化的工作就做得非常好,比如网络子系统,它完全使用单处理器变量,那么 softirq 就是一个非常好的选择。对于时间要求严格和执行频率很高的应用来说,它执行得也最快。如果代码多线索化考虑得并不充分,那么选择 tasklet或许会更好一些,它的接口非常简单,而且由于同一类型的 tasklet
31、不能同时在多个 CPU上执行,所以它实现起来也比较简单一些。驱动程序开发者应尽可能选择 tasklet 而非softirq。tasklet 是有效的软中断,但是它不能并发运行。如果你可以确保软中断能够在多个处理器上安全运行,那么,你还是选择 softirq 比较合适。当你需要将任务推迟到进程上下文中完成,毫无疑问,你只能使用工作队列。工作队列的开销太大,因为它牵涉到内核线程甚至是上下文切换。所以如果进程上下文不是必须的,更确切地说,如果不需要睡眠,那么工作队列就应该尽量避免,softirq 和 tasklet 或许会更合适。这并不是说工作队列的工作效率就低,在大部分情况下,工作队列都能够提供足
32、够的支持。只是,在诸如网络子系统这样的环境中,时常经历的每秒钟几千次的中断,那么采用 softirq 或者 tasklet 机制可能会更合适一些。当然,从易于使用的角度来考虑,首推工作队列,其次才是 tasklet。最后才是 softirq,它必须静态地创建,并且需要慎重地考虑其实现,确保共享数据的安全。一般来说,驱动程序编写者经常需要做两个选择:首先,你是不是需要一个可调度的实体来执行需要推后的工作从根本上来讲,你有休眠的需要吗?如果有,那么,工作队列将是你唯一的选择。否则最好用 tasklet。其次,如果你必须专注于性能的提高,那么就考虑用 softirq 吧。这个时候,你还要考虑的一点是,该如何采取有效的措施,才能保证共享数据的安全。