引言
我们在上一篇文章中说道了中断是一种非常重要的机制,我们即希望它执行的更快,因为中断处理程序不但执行在中断上下文中,而且禁止了其他的中断,并且在执行完以后还需要返回给外设信息,这在一定程度上决定了系统的效率;我们还希望其完成更多的工作量,这就意味着更多的代码量,这显然是和上一条冲突的。此时就引出了Bottom half机制,见名思意,其实就是下半部执行,其实就是把有严格时限的工作交给上半部,把不那么重要的放在下半部,以此提高上半部程序的响应速度。
以网卡举例,当网卡接收到数据包的时候,会触发某个中断,显然网卡希望这些数据处理的越快越好,因为TCP窗口决定了下次收到的信息大小,如果数据处理过慢的话会导致数据的整体传输速度过慢,吞吐量过低。比如网络包的逐层分解就不会放到上半部,上半部要做的就是拷贝数据,以及对网卡做出回复。
总的来说仅仅使用上半部中断处理程序有以下几个不好的地方:
- 中断处理程序以异步方式执行,它一般会打断其他程序执行,为了避免被打断的程序执行时间过长,中断处理程序应该执行的越快越好。
- 如果IRQF_DISABLED没有被设置,只会屏蔽此中断;如果被设置的话会屏蔽此处理器上所有的其他中断,所以中断处理程序应该执行的越快越好。
- 中断处理程序运行在中断上下文中,这意味这中断处理程序无法使用阻塞的函数,这限制了其作用范围。
- 中断处理程序需要对硬件进行操作,所以它们通常由很高的时限要求。
由于以上原因,操作系统引入了下半部分执行程序,针对于对时间要求相对宽松的处理程序,以此提升中断处理程序的效率和增加所能完成的功能。
实现下半部的中断处理有多种方法,各有各的优势所在,在内核2.6版本时有以下几种:
- 软中断
- tasklet
- workqueue
这里有一个有意思的误解,有很大一部分人把下半部成为软中断,也就是说把我们上面提到的实现下半部的一种和整个下半部都称为软中断。但其实并不是这么一回事,软中断,tasklet,workqueue并驾齐驱!
软中断 softirq
软中断的代码位于kernel/softirq.c。
软中断机制是一种静态机制,是一个包含32个softirq_action结构体的数组,softirq_action结构如下。
struct softirq_action
{
void (*action)(struct softirq_action *);
void *data;
};
值得一提的是,软中断不会抢占另一个不同的软中断,但是相同的软中断程序可能在不同的处理器上同时执行,这就意味着我们必须格外在意数据的并发访问。唯一可以抢占软中断的就是中断处理程序。软中断的执行很有意思,使用了位图标记的方式。首先我们要确定什么时候需要执行软中断,在以下地方,已经被触发的软中断会被检查和执行:
- 从一个中断处理程序处返回的时候。
- 在ksoftirqd内核线程中(与tasklet有关)。
- 显示检查和执行待处理的软中断的代码中,比如网络子系统。
如何标志软中断呢?我们前面提到软中断是使用位图来标记哪一个具体的软中断会在下次执行操作中被执行,这其实在权衡下也是最简单的方法,因为软中断机制是静态的,一个int刚好能存下全部的触发信息。
我们可以通过在<Linux/interrupt.h>中加入一个枚举变量来静态的声明一个软中断,使用open_softirq
注册一个已经声明过的软中断,这样我们就可以通过raise_softirq
来触发这个软中断了,当触发过以后,在下一次执行do_softirq
时就会执行已经触发过的软中断了。
tasklet
其实tasklet也是软中断的一种,从实现上来说tasklet就是已注册的软中断的一项,所以其实tasklet也是软中断的一种,只不过tasklet通过一些特殊的手段实现了动态的扩展。使得我们可以使用多于32种软中断处理程序,当然还有其他的不同点,我们后面还会再说。
那么tasklet是如何实现的呢?我们来看看软中断的描述符表:
static struct softirq_action softirq_vec[NR_SOFTIRQS] __cacheline_aligned_in_smp;
enum
{
HI_SOFTIRQ=0, /*用于高优先级的tasklet*/
TIMER_SOFTIRQ, /*用于定时器的下半部*/
NET_TX_SOFTIRQ, /*用于网络层发包*/
NET_RX_SOFTIRQ, /*用于网络层收报*/
BLOCK_SOFTIRQ, /*block装置*/
BLOCK_IOPOLL_SOFTIRQ,
TASKLET_SOFTIRQ, /*用于低优先级的tasklet*/
SCHED_SOFTIRQ, /*调度程序*/
HRTIMER_SOFTIRQ, /*高分辨率定时器*/
RCU_SOFTIRQ, /* Preferable RCU should always be the last softirq */
NR_SOFTIRQS
};
我们可以看到其中的HI_SOFTIRQ
和BLOCK_SOFTIRQ
其实就是用于实现tasklet的软中断表项。
具体的实现流程就是在已经调度的tasklet的放在两个tasklet_struct结构的的链表中,注册就是把一个结构插入链表,执行就是执行其中的一条链表上去全部的回调。我们来看看task_struct结构:
struct tasklet_struct
{
struct tasklet_struct *next; // 链表中的下一项
unsigned long state; // tasklet的状态
atomic_t count; // 引用计数器
void (*func)(unsigned long); // 回调函数
unsigned long data; // 给tasklet处理函数的参数
};
tasklet由tasklet_schedule
和tasklet_hi_schedule
进行调度,它们可以把一个tasklet_struct插入链表,在执行的时候可以执行每一项中的回调函数。值得一提的是在tasklet进行执行的时候,会检查每一项中的status,这样可以保证同一段代码不会被同时执行,但这样必然要在status处加锁。
那么一个tasklet的具体执行流程是怎么样的呢?
- 执行do_softirq,执行已经触发的软中断处理程序。
- 禁止中断。
- 将处理器上对应的链表设置为NULL。
- 循环遍历获取每一个待处理的tasklet。
- 如果是多处理器系统,通过检查states来判断是否正在其他处理器上执行。
- 如果没有执行的话,就设置为TASKLET_STATE_RUN来禁止其他处理器执行这个tasklet
- 检查count,保证tasklet没有被禁止。
- 以如上步骤运行tasklet,直到链表为空。
一个简洁而有效的机制就满足了我们的需求。
但是有一个问题,就是处理函数有时会重新触发自己以再次执行,这就有了两个解决方法,就是全部的被触发的中断在此次中断中被直接执行,或者在下一次中断被触发的时候执行。这两种方法各有各的缺陷。
前者会在负载较高的时候会导致系统一直在执行中断,导致用户空间的任务被忽略了。而在用户负载较低的时候会导致要在下一次中断返回的时候才被触发,这就意味着一定要等一段时间。所以开发者要在这两个地方进行折中。这个方案就是ksoftirqd
。
每个处理器都会有一个线程,只要由待处理的软中断,ksoftirqd就会调用do_softirq()去处理它们。这样就避免了上面的问题。
workqueue工作机制
这是一个与软中断完全不同的将工作推后执行的机制,与软中断最大的不同就是软中断运行在中断上下文中,工作队列运行在进程上下文,这意味着我们的操作范围大了很多,因为可以执行阻塞的操作了。这个不在详细说明了,参考[1]即可。
如何选择
首先软中断在机制上没有保证任何的序列化,这意味我们必须采取一些步骤确保数据安全,而这是比较浪费时间的,但是如果我们让代码本身设计的比较精巧的话,比如网络的子系统,软中断就是一个很好的选择,总而言之,软中断适合与对时间要求严格和执行频率很高的应用。
而tasklet也是软中断的一种,且没有数量上限,且两个相同类型的tasklet不会同时执行,这也保证了每一个tasklet不会出现数据竞争。所以如果需要运行在中断上下文,且对时间要求不严苛的话,就可以使用tasklet。
前两者有一个很致命的问题,就是只能运行在中断上下文,中断上下文没有办法执行阻塞的函数,所以限制了软中断可以做的事情,workqueue则可以运行在进程上下文,解决了这些问题,但是效率较低。
这里面有一个隐晦的问题,就是锁的问题,因为中断可以抢占软中断,软中断可以抢占运行在进程上下文的代码,所以如果进程上下文和一个下半部共享数据,在访问数据之前,必须禁止下半部的处理并得到锁的使用权。在下半部访问共享数据之前也要禁止中断并得到锁的控制权,否则可能出现死锁。
参考:
- 博文《工作队列 ( workqueue )》
- 书籍《Linux内核设计与实现》
- 博文《Linux内核中的下半部机制之tasklet》
- 博文《中断下半部处理之tasklet》